mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-09 03:39:37 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
985a612273 | ||
|
|
9b4cc1d472 | ||
|
|
ad5377900b | ||
|
|
a0f2d9d11a | ||
|
|
7428bed13f | ||
|
|
fbc191c2a2 | ||
|
|
8a474d680d | ||
|
|
eb120376f7 | ||
|
|
d376d88d74 | ||
|
|
f0c31ba8ec | ||
|
|
439bf42eec | ||
|
|
fcaeba6894 | ||
|
|
8a4c2c2c43 | ||
|
|
0b2e6ff69b | ||
|
|
1050d66970 | ||
|
|
bf483f3bd6 |
No files matched your search
@@ -0,0 +1,68 @@
|
||||
name: Quick Debug Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
debug-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
|
||||
- name: Install UV
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system duckduckgo_search
|
||||
uv pip install --system pytest pytest-cov pytest-asyncio pytest-timeout
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
|
||||
- name: Set environment variables
|
||||
run: |
|
||||
echo "OPENAI_API_KEY=sk-test-key-for-github-actions-testing-only-not-real" >> $GITHUB_ENV
|
||||
echo "OPENAI_API_BASE=https://api.openai.com/v1" >> $GITHUB_ENV
|
||||
echo "OPENAI_MODEL_NAME=gpt-4o-mini" >> $GITHUB_ENV
|
||||
echo "LOGLEVEL=WARNING" >> $GITHUB_ENV
|
||||
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
|
||||
|
||||
- name: Run Unit Tests
|
||||
id: pytest
|
||||
run: |
|
||||
cd src/praisonai
|
||||
# Run pytest and save output, don't fail immediately
|
||||
python -m pytest tests/unit/ -v --tb=short > test_output.txt 2>&1 || echo "TESTS_FAILED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Show Test Results
|
||||
if: always()
|
||||
run: |
|
||||
cd src/praisonai
|
||||
echo "=========================================="
|
||||
echo "=== FULL TEST OUTPUT ==="
|
||||
echo "=========================================="
|
||||
cat test_output.txt
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "=== FAILED TESTS SUMMARY ==="
|
||||
echo "=========================================="
|
||||
grep "^FAILED" test_output.txt || echo "No FAILED lines"
|
||||
echo ""
|
||||
# Fail if tests failed
|
||||
if [ "$TESTS_FAILED" = "true" ]; then
|
||||
echo "Tests failed!"
|
||||
exit 1
|
||||
fi
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.7.3" \
|
||||
"praisonai>=2.7.7" \
|
||||
"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>=2.7.3" \
|
||||
"praisonai>=2.7.7" \
|
||||
"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>=2.7.3" \
|
||||
"praisonai>=2.7.7" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ examples/
|
||||
│ ├── memory/ # Memory and sessions
|
||||
│ ├── code/ # Code editing and external CLI tools
|
||||
│ └── ...
|
||||
├── serve/ # Server and endpoints examples
|
||||
├── yaml/ # YAML workflow examples
|
||||
└── cookbooks/ # Complete use-case examples
|
||||
```
|
||||
@@ -31,6 +32,22 @@ examples/
|
||||
| **Code** | [python/code/](python/code/) | [📖](https://docs.praison.ai/code) |
|
||||
| **YAML** | [yaml/](yaml/) | [📖](https://docs.praison.ai/features/yaml-workflows) |
|
||||
| **Doctor** | [doctor/](doctor/) | [📖](https://docs.praison.ai/cli/doctor) |
|
||||
| **Serve** | [serve/](serve/) | [📖](https://docs.praison.ai/cli/serve) |
|
||||
| **Endpoints** | [serve/](serve/) | [📖](https://docs.praison.ai/cli/endpoints) |
|
||||
|
||||
## Serve Examples
|
||||
|
||||
| Example | Description | CLI Command |
|
||||
|---------|-------------|-------------|
|
||||
| [unified_server.py](serve/unified_server.py) | All providers in one server | `praisonai serve unified` |
|
||||
| [agent_as_api_single.py](serve/agent_as_api_single.py) | Single agent HTTP API | `praisonai serve agents` |
|
||||
| [agents_as_api_router.py](serve/agents_as_api_router.py) | Multi-agent router API | `praisonai serve agents` |
|
||||
| [a2a_server_client.py](serve/a2a_server_client.py) | A2A protocol server | `praisonai serve a2a` |
|
||||
| [a2u_events_stream.py](serve/a2u_events_stream.py) | A2U event stream | `praisonai serve a2u` |
|
||||
| [mcp_http_server.py](serve/mcp_http_server.py) | MCP HTTP server | `praisonai serve mcp` |
|
||||
| [tools_as_mcp_server.py](serve/tools_as_mcp_server.py) | Tools as MCP server | `praisonai serve tools` |
|
||||
| [agent_launch_modes.py](serve/agent_launch_modes.py) | Agent.launch() API | Python only |
|
||||
| [endpoints_unified_client.py](serve/endpoints_unified_client.py) | Unified client | `praisonai endpoints` |
|
||||
|
||||
## Running Examples
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# PraisonAI MCP Server Examples
|
||||
|
||||
This directory contains examples for running PraisonAI as an MCP (Model Context Protocol) server.
|
||||
|
||||
## Overview
|
||||
|
||||
PraisonAI can expose its capabilities via MCP protocol, allowing integration with:
|
||||
- Claude Desktop
|
||||
- Cursor
|
||||
- Windsurf
|
||||
- VSCode MCP clients
|
||||
- Any MCP-compatible client
|
||||
|
||||
## Protocol Version
|
||||
|
||||
These examples use MCP Protocol Version **2025-11-25**.
|
||||
|
||||
## Examples
|
||||
|
||||
### 1. STDIO Server (stdio_server.py)
|
||||
|
||||
Run PraisonAI as an MCP server using STDIO transport (for Claude Desktop integration).
|
||||
|
||||
```bash
|
||||
# Run directly
|
||||
python stdio_server.py
|
||||
|
||||
# Or via CLI
|
||||
praisonai mcp serve --transport stdio
|
||||
```
|
||||
|
||||
### 2. HTTP Stream Server (http_stream_server.py)
|
||||
|
||||
Run PraisonAI as an MCP server using HTTP Stream transport.
|
||||
|
||||
```bash
|
||||
# Run directly
|
||||
python http_stream_server.py
|
||||
|
||||
# Or via CLI
|
||||
praisonai mcp serve --transport http-stream --port 8080
|
||||
```
|
||||
|
||||
### 3. Custom Tools Server (custom_tools_server.py)
|
||||
|
||||
Register custom tools and expose them via MCP.
|
||||
|
||||
```bash
|
||||
python custom_tools_server.py
|
||||
```
|
||||
|
||||
### 4. Client Example (mcp_client_example.py)
|
||||
|
||||
Example of connecting to a PraisonAI MCP server as a client.
|
||||
|
||||
```bash
|
||||
python mcp_client_example.py
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
# Start STDIO server
|
||||
praisonai mcp serve --transport stdio
|
||||
|
||||
# Start HTTP Stream server
|
||||
praisonai mcp serve --transport http-stream --port 8080
|
||||
|
||||
# Start with authentication
|
||||
praisonai mcp serve --transport http-stream --api-key YOUR_KEY
|
||||
|
||||
# List available tools
|
||||
praisonai mcp list-tools
|
||||
|
||||
# List available resources
|
||||
praisonai mcp list-resources
|
||||
|
||||
# List available prompts
|
||||
praisonai mcp list-prompts
|
||||
|
||||
# Generate client configuration
|
||||
praisonai mcp config-generate --client claude-desktop
|
||||
|
||||
# Check server health
|
||||
praisonai mcp doctor
|
||||
```
|
||||
|
||||
## Client Configuration
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Add to `~/.config/claude/claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"praisonai": {
|
||||
"command": "praisonai",
|
||||
"args": ["mcp", "serve", "--transport", "stdio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
Add to Cursor settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"praisonai": {
|
||||
"command": "praisonai",
|
||||
"args": ["mcp", "serve", "--transport", "stdio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set the following environment variables for full functionality:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=your_key
|
||||
export ANTHROPIC_API_KEY=your_key # Optional
|
||||
export GOOGLE_API_KEY=your_key # Optional
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
Run `praisonai mcp list-tools` to see all available tools, including:
|
||||
|
||||
- `praisonai.chat.completion` - Chat with LLM
|
||||
- `praisonai.agent.chat` - Chat with an agent
|
||||
- `praisonai.agent.run` - Run a task with an agent
|
||||
- `praisonai.workflow.run` - Run a workflow
|
||||
- `praisonai.images.generate` - Generate images
|
||||
- `praisonai.audio.transcribe` - Transcribe audio
|
||||
- `praisonai.embed.create` - Create embeddings
|
||||
- `praisonai.memory.*` - Memory operations
|
||||
- `praisonai.knowledge.*` - Knowledge base operations
|
||||
- And many more...
|
||||
|
||||
## Available Resources
|
||||
|
||||
- `praisonai://memory/sessions` - List memory sessions
|
||||
- `praisonai://workflows` - List available workflows
|
||||
- `praisonai://tools` - List available tools
|
||||
- `praisonai://agents` - List agent configurations
|
||||
- `praisonai://knowledge/sources` - List knowledge sources
|
||||
- `praisonai://config` - Get current configuration
|
||||
- `praisonai://mcp/status` - Get MCP server status
|
||||
|
||||
## Available Prompts
|
||||
|
||||
- `deep-research` - Generate deep research prompts
|
||||
- `code-review` - Generate code review prompts
|
||||
- `workflow-auto` - Generate workflow auto-generation prompts
|
||||
- `guardrail-check` - Generate guardrail check prompts
|
||||
- `context-engineering` - Generate context engineering prompts
|
||||
- `eval-criteria` - Generate evaluation criteria prompts
|
||||
- `agent-instructions` - Generate agent instructions prompts
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Custom Tools MCP Server Example
|
||||
|
||||
Register custom tools and expose them via MCP server.
|
||||
|
||||
Usage:
|
||||
python custom_tools_server.py
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
"""Run MCP server with custom tools."""
|
||||
from praisonai.mcp_server.server import MCPServer
|
||||
from praisonai.mcp_server.registry import register_tool, get_tool_registry
|
||||
|
||||
# Register custom tools
|
||||
@register_tool("custom.greet")
|
||||
def greet(name: str) -> str:
|
||||
"""Greet a person by name."""
|
||||
return f"Hello, {name}! Welcome to PraisonAI."
|
||||
|
||||
@register_tool("custom.calculate")
|
||||
def calculate(expression: str) -> str:
|
||||
"""Safely evaluate a mathematical expression."""
|
||||
try:
|
||||
# Only allow safe math operations
|
||||
allowed = set("0123456789+-*/.(). ")
|
||||
if not all(c in allowed for c in expression):
|
||||
return "Error: Invalid characters in expression"
|
||||
result = eval(expression) # Safe due to character filtering
|
||||
return f"Result: {result}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("custom.reverse")
|
||||
def reverse_text(text: str) -> str:
|
||||
"""Reverse a text string."""
|
||||
return text[::-1]
|
||||
|
||||
@register_tool("custom.word_count")
|
||||
def word_count(text: str) -> str:
|
||||
"""Count words in text."""
|
||||
words = text.split()
|
||||
return f"Word count: {len(words)}"
|
||||
|
||||
# Show registered tools
|
||||
registry = get_tool_registry()
|
||||
tools = registry.list_all()
|
||||
print(f"Registered {len(tools)} custom tools:")
|
||||
for tool in tools:
|
||||
print(f" - {tool.name}: {tool.description}")
|
||||
print()
|
||||
|
||||
# Create and run server
|
||||
server = MCPServer(
|
||||
name="praisonai-custom",
|
||||
version="1.0.0",
|
||||
instructions="Custom tools MCP server example.",
|
||||
)
|
||||
|
||||
print("Starting MCP server on http://127.0.0.1:8080/mcp")
|
||||
server.run(
|
||||
transport="http-stream",
|
||||
host="127.0.0.1",
|
||||
port=8080,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
HTTP Stream MCP Server Example
|
||||
|
||||
Run PraisonAI as an MCP server using HTTP Stream transport.
|
||||
This transport is useful for web-based integrations.
|
||||
|
||||
Usage:
|
||||
python http_stream_server.py
|
||||
|
||||
Or via CLI:
|
||||
praisonai mcp serve --transport http-stream --port 8080
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
"""Run MCP server with HTTP Stream transport."""
|
||||
from praisonai.mcp_server.server import MCPServer
|
||||
from praisonai.mcp_server.adapters import register_all
|
||||
|
||||
# Register all tools, resources, and prompts
|
||||
register_all()
|
||||
|
||||
# Create server
|
||||
server = MCPServer(
|
||||
name="praisonai",
|
||||
version="1.0.0",
|
||||
instructions="PraisonAI MCP Server - AI agent capabilities via MCP protocol.",
|
||||
)
|
||||
|
||||
# Run with HTTP Stream transport
|
||||
server.run(
|
||||
transport="http-stream",
|
||||
host="127.0.0.1",
|
||||
port=8080,
|
||||
endpoint="/mcp",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Starting PraisonAI MCP Server on http://127.0.0.1:8080/mcp")
|
||||
print("Protocol Version: 2025-11-25")
|
||||
print("Press Ctrl+C to stop")
|
||||
main()
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MCP Client Example
|
||||
|
||||
Example of connecting to a PraisonAI MCP server as a client.
|
||||
|
||||
Prerequisites:
|
||||
1. Start the MCP server first:
|
||||
praisonai mcp serve --transport http-stream --port 8080
|
||||
|
||||
2. Then run this client:
|
||||
python mcp_client_example.py
|
||||
|
||||
Environment Variables:
|
||||
OPENAI_API_KEY - Required for chat completion tools
|
||||
"""
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def main():
|
||||
"""Connect to MCP server and call tools."""
|
||||
base_url = "http://127.0.0.1:8080/mcp"
|
||||
session_id = None
|
||||
|
||||
print("PraisonAI MCP Client Example")
|
||||
print("=" * 40)
|
||||
|
||||
# 1. Initialize connection
|
||||
print("\n1. Initializing connection...")
|
||||
init_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-11-25",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "example-client",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
base_url,
|
||||
json=init_request,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
session_id = response.headers.get("MCP-Session-Id")
|
||||
print(f" Connected! Session ID: {session_id}")
|
||||
print(f" Server: {result.get('result', {}).get('serverInfo', {})}")
|
||||
print(f" Protocol: {result.get('result', {}).get('protocolVersion')}")
|
||||
else:
|
||||
print(f" Error: {response.status_code}")
|
||||
return
|
||||
|
||||
# 2. Send initialized notification
|
||||
print("\n2. Sending initialized notification...")
|
||||
init_notification = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized",
|
||||
}
|
||||
requests.post(
|
||||
base_url,
|
||||
json=init_notification,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"MCP-Session-Id": session_id,
|
||||
},
|
||||
)
|
||||
print(" Done")
|
||||
|
||||
# 3. List available tools
|
||||
print("\n3. Listing available tools...")
|
||||
list_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {},
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
base_url,
|
||||
json=list_request,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"MCP-Session-Id": session_id,
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
tools = result.get("result", {}).get("tools", [])
|
||||
print(f" Found {len(tools)} tools")
|
||||
for tool in tools[:5]: # Show first 5
|
||||
print(f" - {tool.get('name')}")
|
||||
if len(tools) > 5:
|
||||
print(f" ... and {len(tools) - 5} more")
|
||||
|
||||
# 4. List available resources
|
||||
print("\n4. Listing available resources...")
|
||||
list_resources = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "resources/list",
|
||||
"params": {},
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
base_url,
|
||||
json=list_resources,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"MCP-Session-Id": session_id,
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
resources = result.get("result", {}).get("resources", [])
|
||||
print(f" Found {len(resources)} resources")
|
||||
for res in resources:
|
||||
print(f" - {res.get('uri')}")
|
||||
|
||||
# 5. List available prompts
|
||||
print("\n5. Listing available prompts...")
|
||||
list_prompts = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "prompts/list",
|
||||
"params": {},
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
base_url,
|
||||
json=list_prompts,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"MCP-Session-Id": session_id,
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
prompts = result.get("result", {}).get("prompts", [])
|
||||
print(f" Found {len(prompts)} prompts")
|
||||
for prompt in prompts:
|
||||
print(f" - {prompt.get('name')}")
|
||||
|
||||
# 6. Read a resource
|
||||
print("\n6. Reading MCP status resource...")
|
||||
read_resource = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"method": "resources/read",
|
||||
"params": {
|
||||
"uri": "praisonai://mcp/status",
|
||||
},
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
base_url,
|
||||
json=read_resource,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"MCP-Session-Id": session_id,
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
contents = result.get("result", {}).get("contents", [])
|
||||
if contents:
|
||||
print(f" Status: {contents[0].get('text', 'N/A')}")
|
||||
|
||||
# 7. Get a prompt
|
||||
print("\n7. Getting deep-research prompt...")
|
||||
get_prompt = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 6,
|
||||
"method": "prompts/get",
|
||||
"params": {
|
||||
"name": "deep-research",
|
||||
"arguments": {
|
||||
"topic": "AI agents",
|
||||
"depth": "medium",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
base_url,
|
||||
json=get_prompt,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"MCP-Session-Id": session_id,
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
messages = result.get("result", {}).get("messages", [])
|
||||
if messages:
|
||||
content = messages[0].get("content", {})
|
||||
text = content.get("text", "") if isinstance(content, dict) else str(content)
|
||||
print(f" Prompt preview: {text[:100]}...")
|
||||
|
||||
# 8. Ping
|
||||
print("\n8. Pinging server...")
|
||||
ping_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 7,
|
||||
"method": "ping",
|
||||
"params": {},
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
base_url,
|
||||
json=ping_request,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"MCP-Session-Id": session_id,
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
print(" Pong!")
|
||||
|
||||
print("\n" + "=" * 40)
|
||||
print("MCP client example completed successfully!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
STDIO MCP Server Example
|
||||
|
||||
Run PraisonAI as an MCP server using STDIO transport.
|
||||
This is the recommended transport for Claude Desktop integration.
|
||||
|
||||
Usage:
|
||||
python stdio_server.py
|
||||
|
||||
Or via CLI:
|
||||
praisonai mcp serve --transport stdio
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
"""Run MCP server with STDIO transport."""
|
||||
from praisonai.mcp_server.server import MCPServer
|
||||
from praisonai.mcp_server.adapters import register_all
|
||||
|
||||
# Register all tools, resources, and prompts
|
||||
register_all()
|
||||
|
||||
# Create and run server
|
||||
server = MCPServer(
|
||||
name="praisonai",
|
||||
version="1.0.0",
|
||||
instructions="PraisonAI MCP Server - AI agent capabilities via MCP protocol.",
|
||||
)
|
||||
|
||||
# Run with STDIO transport
|
||||
server.run(transport="stdio")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,101 @@
|
||||
# Package Manager Example
|
||||
|
||||
This example demonstrates how to use the PraisonAI package manager for installing and managing Python packages with security defaults.
|
||||
|
||||
## Features Demonstrated
|
||||
|
||||
- Installing packages with version constraints
|
||||
- Listing installed packages
|
||||
- Searching for packages on PyPI
|
||||
- Managing package index configuration
|
||||
- Security features (dependency confusion prevention)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run the example
|
||||
python package_manager_example.py
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### Install Packages
|
||||
|
||||
```bash
|
||||
# Install a package
|
||||
praisonai install requests
|
||||
|
||||
# Install with version constraint
|
||||
praisonai install "requests>=2.28"
|
||||
|
||||
# Install multiple packages
|
||||
praisonai install requests httpx aiohttp
|
||||
|
||||
# Upgrade existing package
|
||||
praisonai install requests --upgrade
|
||||
|
||||
# JSON output
|
||||
praisonai install requests --json
|
||||
```
|
||||
|
||||
### Uninstall Packages
|
||||
|
||||
```bash
|
||||
# Uninstall a package
|
||||
praisonai uninstall requests
|
||||
|
||||
# Uninstall without confirmation
|
||||
praisonai uninstall requests --yes
|
||||
```
|
||||
|
||||
### List Packages
|
||||
|
||||
```bash
|
||||
# List all installed packages
|
||||
praisonai package list
|
||||
|
||||
# JSON output
|
||||
praisonai package list --json
|
||||
```
|
||||
|
||||
### Search Packages
|
||||
|
||||
```bash
|
||||
# Search PyPI
|
||||
praisonai package search langchain
|
||||
|
||||
# JSON output
|
||||
praisonai package search langchain --json
|
||||
```
|
||||
|
||||
### Index Configuration
|
||||
|
||||
```bash
|
||||
# Show current index settings
|
||||
praisonai package index show --json
|
||||
|
||||
# Set custom index
|
||||
praisonai package index set https://pypi.mycompany.com/simple
|
||||
|
||||
# Reset to PyPI default
|
||||
praisonai package index set https://pypi.org/simple
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
By default, only the primary index (PyPI) is used. Extra indexes are blocked to prevent dependency confusion attacks.
|
||||
|
||||
```bash
|
||||
# This will FAIL (extra index not allowed by default)
|
||||
praisonai install mypackage --extra-index-url https://other.index.com/simple
|
||||
|
||||
# Explicitly allow extra index (shows security warning)
|
||||
praisonai install mypackage \
|
||||
--extra-index-url https://other.index.com/simple \
|
||||
--allow-extra-index
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `PRAISONAI_PACKAGE_INDEX_URL` - Override primary index URL
|
||||
- `PIP_INDEX_URL` - Fallback to pip's index URL
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Package Manager Example
|
||||
|
||||
This example demonstrates:
|
||||
1. Checking package index configuration
|
||||
2. Searching for packages on PyPI
|
||||
3. Listing installed packages
|
||||
4. Security features (safe defaults)
|
||||
|
||||
Requirements:
|
||||
- praisonai installed
|
||||
- No external API keys required
|
||||
|
||||
Note: This example does NOT actually install/uninstall packages
|
||||
to avoid modifying your environment. It demonstrates read-only
|
||||
operations and shows the commands for install/uninstall.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
|
||||
def run_command(args, description):
|
||||
"""Run a praisonai command and display results."""
|
||||
print(f"\n[{description}]")
|
||||
print(f"Command: praisonai {' '.join(args)}")
|
||||
print("-" * 40)
|
||||
|
||||
result = subprocess.run(
|
||||
["python3", "-m", "praisonai"] + args,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
print(json.dumps(data, indent=2))
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
print(result.stdout)
|
||||
return None
|
||||
else:
|
||||
print(f"Error (exit code {result.returncode}):")
|
||||
print(result.stderr or result.stdout)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("PraisonAI Package Manager Example")
|
||||
print("=" * 60)
|
||||
print("\nThis example demonstrates package manager features.")
|
||||
print("No packages will be installed or uninstalled.")
|
||||
|
||||
# 1. Show index configuration
|
||||
print("\n" + "=" * 60)
|
||||
print("1. PACKAGE INDEX CONFIGURATION")
|
||||
print("=" * 60)
|
||||
|
||||
data = run_command(
|
||||
["package", "index", "show", "--json"],
|
||||
"Show current index settings"
|
||||
)
|
||||
|
||||
if data:
|
||||
print(f"\n✓ Primary index: {data.get('index_url', 'N/A')}")
|
||||
print(f"✓ Extra indexes allowed: {data.get('allow_extra_index', False)}")
|
||||
|
||||
# 2. Search for packages
|
||||
print("\n" + "=" * 60)
|
||||
print("2. SEARCH PACKAGES")
|
||||
print("=" * 60)
|
||||
|
||||
data = run_command(
|
||||
["package", "search", "requests", "--json"],
|
||||
"Search for 'requests' package"
|
||||
)
|
||||
|
||||
if data and data.get("results"):
|
||||
pkg = data["results"][0]
|
||||
print(f"\n✓ Found: {pkg.get('name')} v{pkg.get('version')}")
|
||||
print(f" Summary: {pkg.get('summary', 'N/A')}")
|
||||
|
||||
# 3. List installed packages (sample)
|
||||
print("\n" + "=" * 60)
|
||||
print("3. LIST INSTALLED PACKAGES")
|
||||
print("=" * 60)
|
||||
|
||||
data = run_command(
|
||||
["package", "list", "--json"],
|
||||
"List installed packages"
|
||||
)
|
||||
|
||||
if data and data.get("packages"):
|
||||
print(f"\n✓ Total packages: {len(data['packages'])}")
|
||||
print(" Sample (first 5):")
|
||||
for pkg in data["packages"][:5]:
|
||||
print(f" - {pkg['name']}=={pkg['version']}")
|
||||
|
||||
# 4. Show install/uninstall commands (without executing)
|
||||
print("\n" + "=" * 60)
|
||||
print("4. INSTALL/UNINSTALL COMMANDS (NOT EXECUTED)")
|
||||
print("=" * 60)
|
||||
|
||||
print("\nTo install packages, use:")
|
||||
print(" praisonai install requests")
|
||||
print(" praisonai install 'requests>=2.28'")
|
||||
print(" praisonai install requests httpx --upgrade")
|
||||
|
||||
print("\nTo uninstall packages, use:")
|
||||
print(" praisonai uninstall requests")
|
||||
print(" praisonai uninstall requests --yes")
|
||||
|
||||
# 5. Security features
|
||||
print("\n" + "=" * 60)
|
||||
print("5. SECURITY FEATURES")
|
||||
print("=" * 60)
|
||||
|
||||
print("\nDependency Confusion Prevention:")
|
||||
print(" - By default, only PyPI is used as package source")
|
||||
print(" - Extra index URLs are blocked unless explicitly allowed")
|
||||
print(" - Use --allow-extra-index to enable (shows warning)")
|
||||
|
||||
print("\nExample (blocked by default):")
|
||||
print(" praisonai install pkg --extra-index-url https://other.index.com/simple")
|
||||
print(" → Error: Extra index URLs not allowed")
|
||||
|
||||
print("\nExample (explicitly allowed):")
|
||||
print(" praisonai install pkg --extra-index-url https://other.index.com/simple --allow-extra-index")
|
||||
print(" → Warning shown, then proceeds")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed!")
|
||||
print("=" * 60)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,11 @@
|
||||
# Creator Suite Examples - Environment Variables
|
||||
|
||||
# Required for most features
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
|
||||
# Optional - for web search in news crawler
|
||||
TAVILY_API_KEY=your_tavily_api_key_here
|
||||
|
||||
# Optional - for additional LLM providers
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
GOOGLE_API_KEY=your_google_api_key_here
|
||||
@@ -0,0 +1,77 @@
|
||||
# Creator Suite Examples
|
||||
|
||||
Examples for the PraisonAI Creator Automation Suite - a complete toolkit for AI content creators.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Set your API key
|
||||
export OPENAI_API_KEY=your_key_here
|
||||
|
||||
# Run an example
|
||||
python news_crawler_example.py
|
||||
python script_writer_example.py
|
||||
python hook_generator_example.py
|
||||
python brief_generator_example.py
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
| Example | Description | API Key Required |
|
||||
|---------|-------------|------------------|
|
||||
| `news_crawler_example.py` | Crawl AI news from multiple sources | No (HN, arXiv) |
|
||||
| `script_writer_example.py` | Generate scripts for YouTube, X, LinkedIn | Yes (OpenAI) |
|
||||
| `hook_generator_example.py` | Generate attention-grabbing hooks | Yes (OpenAI) |
|
||||
| `brief_generator_example.py` | Generate news briefs | Yes (OpenAI) |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env` and fill in your API keys:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
## Features Covered
|
||||
|
||||
### News Intelligence Layer
|
||||
- AI News Crawler
|
||||
- AI News Deduper
|
||||
- AI Signal Ranker
|
||||
- AI Context Enricher
|
||||
- AI Brief Generator
|
||||
|
||||
### Script & Story Layer
|
||||
- AI Script Writer
|
||||
- AI Angle Generator
|
||||
- AI Hook Generator
|
||||
- AI CTA Generator
|
||||
- AI Fact Checker
|
||||
|
||||
### Capture & Screen Automation
|
||||
- AI Screenshot Capture
|
||||
- AI Screen Recorder
|
||||
- AI News Capture Pack
|
||||
|
||||
### Video Generation
|
||||
- AI B-roll Builder
|
||||
- AI Voiceover Generator
|
||||
- AI Video Merger
|
||||
|
||||
### Publishing & Distribution
|
||||
- AI Publisher Pack
|
||||
- AI Content Calendar
|
||||
- AI Post Copy Generator
|
||||
- AI Hashtag Optimizer
|
||||
|
||||
### Feedback & Learning
|
||||
- AI Performance Analyzer
|
||||
- AI Comment Miner
|
||||
- AI A/B Hook Tester
|
||||
|
||||
### Orchestrator
|
||||
- AI Daily News Show (end-to-end pipeline)
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation: https://docs.praison.ai/docs/ai-tools/creator-suite/
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Brief Generator Example
|
||||
|
||||
Demonstrates generating news briefs from articles.
|
||||
Requires: OPENAI_API_KEY
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Check for API key
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("ERROR: OPENAI_API_KEY environment variable required")
|
||||
print("Set it with: export OPENAI_API_KEY=your_key")
|
||||
sys.exit(1)
|
||||
|
||||
# Add the Agent-Recipes templates to path
|
||||
sys.path.insert(0, '/Users/praison/Agent-Recipes/agent_recipes/templates/ai-brief-generator')
|
||||
|
||||
from tools import generate_brief, extract_highlights
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("AI Brief Generator Example")
|
||||
print("=" * 60)
|
||||
|
||||
# Sample articles
|
||||
articles = [
|
||||
{
|
||||
"title": "OpenAI Releases GPT-5 with Multimodal Capabilities",
|
||||
"content": "OpenAI announced GPT-5 today with major improvements in reasoning, multimodal understanding, and reduced hallucinations.",
|
||||
"source": "TechCrunch",
|
||||
"url": "https://example.com/gpt5"
|
||||
},
|
||||
{
|
||||
"title": "Google Gemini 2.0 Launches with Real-time Video Understanding",
|
||||
"content": "Google unveiled Gemini 2.0 with breakthrough capabilities in real-time video analysis and native tool use.",
|
||||
"source": "The Verge",
|
||||
"url": "https://example.com/gemini2"
|
||||
},
|
||||
{
|
||||
"title": "Anthropic Claude 4 Sets New Benchmarks",
|
||||
"content": "Anthropic's Claude 4 achieves state-of-the-art results on coding, math, and reasoning benchmarks.",
|
||||
"source": "Wired",
|
||||
"url": "https://example.com/claude4"
|
||||
}
|
||||
]
|
||||
|
||||
# 1. Generate daily brief
|
||||
print("\n1. Generating daily brief...")
|
||||
brief = generate_brief(articles, format="daily", max_articles=3)
|
||||
print(f" Format: daily")
|
||||
print(f" Articles included: {brief.get('article_count', 0)}")
|
||||
print(f"\n Brief preview:\n {brief.get('brief', '')[:500]}...")
|
||||
|
||||
# 2. Extract highlights
|
||||
print("\n2. Extracting highlights...")
|
||||
highlights = extract_highlights(articles, num_highlights=3)
|
||||
print(f" Highlights:")
|
||||
for h in highlights.get('highlights', []):
|
||||
print(f" - {h}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Hook Generator Example
|
||||
|
||||
Demonstrates generating attention-grabbing hooks.
|
||||
Requires: OPENAI_API_KEY
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Check for API key
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("ERROR: OPENAI_API_KEY environment variable required")
|
||||
print("Set it with: export OPENAI_API_KEY=your_key")
|
||||
sys.exit(1)
|
||||
|
||||
# Add the Agent-Recipes templates to path
|
||||
sys.path.insert(0, '/Users/praison/Agent-Recipes/agent_recipes/templates/ai-hook-generator')
|
||||
|
||||
from tools import generate_hooks, rank_hooks
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("AI Hook Generator Example")
|
||||
print("=" * 60)
|
||||
|
||||
topic = "GPT-5 just released with groundbreaking capabilities"
|
||||
|
||||
# 1. Generate hooks
|
||||
print(f"\n1. Generating hooks for: '{topic}'")
|
||||
hooks = generate_hooks(topic, num_variants=5)
|
||||
print(f" Generated {len(hooks.get('hooks', []))} hooks:")
|
||||
|
||||
for hook in hooks.get('hooks', []):
|
||||
print(f"\n [{hook.get('style', 'unknown')}]")
|
||||
print(f" {hook.get('text', '')}")
|
||||
|
||||
# 2. Rank hooks
|
||||
print("\n2. Ranking hooks by engagement potential...")
|
||||
ranked = rank_hooks(hooks.get('hooks', []))
|
||||
best = ranked.get('best_hook', {})
|
||||
print(f"\n Best hook (score: {best.get('score', 0)}):")
|
||||
print(f" {best.get('text', '')}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI News Crawler Example
|
||||
|
||||
Demonstrates crawling AI news from multiple sources.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the Agent-Recipes templates to path
|
||||
sys.path.insert(0, '/Users/praison/Agent-Recipes/agent_recipes/templates/ai-news-crawler')
|
||||
|
||||
from tools import crawl_hackernews, crawl_reddit_ai, crawl_arxiv, crawl_ai_news
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("AI News Crawler Example")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. Crawl HackerNews (no API key needed)
|
||||
print("\n1. Crawling HackerNews...")
|
||||
hn_articles = crawl_hackernews(max_articles=5, time_window_hours=48)
|
||||
print(f" Found {len(hn_articles)} articles from HackerNews")
|
||||
for article in hn_articles[:3]:
|
||||
print(f" - {article.get('title', 'No title')[:60]}...")
|
||||
|
||||
# 2. Crawl arXiv (no API key needed)
|
||||
print("\n2. Crawling arXiv...")
|
||||
arxiv_articles = crawl_arxiv(categories=["cs.AI", "cs.LG"], max_results=5)
|
||||
print(f" Found {len(arxiv_articles)} articles from arXiv")
|
||||
for article in arxiv_articles[:3]:
|
||||
print(f" - {article.get('title', 'No title')[:60]}...")
|
||||
|
||||
# 3. Full crawl from multiple sources
|
||||
print("\n3. Full multi-source crawl...")
|
||||
all_articles = crawl_ai_news(
|
||||
sources=["hackernews", "arxiv"],
|
||||
max_articles=10,
|
||||
time_window_hours=48
|
||||
)
|
||||
print(f" Total articles: {all_articles.get('total', 0)}")
|
||||
print(f" Sources crawled: {all_articles.get('sources_crawled', [])}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Script Writer Example
|
||||
|
||||
Demonstrates generating scripts for different platforms.
|
||||
Requires: OPENAI_API_KEY
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Check for API key
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("ERROR: OPENAI_API_KEY environment variable required")
|
||||
print("Set it with: export OPENAI_API_KEY=your_key")
|
||||
sys.exit(1)
|
||||
|
||||
# Add the Agent-Recipes templates to path
|
||||
sys.path.insert(0, '/Users/praison/Agent-Recipes/agent_recipes/templates/ai-script-writer')
|
||||
|
||||
from tools import write_youtube_script, write_short_script, write_thread
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("AI Script Writer Example")
|
||||
print("=" * 60)
|
||||
|
||||
topic = "AI agents are revolutionizing software development"
|
||||
|
||||
# 1. YouTube Long-form Script
|
||||
print("\n1. Generating YouTube long-form script...")
|
||||
yt_script = write_youtube_script(
|
||||
topic=topic,
|
||||
target_length=180,
|
||||
key_points=["automation", "productivity", "future of coding"]
|
||||
)
|
||||
print(f" Format: {yt_script.get('format')}")
|
||||
print(f" Estimated duration: {yt_script.get('estimated_duration')}s")
|
||||
print(f" Script preview:\n {yt_script.get('script', '')[:200]}...")
|
||||
|
||||
# 2. YouTube Short Script
|
||||
print("\n2. Generating YouTube Short script...")
|
||||
short_script = write_short_script(topic, duration=30)
|
||||
print(f" Format: {short_script.get('format')}")
|
||||
print(f" Script:\n {short_script.get('script', '')[:300]}...")
|
||||
|
||||
# 3. X Thread
|
||||
print("\n3. Generating X thread...")
|
||||
thread = write_thread(topic, num_tweets=3)
|
||||
print(f" Thread ({len(thread.get('tweets', []))} tweets):")
|
||||
for i, tweet in enumerate(thread.get('tweets', [])[:3], 1):
|
||||
print(f" Tweet {i}: {tweet[:80]}...")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example: LLM-based Recipes
|
||||
|
||||
Demonstrates usage of LLM-powered recipes:
|
||||
- ai-blog-generator
|
||||
- ai-social-media-generator
|
||||
- ai-faq-generator
|
||||
- ai-sentiment-analyzer
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure API key is set
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("Please set OPENAI_API_KEY environment variable")
|
||||
sys.exit(1)
|
||||
|
||||
# Add recipe tools to path
|
||||
sys.path.insert(0, "/Users/praison/PraisonAI-tools/praisonai_tools/recipe_tools")
|
||||
|
||||
from llm_tool import LLMTool, llm_complete
|
||||
|
||||
|
||||
def example_blog_generator():
|
||||
"""Generate a blog post."""
|
||||
print("\n=== AI Blog Generator ===")
|
||||
|
||||
llm = LLMTool(provider="openai", model="gpt-4o-mini")
|
||||
|
||||
topic = "The Future of AI in Healthcare"
|
||||
|
||||
response = llm.complete(
|
||||
f"""Write a short blog post (3 paragraphs) about: {topic}
|
||||
|
||||
Include:
|
||||
- Engaging introduction
|
||||
- Key points
|
||||
- Call to action
|
||||
|
||||
Format as markdown.""",
|
||||
system="You are an expert blog writer specializing in technology topics.",
|
||||
max_tokens=500,
|
||||
)
|
||||
|
||||
print(f"Topic: {topic}")
|
||||
print(f"\nGenerated Blog Post:\n{response.content}")
|
||||
print(f"\nTokens used: {response.usage.get('total_tokens', 'N/A')}")
|
||||
|
||||
|
||||
def example_social_media_generator():
|
||||
"""Generate social media posts."""
|
||||
print("\n=== AI Social Media Generator ===")
|
||||
|
||||
llm = LLMTool(provider="openai", model="gpt-4o-mini")
|
||||
|
||||
content = "We just launched our new AI-powered productivity app!"
|
||||
|
||||
response = llm.complete(
|
||||
f"""Create social media posts for the following announcement:
|
||||
{content}
|
||||
|
||||
Generate posts for:
|
||||
1. Twitter (280 chars max)
|
||||
2. LinkedIn (professional tone)
|
||||
3. Instagram (with emoji suggestions)
|
||||
|
||||
Format each clearly.""",
|
||||
system="You are a social media marketing expert.",
|
||||
max_tokens=400,
|
||||
)
|
||||
|
||||
print(f"Content: {content}")
|
||||
print(f"\nGenerated Posts:\n{response.content}")
|
||||
|
||||
|
||||
def example_faq_generator():
|
||||
"""Generate FAQ from content."""
|
||||
print("\n=== AI FAQ Generator ===")
|
||||
|
||||
llm = LLMTool(provider="openai", model="gpt-4o-mini")
|
||||
|
||||
documentation = """
|
||||
Our product is a cloud-based project management tool.
|
||||
It supports team collaboration, task tracking, and reporting.
|
||||
Pricing starts at $10/month per user.
|
||||
We offer a 14-day free trial.
|
||||
Integration with Slack, GitHub, and Jira is available.
|
||||
"""
|
||||
|
||||
response = llm.complete(
|
||||
f"""Based on this documentation, generate 5 FAQ questions and answers:
|
||||
|
||||
{documentation}
|
||||
|
||||
Format as:
|
||||
Q: [question]
|
||||
A: [answer]
|
||||
""",
|
||||
system="You are a technical writer creating FAQ documentation.",
|
||||
max_tokens=400,
|
||||
)
|
||||
|
||||
print(f"Generated FAQ:\n{response.content}")
|
||||
|
||||
|
||||
def example_sentiment_analyzer():
|
||||
"""Analyze sentiment in text."""
|
||||
print("\n=== AI Sentiment Analyzer ===")
|
||||
|
||||
llm = LLMTool(provider="openai", model="gpt-4o-mini")
|
||||
|
||||
reviews = [
|
||||
"This product is amazing! Best purchase ever.",
|
||||
"Terrible experience. Would not recommend.",
|
||||
"It's okay, nothing special but does the job.",
|
||||
]
|
||||
|
||||
for review in reviews:
|
||||
response = llm.complete(
|
||||
f"""Analyze the sentiment of this text and respond with ONLY one word: POSITIVE, NEGATIVE, or NEUTRAL
|
||||
|
||||
Text: {review}""",
|
||||
max_tokens=10,
|
||||
)
|
||||
print(f"Review: {review[:50]}...")
|
||||
print(f"Sentiment: {response.content.strip()}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("LLM-Based Recipes Examples")
|
||||
print("=" * 60)
|
||||
|
||||
example_blog_generator()
|
||||
example_social_media_generator()
|
||||
example_faq_generator()
|
||||
example_sentiment_analyzer()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("All examples completed!")
|
||||
print("=" * 60)
|
||||
+47
-24
@@ -1,48 +1,71 @@
|
||||
# Recipe Registry Example
|
||||
|
||||
This example demonstrates how to use the PraisonAI recipe registry for publishing and pulling recipes.
|
||||
This example demonstrates how to use the PraisonAI recipe registry for publishing and pulling recipes via both local filesystem and HTTP server.
|
||||
|
||||
## Features Demonstrated
|
||||
|
||||
- Creating a local registry
|
||||
- Packing recipes into bundles
|
||||
- Publishing recipes to the registry
|
||||
- Starting an HTTP registry server
|
||||
- Publishing recipes with token authentication
|
||||
- Listing and searching recipes
|
||||
- Pulling recipes from the registry
|
||||
- Pulling recipes from HTTP registry
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run the example
|
||||
python registry_example.py
|
||||
# Run the HTTP registry example
|
||||
python http_registry_example.py
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
## HTTP Registry Server
|
||||
|
||||
```bash
|
||||
# Publish a recipe
|
||||
# Start HTTP registry server
|
||||
praisonai registry serve --port 7777
|
||||
|
||||
# Start with authentication
|
||||
praisonai registry serve --port 7777 --token mysecret
|
||||
|
||||
# Check server status
|
||||
praisonai registry status --registry http://localhost:7777
|
||||
```
|
||||
|
||||
## CLI Commands with HTTP Registry
|
||||
|
||||
```bash
|
||||
# Publish to HTTP registry
|
||||
praisonai recipe publish ./my-recipe --registry http://localhost:7777 --json
|
||||
|
||||
# Publish with token
|
||||
praisonai recipe publish ./my-recipe --registry http://localhost:7777 --token mysecret
|
||||
|
||||
# Pull from HTTP registry
|
||||
praisonai recipe pull [email protected] --registry http://localhost:7777 -o ./recipes
|
||||
|
||||
# List recipes from HTTP registry
|
||||
praisonai recipe list --registry http://localhost:7777 --json
|
||||
|
||||
# Search HTTP registry
|
||||
praisonai recipe search "hello" --registry http://localhost:7777
|
||||
```
|
||||
|
||||
## Local Registry Commands
|
||||
|
||||
```bash
|
||||
# Publish to local registry (default)
|
||||
praisonai recipe publish ./my-recipe --json
|
||||
|
||||
# Pull a recipe
|
||||
# Pull from local registry
|
||||
praisonai recipe pull [email protected] -o ./recipes
|
||||
|
||||
# List recipes in registry
|
||||
praisonai recipe list --json
|
||||
|
||||
# Search recipes
|
||||
praisonai recipe search "hello"
|
||||
```
|
||||
|
||||
## Using a Custom Registry Path
|
||||
|
||||
```bash
|
||||
# Publish to custom registry
|
||||
# Use custom local path
|
||||
praisonai recipe publish ./my-recipe --registry /path/to/registry
|
||||
|
||||
# Pull from custom registry
|
||||
praisonai recipe pull [email protected] --registry /path/to/registry
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `PRAISONAI_REGISTRY_TOKEN` - Default token for HTTP registry authentication
|
||||
|
||||
## Default Registry Location
|
||||
|
||||
By default, the registry is stored at `~/.praison/registry`.
|
||||
By default, the local registry is stored at `~/.praison/registry`.
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
HTTP Recipe Registry Example
|
||||
|
||||
This example demonstrates:
|
||||
1. Starting an HTTP registry server programmatically
|
||||
2. Publishing recipes with token authentication
|
||||
3. Listing and searching recipes via HTTP
|
||||
4. Pulling recipes from HTTP registry
|
||||
|
||||
Requirements:
|
||||
- praisonai installed
|
||||
- No external API keys required (local registry only)
|
||||
|
||||
Environment Variables (optional):
|
||||
- PRAISONAI_REGISTRY_TOKEN: Token for authentication
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
def create_sample_recipe(output_dir: Path) -> Path:
|
||||
"""Create a sample recipe for testing."""
|
||||
recipe_dir = output_dir / "sample-agent"
|
||||
recipe_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create manifest.json
|
||||
manifest = {
|
||||
"name": "sample-agent",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample agent recipe for registry testing",
|
||||
"tags": ["sample", "agent", "test"],
|
||||
"author": "praison",
|
||||
"files": ["recipe.yaml"]
|
||||
}
|
||||
(recipe_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
|
||||
|
||||
# Create recipe.yaml
|
||||
recipe_yaml = """
|
||||
name: sample-agent
|
||||
version: "1.0.0"
|
||||
description: A sample agent recipe
|
||||
|
||||
agents:
|
||||
- name: assistant
|
||||
role: General Assistant
|
||||
goal: Help users with their questions
|
||||
instructions: You are a helpful assistant.
|
||||
|
||||
tasks:
|
||||
- name: respond
|
||||
description: Respond to user query
|
||||
agent: assistant
|
||||
"""
|
||||
(recipe_dir / "recipe.yaml").write_text(recipe_yaml.strip())
|
||||
|
||||
# Pack into .praison bundle
|
||||
bundle_path = output_dir / "sample-agent-1.0.0.praison"
|
||||
with tarfile.open(bundle_path, "w:gz") as tar:
|
||||
for file in recipe_dir.iterdir():
|
||||
tar.add(file, arcname=file.name)
|
||||
|
||||
return bundle_path
|
||||
|
||||
|
||||
def start_registry_server(port: int, registry_path: Path, token: str = None):
|
||||
"""Start registry server in background process."""
|
||||
cmd = [
|
||||
sys.executable, "-c",
|
||||
f"""
|
||||
import sys
|
||||
sys.path.insert(0, '/Users/praison/praisonai-package/src/praisonai')
|
||||
from praisonai.recipe.server import create_wsgi_app
|
||||
from wsgiref.simple_server import make_server, WSGIRequestHandler
|
||||
from pathlib import Path
|
||||
|
||||
class QuietHandler(WSGIRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
app = create_wsgi_app(registry_path=Path("{registry_path}"), token={repr(token)})
|
||||
server = make_server("127.0.0.1", {port}, app, handler_class=QuietHandler)
|
||||
server.serve_forever()
|
||||
"""
|
||||
]
|
||||
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return proc
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("HTTP Recipe Registry Example")
|
||||
print("=" * 60)
|
||||
|
||||
# Setup
|
||||
work_dir = Path(tempfile.mkdtemp(prefix="registry_example_"))
|
||||
registry_path = work_dir / "registry"
|
||||
pulled_dir = work_dir / "pulled"
|
||||
port = 7799
|
||||
token = "example-secret-token"
|
||||
|
||||
print(f"\nWork directory: {work_dir}")
|
||||
print(f"Registry path: {registry_path}")
|
||||
print(f"Server port: {port}")
|
||||
print(f"Token: {'*' * len(token)} (redacted)")
|
||||
|
||||
try:
|
||||
# 1. Create sample recipe
|
||||
print("\n[1] Creating sample recipe...")
|
||||
bundle_path = create_sample_recipe(work_dir)
|
||||
print(f" ✓ Created bundle: {bundle_path.name}")
|
||||
|
||||
# 2. Start HTTP registry server
|
||||
print("\n[2] Starting HTTP registry server...")
|
||||
server = start_registry_server(port, registry_path, token)
|
||||
time.sleep(1) # Wait for server to start
|
||||
print(f" ✓ Server running on http://127.0.0.1:{port}")
|
||||
|
||||
# 3. Check health
|
||||
print("\n[3] Checking server health...")
|
||||
result = subprocess.run(
|
||||
["curl", "-s", f"http://127.0.0.1:{port}/healthz"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
health = json.loads(result.stdout)
|
||||
print(f" Status: {health['status']}")
|
||||
print(f" Auth required: {health['auth_required']}")
|
||||
|
||||
# 4. Publish WITHOUT token (should fail)
|
||||
print("\n[4] Publishing WITHOUT token (expect failure)...")
|
||||
result = subprocess.run(
|
||||
["python3", "-m", "praisonai", "recipe", "publish", str(bundle_path),
|
||||
"--registry", f"http://127.0.0.1:{port}", "--json"],
|
||||
capture_output=True, text=True,
|
||||
cwd="/Users/praison/praisonai-package/src/praisonai"
|
||||
)
|
||||
if result.returncode != 0 or "error" in result.stdout.lower() or "401" in result.stdout:
|
||||
print(" ✓ Correctly rejected (authentication required)")
|
||||
else:
|
||||
print(f" ✗ Unexpected success: {result.stdout}")
|
||||
|
||||
# 5. Publish WITH token (should succeed)
|
||||
print("\n[5] Publishing WITH token...")
|
||||
result = subprocess.run(
|
||||
["python3", "-m", "praisonai", "recipe", "publish", str(bundle_path),
|
||||
"--registry", f"http://127.0.0.1:{port}", "--token", token, "--json"],
|
||||
capture_output=True, text=True,
|
||||
cwd="/Users/praison/praisonai-package/src/praisonai"
|
||||
)
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
if data.get("ok"):
|
||||
print(f" ✓ Published: {data['name']}@{data['version']}")
|
||||
else:
|
||||
print(f" ✗ Failed: {result.stdout}")
|
||||
return 1
|
||||
except json.JSONDecodeError:
|
||||
print(f" Output: {result.stdout}")
|
||||
print(f" Stderr: {result.stderr}")
|
||||
return 1
|
||||
|
||||
# 6. List recipes
|
||||
print("\n[6] Listing recipes from registry...")
|
||||
result = subprocess.run(
|
||||
["python3", "-m", "praisonai", "recipe", "list",
|
||||
"--registry", f"http://127.0.0.1:{port}", "--json"],
|
||||
capture_output=True, text=True,
|
||||
cwd="/Users/praison/praisonai-package/src/praisonai"
|
||||
)
|
||||
recipes = json.loads(result.stdout)
|
||||
print(f" Found {len(recipes)} recipe(s):")
|
||||
for r in recipes:
|
||||
print(f" - {r['name']} v{r['version']}: {r.get('description', '')[:40]}")
|
||||
|
||||
# 7. Search recipes
|
||||
print("\n[7] Searching for 'agent'...")
|
||||
result = subprocess.run(
|
||||
["python3", "-m", "praisonai", "recipe", "search", "agent",
|
||||
"--registry", f"http://127.0.0.1:{port}", "--json"],
|
||||
capture_output=True, text=True,
|
||||
cwd="/Users/praison/praisonai-package/src/praisonai"
|
||||
)
|
||||
matches = json.loads(result.stdout)
|
||||
print(f" Found {len(matches)} match(es)")
|
||||
|
||||
# 8. Pull recipe
|
||||
print("\n[8] Pulling recipe...")
|
||||
pulled_dir.mkdir(parents=True, exist_ok=True)
|
||||
result = subprocess.run(
|
||||
["python3", "-m", "praisonai", "recipe", "pull", "sample-agent",
|
||||
"--registry", f"http://127.0.0.1:{port}", "-o", str(pulled_dir), "--json"],
|
||||
capture_output=True, text=True,
|
||||
cwd="/Users/praison/praisonai-package/src/praisonai"
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
if data.get("ok"):
|
||||
print(f" ✓ Pulled to: {data['path']}")
|
||||
|
||||
# Verify content
|
||||
manifest_path = Path(data['path']) / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
print(f" ✓ Verified: {manifest['name']}@{manifest['version']}")
|
||||
else:
|
||||
print(f" ✗ Failed: {result.stdout}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
# Cleanup
|
||||
server.terminate()
|
||||
server.wait()
|
||||
|
||||
finally:
|
||||
# Clean up temp directory
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+98
-31
@@ -1,47 +1,72 @@
|
||||
# Recipe Server Example
|
||||
# PraisonAI Serve Examples
|
||||
|
||||
This example demonstrates how to configure and run the PraisonAI recipe server with authentication.
|
||||
Examples for all PraisonAI server types and the unified endpoints CLI.
|
||||
|
||||
## Files
|
||||
## Examples
|
||||
|
||||
- `serve.yaml` - Server configuration file
|
||||
- `serve_example.py` - Python client example with authentication
|
||||
| Example | Description | CLI Command |
|
||||
|---------|-------------|-------------|
|
||||
| `unified_server.py` | All providers in one server | `praisonai serve unified` |
|
||||
| `agent_as_api_single.py` | Single agent HTTP API | `praisonai serve agents` |
|
||||
| `agents_as_api_router.py` | Multi-agent router API | `praisonai serve agents` |
|
||||
| `a2a_server_client.py` | A2A protocol server | `praisonai serve a2a` |
|
||||
| `a2u_events_stream.py` | A2U event stream | `praisonai serve a2u` |
|
||||
| `mcp_http_server.py` | MCP HTTP server | `praisonai serve mcp` |
|
||||
| `tools_as_mcp_server.py` | Tools as MCP server | `praisonai serve tools` |
|
||||
| `agent_launch_modes.py` | Agent.launch() API | Python only |
|
||||
| `endpoints_unified_client.py` | Unified client | `praisonai endpoints` |
|
||||
| `serve_example.py` | Recipe server client | `praisonai recipe serve` |
|
||||
|
||||
## Server Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `praisonai serve unified --port 8765` | Start unified server |
|
||||
| `praisonai serve agents --file agents.yaml --port 8000` | Start agents API |
|
||||
| `praisonai serve recipe --port 8765` | Start recipe server |
|
||||
| `praisonai serve mcp --transport http --port 8080` | Start MCP server |
|
||||
| `praisonai serve tools --port 8081` | Start tools MCP server |
|
||||
| `praisonai serve a2a --port 8082` | Start A2A server |
|
||||
| `praisonai serve a2u --port 8083` | Start A2U server |
|
||||
|
||||
## Client Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `praisonai endpoints list` | List all endpoints |
|
||||
| `praisonai endpoints list --type agents-api` | Filter by type |
|
||||
| `praisonai endpoints describe <name>` | Get endpoint details |
|
||||
| `praisonai endpoints invoke <name> --input-json '{}'` | Invoke endpoint |
|
||||
| `praisonai endpoints health` | Check server health |
|
||||
| `praisonai endpoints types` | List provider types |
|
||||
| `praisonai endpoints discovery` | Show discovery document |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install praisonai[serve]
|
||||
# Terminal 1: Start unified server
|
||||
export OPENAI_API_KEY=your-key
|
||||
praisonai serve unified --port 8765
|
||||
|
||||
# Terminal 2: Use endpoints CLI
|
||||
praisonai endpoints health
|
||||
praisonai endpoints list
|
||||
praisonai endpoints types
|
||||
```
|
||||
|
||||
### 2. Set Environment Variables
|
||||
## Provider Types
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=your-openai-key
|
||||
export PRAISONAI_API_KEY=your-secret-api-key
|
||||
```
|
||||
|
||||
### 3. Start the Server
|
||||
|
||||
```bash
|
||||
# Using config file
|
||||
praisonai recipe serve --config serve.yaml
|
||||
|
||||
# Or with CLI flags
|
||||
praisonai recipe serve --port 8765 --auth api-key
|
||||
```
|
||||
|
||||
### 4. Test with the Example
|
||||
|
||||
```bash
|
||||
python serve_example.py
|
||||
```
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `recipe` | Recipe runner endpoints |
|
||||
| `agents-api` | Single/multi-agent HTTP API |
|
||||
| `mcp` | MCP server (stdio, http, sse) |
|
||||
| `tools-mcp` | Tools exposed as MCP server |
|
||||
| `a2a` | Agent-to-agent protocol |
|
||||
| `a2u` | Agent-to-user event stream |
|
||||
|
||||
## Configuration Options
|
||||
|
||||
See `serve.yaml` for all available options:
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `host` | Server bind address | 127.0.0.1 |
|
||||
@@ -52,6 +77,48 @@ See `serve.yaml` for all available options:
|
||||
| `recipes` | List of recipes to serve | all |
|
||||
| `cors_origins` | CORS allowed origins | - |
|
||||
|
||||
## Advanced Features
|
||||
|
||||
| Feature | CLI Flag | Description | Docs |
|
||||
|---------|----------|-------------|------|
|
||||
| Rate Limiting | `--rate_limit 100` | Requests per minute per client | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
|
||||
| Request Size Limit | `--max_request_size 10485760` | Max request body (bytes) | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
|
||||
| Metrics Endpoint | `--enable_metrics` | Prometheus /metrics endpoint | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
|
||||
| Admin Reload | `--enable_admin` | Hot reload /admin/reload | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
|
||||
| Workers | `--workers 4` | Multi-process workers | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
|
||||
| OpenTelemetry | `--trace_exporter otlp` | Distributed tracing | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
|
||||
| OpenAPI | `GET /openapi.json` | API specification | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
|
||||
|
||||
## Advanced Examples
|
||||
|
||||
| Example | Description |
|
||||
|---------|-------------|
|
||||
| `recipe_serve_features.py` | All advanced features demo |
|
||||
|
||||
## Advanced CLI Commands
|
||||
|
||||
```bash
|
||||
# Production server with all features
|
||||
praisonai recipe serve \
|
||||
--host 0.0.0.0 \
|
||||
--port 8765 \
|
||||
--auth api-key \
|
||||
--workers 4 \
|
||||
--rate_limit 100 \
|
||||
--enable_metrics \
|
||||
--enable_admin
|
||||
|
||||
# Get metrics
|
||||
curl http://localhost:8765/metrics
|
||||
|
||||
# Get OpenAPI spec
|
||||
curl http://localhost:8765/openapi.json
|
||||
|
||||
# Hot reload recipes
|
||||
curl -X POST http://localhost:8765/admin/reload \
|
||||
-H "X-API-Key: your-key"
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
A2A (Agent-to-Agent) Server and Client Example
|
||||
|
||||
Demonstrates:
|
||||
- Launching an agent as A2A server
|
||||
- Agent card discovery
|
||||
- Sending A2A messages
|
||||
- JSON-RPC protocol
|
||||
|
||||
Usage:
|
||||
# Run this example (starts server)
|
||||
python a2a_server_client.py
|
||||
|
||||
# Or use CLI:
|
||||
praisonai serve a2a --port 8082
|
||||
|
||||
# Test with curl:
|
||||
curl http://localhost:8082/.well-known/agent.json
|
||||
curl -X POST http://localhost:8082/a2a \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"message/send","id":"1","params":{"message":{"role":"user","parts":[{"type":"text","text":"Hello!"}]}}}'
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("Error: OPENAI_API_KEY environment variable not set")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_server():
|
||||
"""Run A2A server."""
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.ui.a2a import A2A
|
||||
from fastapi import FastAPI
|
||||
import uvicorn
|
||||
except ImportError as e:
|
||||
print(f"Error: Missing dependency - {e}")
|
||||
print("Install with: pip install 'praisonaiagents[api]'")
|
||||
sys.exit(1)
|
||||
|
||||
# Create agent
|
||||
agent = Agent(
|
||||
name="A2A-Assistant",
|
||||
role="Helpful AI Assistant",
|
||||
goal="Help other agents and users with their questions",
|
||||
backstory="You are a knowledgeable assistant accessible via A2A protocol.",
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
# Create A2A interface
|
||||
a2a = A2A(
|
||||
agent=agent,
|
||||
url="http://localhost:8082/a2a",
|
||||
name="A2A-Assistant",
|
||||
description="A helpful AI assistant accessible via A2A protocol"
|
||||
)
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(title="PraisonAI A2A Server")
|
||||
app.include_router(a2a.get_router())
|
||||
|
||||
# Add discovery endpoint
|
||||
@app.get("/__praisonai__/discovery")
|
||||
async def discovery():
|
||||
return {
|
||||
"schema_version": "1.0.0",
|
||||
"server_name": "praisonai-a2a",
|
||||
"providers": [{"type": "a2a", "name": "A2A Protocol", "capabilities": ["agent-card", "message-send"]}],
|
||||
"endpoints": [{"name": "a2a", "provider_type": "a2a", "streaming": ["sse"]}]
|
||||
}
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": "PraisonAI A2A Server",
|
||||
"agent_card": "/.well-known/agent.json",
|
||||
"a2a_endpoint": "/a2a",
|
||||
"discovery": "/__praisonai__/discovery"
|
||||
}
|
||||
|
||||
print("Starting A2A server on http://localhost:8082")
|
||||
print("Agent Card: http://localhost:8082/.well-known/agent.json")
|
||||
print("A2A Endpoint: http://localhost:8082/a2a")
|
||||
|
||||
uvicorn.run(app, host="127.0.0.1", port=8082, log_level="warning")
|
||||
|
||||
|
||||
def run_client():
|
||||
"""Run A2A client test."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
base_url = "http://localhost:8082"
|
||||
|
||||
print("\n--- A2A Client Test ---")
|
||||
|
||||
# 1. Get agent card
|
||||
print("\n1. Getting Agent Card...")
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/.well-known/agent.json")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
agent_card = json.loads(resp.read().decode())
|
||||
print(f" Agent: {agent_card.get('name')}")
|
||||
print(f" URL: {agent_card.get('url')}")
|
||||
print(f" Capabilities: {agent_card.get('capabilities', {})}")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
return
|
||||
|
||||
# 2. Send A2A message
|
||||
print("\n2. Sending A2A Message...")
|
||||
message = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "test-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [{"type": "text", "text": "Hello! What can you help me with?"}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/a2a",
|
||||
data=json.dumps(message).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
response = json.loads(resp.read().decode())
|
||||
if "result" in response:
|
||||
result_msg = response["result"].get("message", {})
|
||||
parts = result_msg.get("parts", [])
|
||||
for part in parts:
|
||||
if part.get("type") == "text":
|
||||
print(f" Response: {part.get('text', '')[:200]}...")
|
||||
elif "error" in response:
|
||||
print(f" Error: {response['error']}")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
|
||||
# 3. Check discovery
|
||||
print("\n3. Checking Discovery...")
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/__praisonai__/discovery")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
discovery = json.loads(resp.read().decode())
|
||||
print(f" Server: {discovery.get('server_name')}")
|
||||
print(f" Providers: {[p.get('type') for p in discovery.get('providers', [])]}")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
|
||||
print("\n✓ A2A client test completed!")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("A2A (Agent-to-Agent) Server and Client Example")
|
||||
print("=" * 60)
|
||||
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "server"
|
||||
|
||||
if mode == "client":
|
||||
run_client()
|
||||
elif mode == "test":
|
||||
# Start server in background, run client, then stop
|
||||
print("Starting server in background...")
|
||||
server_thread = threading.Thread(target=run_server, daemon=True)
|
||||
server_thread.start()
|
||||
time.sleep(3) # Wait for server to start
|
||||
run_client()
|
||||
else:
|
||||
print("\nStarting A2A server...")
|
||||
print("Press Ctrl+C to stop")
|
||||
print("\nTo test, run in another terminal:")
|
||||
print(" python a2a_server_client.py client")
|
||||
run_server()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
A2U (Agent-to-User) Event Stream Example
|
||||
|
||||
Demonstrates:
|
||||
- Launching an A2U event stream server
|
||||
- Subscribing to agent events
|
||||
- SSE (Server-Sent Events) streaming
|
||||
- Event filtering
|
||||
|
||||
Usage:
|
||||
# Run this example (starts server)
|
||||
python a2u_events_stream.py
|
||||
|
||||
# Or use CLI:
|
||||
praisonai serve a2u --port 8083
|
||||
|
||||
# Test with curl:
|
||||
curl http://localhost:8083/a2u/info
|
||||
curl -N http://localhost:8083/a2u/events/events
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("Error: OPENAI_API_KEY environment variable not set")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_server():
|
||||
"""Run A2U server."""
|
||||
try:
|
||||
from fastapi import FastAPI
|
||||
import uvicorn
|
||||
from praisonai.endpoints.a2u_server import (
|
||||
create_a2u_routes,
|
||||
emit_agent_started,
|
||||
emit_agent_thinking,
|
||||
emit_agent_response,
|
||||
emit_agent_completed,
|
||||
)
|
||||
except ImportError as e:
|
||||
print(f"Error: Missing dependency - {e}")
|
||||
print("Install with: pip install praisonai[serve]")
|
||||
sys.exit(1)
|
||||
|
||||
app = FastAPI(title="PraisonAI A2U Server")
|
||||
|
||||
# Add A2U routes
|
||||
create_a2u_routes(app)
|
||||
|
||||
# Add discovery endpoint
|
||||
@app.get("/__praisonai__/discovery")
|
||||
async def discovery():
|
||||
return {
|
||||
"schema_version": "1.0.0",
|
||||
"server_name": "praisonai-a2u",
|
||||
"providers": [{"type": "a2u", "name": "A2U Event Stream", "capabilities": ["subscribe", "stream"]}],
|
||||
"endpoints": [{"name": "events", "provider_type": "a2u", "streaming": ["sse"]}]
|
||||
}
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": "PraisonAI A2U Server",
|
||||
"info": "/a2u/info",
|
||||
"events": "/a2u/events/<stream>",
|
||||
"discovery": "/__praisonai__/discovery"
|
||||
}
|
||||
|
||||
# Demo endpoint to emit events
|
||||
@app.post("/demo/emit")
|
||||
async def demo_emit():
|
||||
"""Emit demo events for testing."""
|
||||
agent_id = "demo-agent-1"
|
||||
emit_agent_started(agent_id, "DemoAgent")
|
||||
emit_agent_thinking(agent_id, "Processing your request...")
|
||||
emit_agent_response(agent_id, "Here is my response to your query.")
|
||||
emit_agent_completed(agent_id, {"status": "success"})
|
||||
return {"message": "Demo events emitted", "agent_id": agent_id}
|
||||
|
||||
print("Starting A2U server on http://localhost:8083")
|
||||
print("Info: http://localhost:8083/a2u/info")
|
||||
print("Events: http://localhost:8083/a2u/events/events")
|
||||
print("Demo: POST http://localhost:8083/demo/emit")
|
||||
|
||||
uvicorn.run(app, host="127.0.0.1", port=8083, log_level="warning")
|
||||
|
||||
|
||||
def run_client():
|
||||
"""Run A2U client test."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
base_url = "http://localhost:8083"
|
||||
|
||||
print("\n--- A2U Client Test ---")
|
||||
|
||||
# 1. Get A2U info
|
||||
print("\n1. Getting A2U Info...")
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/a2u/info")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
info = json.loads(resp.read().decode())
|
||||
print(f" Name: {info.get('name')}")
|
||||
print(f" Version: {info.get('version')}")
|
||||
print(f" Streams: {info.get('streams')}")
|
||||
print(f" Event Types: {info.get('event_types')}")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
return
|
||||
|
||||
# 2. Subscribe to events
|
||||
print("\n2. Subscribing to Events...")
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/a2u/subscribe",
|
||||
data=json.dumps({"stream": "events", "filters": []}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
sub = json.loads(resp.read().decode())
|
||||
print(f" Subscription ID: {sub.get('subscription_id')}")
|
||||
print(f" Stream URL: {sub.get('stream_url')}")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
|
||||
# 3. Emit demo events
|
||||
print("\n3. Emitting Demo Events...")
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/demo/emit",
|
||||
data=b"{}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
print(f" {result.get('message')}")
|
||||
print(f" Agent ID: {result.get('agent_id')}")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
|
||||
# 4. Check discovery
|
||||
print("\n4. Checking Discovery...")
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/__praisonai__/discovery")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
discovery = json.loads(resp.read().decode())
|
||||
print(f" Server: {discovery.get('server_name')}")
|
||||
print(f" Providers: {[p.get('type') for p in discovery.get('providers', [])]}")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
|
||||
print("\n✓ A2U client test completed!")
|
||||
print("\nTo stream events, run:")
|
||||
print(f" curl -N {base_url}/a2u/events/events")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("A2U (Agent-to-User) Event Stream Example")
|
||||
print("=" * 60)
|
||||
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "server"
|
||||
|
||||
if mode == "client":
|
||||
run_client()
|
||||
elif mode == "test":
|
||||
print("Starting server in background...")
|
||||
server_thread = threading.Thread(target=run_server, daemon=True)
|
||||
server_thread.start()
|
||||
time.sleep(2)
|
||||
run_client()
|
||||
else:
|
||||
print("\nStarting A2U server...")
|
||||
print("Press Ctrl+C to stop")
|
||||
print("\nTo test, run in another terminal:")
|
||||
print(" python a2u_events_stream.py client")
|
||||
run_server()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Single Agent as HTTP API Example
|
||||
|
||||
Demonstrates launching a single agent as an HTTP API server with:
|
||||
- REST endpoint for queries
|
||||
- Streaming support (SSE)
|
||||
- Discovery endpoint
|
||||
- Health check
|
||||
|
||||
Usage:
|
||||
# Run this example
|
||||
python agent_as_api_single.py
|
||||
|
||||
# Or use CLI:
|
||||
praisonai serve agents --file agents.yaml --port 8000
|
||||
|
||||
# Test with curl:
|
||||
curl -X POST http://localhost:8000/agent \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "What is AI?"}'
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure API key is set
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("Error: OPENAI_API_KEY environment variable not set")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Single Agent as HTTP API Example")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
except ImportError:
|
||||
print("Error: praisonaiagents not installed")
|
||||
print("Install with: pip install praisonaiagents")
|
||||
sys.exit(1)
|
||||
|
||||
# Create a single agent
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
role="Helpful AI Assistant",
|
||||
goal="Help users with their questions accurately and concisely",
|
||||
backstory="You are a knowledgeable assistant who provides clear, helpful answers.",
|
||||
llm="gpt-4o-mini",
|
||||
verbose=True
|
||||
)
|
||||
|
||||
print(f"Agent: {agent.name}")
|
||||
print(f"Role: {agent.role}")
|
||||
|
||||
# Launch as HTTP API
|
||||
print("\nLaunching agent as HTTP API...")
|
||||
print("Endpoints:")
|
||||
print(" POST /agent - Query the agent")
|
||||
print(" GET /health - Health check")
|
||||
print(" GET /__praisonai__/discovery - Discovery document")
|
||||
print("\nPress Ctrl+C to stop")
|
||||
|
||||
try:
|
||||
# Use Agent.launch() to start HTTP server
|
||||
agent.launch(
|
||||
port=8000,
|
||||
host="127.0.0.1",
|
||||
path="/agent"
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("\nServer stopped")
|
||||
except Exception as e:
|
||||
print(f"Error launching server: {e}")
|
||||
# Fallback: manual FastAPI setup
|
||||
print("\nFallback: Using manual FastAPI setup...")
|
||||
launch_manual(agent)
|
||||
|
||||
|
||||
def launch_manual(agent):
|
||||
"""Manual FastAPI server setup as fallback."""
|
||||
try:
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
import uvicorn
|
||||
except ImportError:
|
||||
print("Error: FastAPI/uvicorn not installed")
|
||||
print("Install with: pip install fastapi uvicorn")
|
||||
return
|
||||
|
||||
app = FastAPI(title="PraisonAI Agent API")
|
||||
|
||||
@app.post("/agent")
|
||||
async def query_agent(request: Request):
|
||||
try:
|
||||
body = await request.json()
|
||||
query = body.get("query", "")
|
||||
if not query:
|
||||
return JSONResponse({"error": "query required"}, status_code=400)
|
||||
|
||||
response = agent.chat(query)
|
||||
return {"response": response}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "healthy", "agent": agent.name}
|
||||
|
||||
@app.get("/__praisonai__/discovery")
|
||||
async def discovery():
|
||||
return {
|
||||
"schema_version": "1.0.0",
|
||||
"server_name": "praisonai-agent",
|
||||
"providers": [{"type": "agents-api", "name": "Single Agent API"}],
|
||||
"endpoints": [{"name": "agent", "provider_type": "agents-api"}]
|
||||
}
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": f"PraisonAI Agent: {agent.name}",
|
||||
"endpoints": ["/agent", "/health", "/__praisonai__/discovery"]
|
||||
}
|
||||
|
||||
uvicorn.run(app, host="127.0.0.1", port=8000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Agent.launch() and Agents.launch() Example
|
||||
|
||||
Demonstrates the high-level Python API for launching servers:
|
||||
- Agent.launch() for single agent
|
||||
- Agents.launch() for multi-agent
|
||||
- Different server modes (HTTP, MCP, A2A)
|
||||
|
||||
Usage:
|
||||
# Run this example
|
||||
python agent_launch_modes.py
|
||||
|
||||
# Test endpoints:
|
||||
curl http://localhost:8000/agent
|
||||
curl http://localhost:8000/health
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("Error: OPENAI_API_KEY environment variable not set")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def demo_single_agent_launch():
|
||||
"""Demo Agent.launch() for single agent."""
|
||||
print("\n--- Single Agent Launch ---")
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
except ImportError:
|
||||
print("Error: praisonaiagents not installed")
|
||||
return False
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
role="Helpful AI Assistant",
|
||||
goal="Help users with their questions",
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
print(f"Agent: {agent.name}")
|
||||
print("Launching as HTTP API on port 8000...")
|
||||
print("\nEndpoints:")
|
||||
print(" POST /agent - Query the agent")
|
||||
print(" GET /health - Health check")
|
||||
print("\nPress Ctrl+C to stop")
|
||||
|
||||
try:
|
||||
agent.launch(port=8000, host="127.0.0.1", path="/agent")
|
||||
except AttributeError:
|
||||
print("Agent.launch() not available, using fallback...")
|
||||
launch_single_fallback(agent)
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def demo_multi_agent_launch():
|
||||
"""Demo Agents.launch() for multi-agent."""
|
||||
print("\n--- Multi-Agent Launch ---")
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent, PraisonAIAgents
|
||||
except ImportError:
|
||||
print("Error: praisonaiagents not installed")
|
||||
return False
|
||||
|
||||
researcher = Agent(
|
||||
name="Researcher",
|
||||
role="Research Specialist",
|
||||
goal="Find accurate information",
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
writer = Agent(
|
||||
name="Writer",
|
||||
role="Content Writer",
|
||||
goal="Create engaging content",
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
agents = PraisonAIAgents(agents=[researcher, writer])
|
||||
|
||||
print(f"Agents: {[researcher.name, writer.name]}")
|
||||
print("Launching as HTTP API on port 8000...")
|
||||
print("\nEndpoints:")
|
||||
print(" POST /agents - Route to best agent")
|
||||
print(" POST /agents/researcher - Query researcher")
|
||||
print(" POST /agents/writer - Query writer")
|
||||
print(" GET /agents/list - List agents")
|
||||
print("\nPress Ctrl+C to stop")
|
||||
|
||||
try:
|
||||
agents.launch(port=8000, host="127.0.0.1")
|
||||
except AttributeError:
|
||||
print("Agents.launch() not available, using fallback...")
|
||||
launch_multi_fallback([researcher, writer])
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def launch_single_fallback(agent):
|
||||
"""Fallback implementation for Agent.launch()."""
|
||||
try:
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
import uvicorn
|
||||
except ImportError:
|
||||
print("Error: FastAPI/uvicorn not installed")
|
||||
return
|
||||
|
||||
app = FastAPI(title=f"PraisonAI Agent: {agent.name}")
|
||||
|
||||
@app.post("/agent")
|
||||
async def query(request: Request):
|
||||
try:
|
||||
body = await request.json()
|
||||
query_text = body.get("query", "")
|
||||
response = agent.chat(query_text)
|
||||
return {"response": response}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "healthy", "agent": agent.name}
|
||||
|
||||
@app.get("/__praisonai__/discovery")
|
||||
async def discovery():
|
||||
return {
|
||||
"schema_version": "1.0.0",
|
||||
"server_name": f"praisonai-{agent.name.lower()}",
|
||||
"providers": [{"type": "agents-api", "name": "Single Agent API"}],
|
||||
"endpoints": [{"name": "agent", "provider_type": "agents-api"}]
|
||||
}
|
||||
|
||||
uvicorn.run(app, host="127.0.0.1", port=8000)
|
||||
|
||||
|
||||
def launch_multi_fallback(agents):
|
||||
"""Fallback implementation for Agents.launch()."""
|
||||
try:
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
import uvicorn
|
||||
except ImportError:
|
||||
print("Error: FastAPI/uvicorn not installed")
|
||||
return
|
||||
|
||||
app = FastAPI(title="PraisonAI Multi-Agent API")
|
||||
agent_map = {a.name.lower(): a for a in agents}
|
||||
|
||||
@app.post("/agents")
|
||||
async def route(request: Request):
|
||||
try:
|
||||
body = await request.json()
|
||||
query_text = body.get("query", "")
|
||||
# Simple routing
|
||||
agent = agents[0]
|
||||
response = agent.chat(query_text)
|
||||
return {"agent": agent.name, "response": response}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
@app.post("/agents/{name}")
|
||||
async def query_agent(name: str, request: Request):
|
||||
agent = agent_map.get(name.lower())
|
||||
if not agent:
|
||||
return JSONResponse({"error": f"Agent not found: {name}"}, status_code=404)
|
||||
try:
|
||||
body = await request.json()
|
||||
response = agent.chat(body.get("query", ""))
|
||||
return {"agent": agent.name, "response": response}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
@app.get("/agents/list")
|
||||
async def list_agents():
|
||||
return {"agents": [{"name": a.name, "role": a.role} for a in agents]}
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "healthy", "agents": len(agents)}
|
||||
|
||||
@app.get("/__praisonai__/discovery")
|
||||
async def discovery():
|
||||
return {
|
||||
"schema_version": "1.0.0",
|
||||
"server_name": "praisonai-agents",
|
||||
"providers": [{"type": "agents-api", "name": "Multi-Agent API"}],
|
||||
"endpoints": [
|
||||
{"name": "agents", "provider_type": "agents-api"},
|
||||
*[{"name": f"agents/{a.name.lower()}", "provider_type": "agents-api"} for a in agents]
|
||||
]
|
||||
}
|
||||
|
||||
uvicorn.run(app, host="127.0.0.1", port=8000)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Agent.launch() and Agents.launch() Example")
|
||||
print("=" * 60)
|
||||
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "single"
|
||||
|
||||
if mode == "multi":
|
||||
demo_multi_agent_launch()
|
||||
else:
|
||||
demo_single_agent_launch()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Multi-Agent Router as HTTP API Example
|
||||
|
||||
Demonstrates launching multiple agents as an HTTP API with:
|
||||
- Router endpoint for automatic agent selection
|
||||
- Per-agent endpoints
|
||||
- Streaming support (SSE)
|
||||
- Discovery endpoint
|
||||
|
||||
Usage:
|
||||
# Run this example
|
||||
python agents_as_api_router.py
|
||||
|
||||
# Or use CLI:
|
||||
praisonai serve agents --file agents.yaml --port 8000
|
||||
|
||||
# Test with curl:
|
||||
curl -X POST http://localhost:8000/agents \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "Research AI trends"}'
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("Error: OPENAI_API_KEY environment variable not set")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Multi-Agent Router as HTTP API Example")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent, PraisonAIAgents
|
||||
except ImportError:
|
||||
print("Error: praisonaiagents not installed")
|
||||
print("Install with: pip install praisonaiagents")
|
||||
sys.exit(1)
|
||||
|
||||
# Create multiple agents
|
||||
researcher = Agent(
|
||||
name="Researcher",
|
||||
role="Research Specialist",
|
||||
goal="Find accurate and relevant information",
|
||||
backstory="Expert at finding and synthesizing information from various sources.",
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
writer = Agent(
|
||||
name="Writer",
|
||||
role="Content Writer",
|
||||
goal="Create engaging content from research",
|
||||
backstory="Skilled writer who transforms research into readable content.",
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
analyst = Agent(
|
||||
name="Analyst",
|
||||
role="Data Analyst",
|
||||
goal="Analyze data and provide insights",
|
||||
backstory="Expert at analyzing data and extracting meaningful insights.",
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
agents = [researcher, writer, analyst]
|
||||
|
||||
print(f"Agents: {[a.name for a in agents]}")
|
||||
|
||||
# Create multi-agent system
|
||||
praison = PraisonAIAgents(agents=agents)
|
||||
|
||||
print("\nLaunching multi-agent router as HTTP API...")
|
||||
print("Endpoints:")
|
||||
print(" POST /agents - Route to best agent")
|
||||
print(" POST /agents/researcher - Query researcher")
|
||||
print(" POST /agents/writer - Query writer")
|
||||
print(" POST /agents/analyst - Query analyst")
|
||||
print(" GET /agents/list - List all agents")
|
||||
print(" GET /health - Health check")
|
||||
print(" GET /__praisonai__/discovery - Discovery document")
|
||||
print("\nPress Ctrl+C to stop")
|
||||
|
||||
try:
|
||||
# Use Agents.launch() to start HTTP server
|
||||
praison.launch(
|
||||
port=8000,
|
||||
host="127.0.0.1"
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("\nServer stopped")
|
||||
except Exception as e:
|
||||
print(f"Error launching server: {e}")
|
||||
print("\nFallback: Using manual FastAPI setup...")
|
||||
launch_manual(agents)
|
||||
|
||||
|
||||
def launch_manual(agents):
|
||||
"""Manual FastAPI server setup as fallback."""
|
||||
try:
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
import uvicorn
|
||||
except ImportError:
|
||||
print("Error: FastAPI/uvicorn not installed")
|
||||
print("Install with: pip install fastapi uvicorn")
|
||||
return
|
||||
|
||||
app = FastAPI(title="PraisonAI Multi-Agent API")
|
||||
|
||||
# Create agent lookup
|
||||
agent_map = {a.name.lower(): a for a in agents}
|
||||
|
||||
@app.post("/agents")
|
||||
async def route_query(request: Request):
|
||||
"""Route query to best agent."""
|
||||
try:
|
||||
body = await request.json()
|
||||
query = body.get("query", "")
|
||||
if not query:
|
||||
return JSONResponse({"error": "query required"}, status_code=400)
|
||||
|
||||
# Simple routing based on keywords
|
||||
query_lower = query.lower()
|
||||
if any(w in query_lower for w in ["research", "find", "search"]):
|
||||
agent = agent_map.get("researcher", agents[0])
|
||||
elif any(w in query_lower for w in ["write", "create", "draft"]):
|
||||
agent = agent_map.get("writer", agents[0])
|
||||
elif any(w in query_lower for w in ["analyze", "data", "insight"]):
|
||||
agent = agent_map.get("analyst", agents[0])
|
||||
else:
|
||||
agent = agents[0]
|
||||
|
||||
response = agent.chat(query)
|
||||
return {"agent": agent.name, "response": response}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
@app.post("/agents/{agent_name}")
|
||||
async def query_agent(agent_name: str, request: Request):
|
||||
"""Query specific agent."""
|
||||
agent = agent_map.get(agent_name.lower())
|
||||
if not agent:
|
||||
return JSONResponse({"error": f"Agent not found: {agent_name}"}, status_code=404)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
query = body.get("query", "")
|
||||
response = agent.chat(query)
|
||||
return {"agent": agent.name, "response": response}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
@app.get("/agents/list")
|
||||
async def list_agents():
|
||||
return {"agents": [{"name": a.name, "role": a.role} for a in agents]}
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "healthy", "agents": len(agents)}
|
||||
|
||||
@app.get("/__praisonai__/discovery")
|
||||
async def discovery():
|
||||
return {
|
||||
"schema_version": "1.0.0",
|
||||
"server_name": "praisonai-agents-router",
|
||||
"providers": [{"type": "agents-api", "name": "Multi-Agent Router"}],
|
||||
"endpoints": [
|
||||
{"name": "agents", "provider_type": "agents-api"},
|
||||
*[{"name": f"agents/{a.name.lower()}", "provider_type": "agents-api"} for a in agents]
|
||||
]
|
||||
}
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": "PraisonAI Multi-Agent Router",
|
||||
"agents": [a.name for a in agents],
|
||||
"endpoints": ["/agents", "/agents/list", "/health"]
|
||||
}
|
||||
|
||||
uvicorn.run(app, host="127.0.0.1", port=8000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unified Endpoints Client Example
|
||||
|
||||
Demonstrates using the praisonai endpoints CLI to interact with any PraisonAI server.
|
||||
Works with recipe, agents-api, mcp, a2a, a2u, and tools-mcp servers.
|
||||
|
||||
Usage:
|
||||
# Start any server first (e.g., unified server)
|
||||
praisonai serve unified --port 8765
|
||||
|
||||
# Then run this example
|
||||
python endpoints_unified_client.py
|
||||
|
||||
# Or use CLI directly:
|
||||
praisonai endpoints list
|
||||
praisonai endpoints types
|
||||
praisonai endpoints health
|
||||
praisonai endpoints discovery
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def run_cli(args: list) -> tuple:
|
||||
"""Run praisonai endpoints CLI command."""
|
||||
cmd = ["python3", "-m", "praisonai.cli.main", "endpoints"] + args
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("PraisonAI Unified Endpoints Client Example")
|
||||
print("=" * 60)
|
||||
|
||||
server_url = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
|
||||
print(f"Server URL: {server_url}")
|
||||
|
||||
# 1. List provider types
|
||||
print("\n--- Provider Types ---")
|
||||
code, out, err = run_cli(["types", "--format", "json"])
|
||||
if code == 0:
|
||||
types = json.loads(out)
|
||||
print(f"Supported types: {[t['type'] for t in types]}")
|
||||
else:
|
||||
print(f"Error: {err}")
|
||||
|
||||
# 2. Health check
|
||||
print("\n--- Health Check ---")
|
||||
code, out, err = run_cli(["health", "--url", server_url])
|
||||
if code == 0:
|
||||
print("✓ Server is healthy")
|
||||
print(out.strip())
|
||||
elif code == 8:
|
||||
print("✗ Connection error - server not running")
|
||||
print("\nStart a server first:")
|
||||
print(" praisonai serve unified --port 8765")
|
||||
print(" praisonai serve agents --file agents.yaml --port 8765")
|
||||
print(" praisonai serve a2a --port 8765")
|
||||
return 1
|
||||
else:
|
||||
print(f"✗ Health check failed: {err}")
|
||||
return 1
|
||||
|
||||
# 3. Discovery document
|
||||
print("\n--- Discovery Document ---")
|
||||
code, out, err = run_cli(["discovery", "--url", server_url])
|
||||
if code == 0:
|
||||
try:
|
||||
discovery = json.loads(out)
|
||||
print(f"Server: {discovery.get('server_name', 'unknown')}")
|
||||
print(f"Schema version: {discovery.get('schema_version', 'unknown')}")
|
||||
providers = discovery.get('providers', [])
|
||||
print(f"Providers: {[p.get('type') for p in providers]}")
|
||||
endpoints = discovery.get('endpoints', [])
|
||||
print(f"Endpoints: {[e.get('name') for e in endpoints]}")
|
||||
except json.JSONDecodeError:
|
||||
print(out.strip())
|
||||
else:
|
||||
print(f"Discovery not available: {err}")
|
||||
|
||||
# 4. List endpoints
|
||||
print("\n--- List Endpoints ---")
|
||||
code, out, err = run_cli(["list", "--url", server_url, "--format", "json"])
|
||||
if code == 0:
|
||||
try:
|
||||
endpoints = json.loads(out)
|
||||
if isinstance(endpoints, list):
|
||||
for ep in endpoints[:5]: # Show first 5
|
||||
print(f" - {ep.get('name')}: {ep.get('description', '')[:50]}")
|
||||
else:
|
||||
print(out.strip()[:200])
|
||||
except json.JSONDecodeError:
|
||||
print(out.strip()[:200])
|
||||
else:
|
||||
print(f"List failed: {err}")
|
||||
|
||||
# 5. Filter by type
|
||||
print("\n--- Filter by Provider Type ---")
|
||||
for ptype in ["agents-api", "a2a", "recipe"]:
|
||||
code, out, err = run_cli(["list", "--url", server_url, "--type", ptype])
|
||||
if code == 0 and out.strip():
|
||||
print(f" {ptype}: {len(out.strip().split(chr(10)))} endpoints")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed successfully!")
|
||||
print("=" * 60)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MCP HTTP Server Example
|
||||
|
||||
Demonstrates launching an MCP server with HTTP transport:
|
||||
- HTTP-based MCP protocol
|
||||
- Tool registration
|
||||
- Discovery endpoint
|
||||
|
||||
Usage:
|
||||
# Run this example
|
||||
python mcp_http_server.py
|
||||
|
||||
# Or use CLI:
|
||||
praisonai serve mcp --transport http --port 8080
|
||||
|
||||
# Test with curl:
|
||||
curl http://localhost:8080/mcp/tools
|
||||
curl -X POST http://localhost:8080/mcp/tools/call \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tool": "search", "arguments": {"query": "AI news"}}'
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("MCP HTTP Server Example")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI
|
||||
import uvicorn
|
||||
except ImportError:
|
||||
print("Error: FastAPI/uvicorn not installed")
|
||||
print("Install with: pip install fastapi uvicorn")
|
||||
sys.exit(1)
|
||||
|
||||
# Define tools
|
||||
def search(query: str) -> str:
|
||||
"""Search the web for information."""
|
||||
return f"Search results for: {query}"
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a math expression safely."""
|
||||
try:
|
||||
# Safe eval for basic math
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if all(c in allowed for c in expression):
|
||||
return str(eval(expression))
|
||||
return "Error: Invalid expression"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def get_weather(city: str) -> str:
|
||||
"""Get weather for a city (mock)."""
|
||||
return f"Weather in {city}: Sunny, 72°F"
|
||||
|
||||
tools = {
|
||||
"search": {"func": search, "description": "Search the web for information"},
|
||||
"calculate": {"func": calculate, "description": "Calculate a math expression"},
|
||||
"get_weather": {"func": get_weather, "description": "Get weather for a city"},
|
||||
}
|
||||
|
||||
app = FastAPI(title="PraisonAI MCP Server")
|
||||
|
||||
@app.get("/mcp/tools")
|
||||
async def list_tools():
|
||||
"""List available MCP tools."""
|
||||
return {
|
||||
"tools": [
|
||||
{
|
||||
"name": name,
|
||||
"description": info["description"],
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
list(info["func"].__code__.co_varnames)[0]: {"type": "string"}
|
||||
},
|
||||
"required": [list(info["func"].__code__.co_varnames)[0]]
|
||||
}
|
||||
}
|
||||
for name, info in tools.items()
|
||||
]
|
||||
}
|
||||
|
||||
@app.post("/mcp/tools/call")
|
||||
async def call_tool(request: dict):
|
||||
"""Call an MCP tool."""
|
||||
tool_name = request.get("tool")
|
||||
arguments = request.get("arguments", {})
|
||||
|
||||
if tool_name not in tools:
|
||||
return {"error": f"Tool not found: {tool_name}"}
|
||||
|
||||
try:
|
||||
func = tools[tool_name]["func"]
|
||||
# Get first argument
|
||||
arg_name = list(func.__code__.co_varnames)[0]
|
||||
arg_value = arguments.get(arg_name, "")
|
||||
result = func(arg_value)
|
||||
return {"result": result, "tool": tool_name}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.get("/__praisonai__/discovery")
|
||||
async def discovery():
|
||||
return {
|
||||
"schema_version": "1.0.0",
|
||||
"server_name": "praisonai-mcp",
|
||||
"providers": [{"type": "mcp", "name": "MCP Server", "capabilities": ["list-tools", "call-tool"]}],
|
||||
"endpoints": [{"name": "mcp", "provider_type": "mcp"}]
|
||||
}
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "healthy", "tools": len(tools)}
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": "PraisonAI MCP Server (HTTP)",
|
||||
"tools": list(tools.keys()),
|
||||
"endpoints": ["/mcp/tools", "/mcp/tools/call", "/health"]
|
||||
}
|
||||
|
||||
print(f"Tools: {list(tools.keys())}")
|
||||
print("\nStarting MCP HTTP server on http://localhost:8080")
|
||||
print("List tools: GET http://localhost:8080/mcp/tools")
|
||||
print("Call tool: POST http://localhost:8080/mcp/tools/call")
|
||||
print("\nPress Ctrl+C to stop")
|
||||
|
||||
uvicorn.run(app, host="127.0.0.1", port=8080)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,350 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PraisonAI Recipe Server Advanced Features Example
|
||||
|
||||
Demonstrates:
|
||||
1. Rate limiting
|
||||
2. Request size limits
|
||||
3. Metrics endpoint
|
||||
4. Admin reload endpoint
|
||||
5. OpenAPI specification
|
||||
6. Workers configuration
|
||||
7. OpenTelemetry tracing
|
||||
|
||||
Prerequisites:
|
||||
- pip install praisonai[serve]
|
||||
- Set OPENAI_API_KEY environment variable
|
||||
|
||||
Usage:
|
||||
# Run this example
|
||||
python recipe_serve_features.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
# Configuration
|
||||
SERVER_URL = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
|
||||
API_KEY = os.environ.get("PRAISONAI_API_KEY", "test-api-key")
|
||||
|
||||
|
||||
def make_request(method: str, path: str, data: dict = None, headers: dict = None) -> dict:
|
||||
"""Make HTTP request to server."""
|
||||
url = f"{SERVER_URL}{path}"
|
||||
default_headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": API_KEY,
|
||||
}
|
||||
if headers:
|
||||
default_headers.update(headers)
|
||||
|
||||
body = json.dumps(data).encode() if data else None
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, data=body, headers=default_headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
return {
|
||||
"status": response.status,
|
||||
"data": json.loads(response.read().decode()),
|
||||
"headers": dict(response.headers)
|
||||
}
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode() if e.fp else ""
|
||||
try:
|
||||
error_data = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
error_data = {"raw": body}
|
||||
return {
|
||||
"status": e.code,
|
||||
"error": error_data,
|
||||
"headers": dict(e.headers) if hasattr(e, 'headers') else {}
|
||||
}
|
||||
except urllib.error.URLError as e:
|
||||
return {"status": 0, "error": str(e.reason)}
|
||||
|
||||
|
||||
def test_health():
|
||||
"""Test health endpoint."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST: Health Check")
|
||||
print("=" * 60)
|
||||
|
||||
result = make_request("GET", "/health")
|
||||
|
||||
if result.get("status") == 200:
|
||||
data = result["data"]
|
||||
print("✓ Server healthy")
|
||||
print(f" Service: {data.get('service')}")
|
||||
print(f" Version: {data.get('version')}")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ Server unhealthy: {result.get('error')}")
|
||||
return False
|
||||
|
||||
|
||||
def test_openapi():
|
||||
"""Test OpenAPI specification endpoint."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST: OpenAPI Specification")
|
||||
print("=" * 60)
|
||||
|
||||
result = make_request("GET", "/openapi.json")
|
||||
|
||||
if result.get("status") == 200:
|
||||
spec = result["data"]
|
||||
print("✓ OpenAPI spec available")
|
||||
print(f" Version: {spec.get('openapi')}")
|
||||
print(f" Title: {spec.get('info', {}).get('title')}")
|
||||
print(f" Paths: {list(spec.get('paths', {}).keys())[:5]}...")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ OpenAPI not available: {result.get('error')}")
|
||||
return False
|
||||
|
||||
|
||||
def test_metrics():
|
||||
"""Test metrics endpoint."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST: Metrics Endpoint")
|
||||
print("=" * 60)
|
||||
|
||||
result = make_request("GET", "/metrics")
|
||||
|
||||
if result.get("status") == 200:
|
||||
# Metrics returns plain text, not JSON
|
||||
print("✓ Metrics endpoint available")
|
||||
print(" (Prometheus format)")
|
||||
return True
|
||||
elif result.get("status") == 404:
|
||||
print("⚠ Metrics endpoint not enabled")
|
||||
print(" Enable with: --enable_metrics")
|
||||
return True # Not a failure, just not enabled
|
||||
else:
|
||||
print(f"✗ Metrics error: {result.get('error')}")
|
||||
return False
|
||||
|
||||
|
||||
def test_admin_reload():
|
||||
"""Test admin reload endpoint."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST: Admin Reload Endpoint")
|
||||
print("=" * 60)
|
||||
|
||||
result = make_request("POST", "/admin/reload")
|
||||
|
||||
if result.get("status") == 200:
|
||||
data = result["data"]
|
||||
print("✓ Admin reload successful")
|
||||
print(f" Status: {data.get('status')}")
|
||||
print(f" Timestamp: {data.get('timestamp')}")
|
||||
return True
|
||||
elif result.get("status") == 404:
|
||||
print("⚠ Admin endpoint not enabled")
|
||||
print(" Enable with: --enable_admin")
|
||||
return True # Not a failure, just not enabled
|
||||
elif result.get("status") == 401:
|
||||
print("⚠ Admin endpoint requires authentication")
|
||||
return True # Expected behavior
|
||||
else:
|
||||
print(f"✗ Admin reload error: {result.get('error')}")
|
||||
return False
|
||||
|
||||
|
||||
def test_rate_limiting():
|
||||
"""Test rate limiting."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST: Rate Limiting")
|
||||
print("=" * 60)
|
||||
|
||||
# Make rapid requests
|
||||
rate_limited = False
|
||||
retry_after = None
|
||||
|
||||
for i in range(20):
|
||||
result = make_request("GET", "/v1/recipes")
|
||||
|
||||
if result.get("status") == 429:
|
||||
rate_limited = True
|
||||
retry_after = result.get("headers", {}).get("Retry-After")
|
||||
error = result.get("error", {})
|
||||
print(f"✓ Rate limited after {i} requests")
|
||||
print(f" Error code: {error.get('error', {}).get('code')}")
|
||||
print(f" Retry-After: {retry_after}")
|
||||
break
|
||||
|
||||
if not rate_limited:
|
||||
print("⚠ Rate limiting not triggered (may not be enabled)")
|
||||
print(" Enable with: --rate_limit 10")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_request_size_limit():
|
||||
"""Test request size limit."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST: Request Size Limit")
|
||||
print("=" * 60)
|
||||
|
||||
# Create a large payload
|
||||
large_data = {
|
||||
"recipe": "test",
|
||||
"input": {"data": "x" * 100000} # ~100KB
|
||||
}
|
||||
|
||||
result = make_request("POST", "/v1/recipes/run", large_data)
|
||||
|
||||
if result.get("status") == 413:
|
||||
error = result.get("error", {})
|
||||
print("✓ Request size limit enforced")
|
||||
print(f" Error code: {error.get('error', {}).get('code')}")
|
||||
return True
|
||||
elif result.get("status") in [200, 400, 404]:
|
||||
print("⚠ Request size limit not triggered (limit may be higher)")
|
||||
print(" Set with: --max_request_size 1000")
|
||||
return True
|
||||
else:
|
||||
print(f" Response: {result.get('status')} - {result.get('error')}")
|
||||
return True
|
||||
|
||||
|
||||
def test_list_recipes():
|
||||
"""Test listing recipes."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST: List Recipes")
|
||||
print("=" * 60)
|
||||
|
||||
result = make_request("GET", "/v1/recipes")
|
||||
|
||||
if result.get("status") == 200:
|
||||
recipes = result["data"].get("recipes", [])
|
||||
print(f"✓ Found {len(recipes)} recipes")
|
||||
for r in recipes[:3]:
|
||||
print(f" - {r.get('name')} ({r.get('version', 'unknown')})")
|
||||
if len(recipes) > 3:
|
||||
print(f" ... and {len(recipes) - 3} more")
|
||||
return True
|
||||
elif result.get("status") == 401:
|
||||
print("⚠ Authentication required")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ Error: {result.get('error')}")
|
||||
return False
|
||||
|
||||
|
||||
def demonstrate_programmatic_usage():
|
||||
"""Demonstrate programmatic server configuration."""
|
||||
print("\n" + "=" * 60)
|
||||
print("DEMO: Programmatic Configuration")
|
||||
print("=" * 60)
|
||||
|
||||
print("""
|
||||
# Rate Limiter
|
||||
from praisonai.recipe.serve import create_rate_limiter
|
||||
limiter = create_rate_limiter(requests_per_minute=100)
|
||||
allowed, retry_after = limiter.check("client-id")
|
||||
|
||||
# Metrics Collector
|
||||
from praisonai.recipe.serve import MetricsCollector
|
||||
metrics = MetricsCollector()
|
||||
metrics.record_request("/health", "GET", 200, 0.01)
|
||||
print(metrics.get_prometheus_metrics())
|
||||
|
||||
# OpenAPI Spec
|
||||
from praisonai.recipe.serve import get_openapi_spec
|
||||
spec = get_openapi_spec({"enable_metrics": True, "enable_admin": True})
|
||||
print(spec["paths"].keys())
|
||||
|
||||
# Template Search Paths
|
||||
from praisonai.recipe.core import get_template_search_paths
|
||||
paths = get_template_search_paths()
|
||||
print(f"Searching {len(paths)} paths for recipes")
|
||||
|
||||
# Reload Registry
|
||||
from praisonai.recipe.core import reload_registry
|
||||
reload_registry()
|
||||
print("Registry reloaded")
|
||||
""")
|
||||
|
||||
# Actually run the demos
|
||||
try:
|
||||
from praisonai.recipe.serve import create_rate_limiter, MetricsCollector, get_openapi_spec
|
||||
from praisonai.recipe.core import get_template_search_paths, reload_registry
|
||||
|
||||
# Rate limiter
|
||||
limiter = create_rate_limiter(requests_per_minute=100)
|
||||
allowed, _ = limiter.check("demo-client")
|
||||
print(f"✓ Rate limiter: allowed={allowed}")
|
||||
|
||||
# Metrics
|
||||
metrics = MetricsCollector()
|
||||
metrics.record_request("/demo", "GET", 200, 0.01)
|
||||
prom = metrics.get_prometheus_metrics()
|
||||
print(f"✓ Metrics: {len(prom)} chars of Prometheus format")
|
||||
|
||||
# OpenAPI
|
||||
spec = get_openapi_spec({"enable_metrics": True})
|
||||
print(f"✓ OpenAPI: {len(spec['paths'])} paths defined")
|
||||
|
||||
# Search paths
|
||||
paths = get_template_search_paths()
|
||||
print(f"✓ Template paths: {len(paths)} directories")
|
||||
|
||||
# Reload
|
||||
reload_registry()
|
||||
print("✓ Registry reloaded")
|
||||
|
||||
except ImportError as e:
|
||||
print(f"⚠ Import error: {e}")
|
||||
print(" Install with: pip install praisonai[serve]")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main example function."""
|
||||
print("=" * 60)
|
||||
print("PraisonAI Recipe Server Advanced Features")
|
||||
print("=" * 60)
|
||||
print(f"Server URL: {SERVER_URL}")
|
||||
print(f"API Key: {'*' * 8}..." if API_KEY else "API Key: Not set")
|
||||
|
||||
# Check if server is running
|
||||
if not test_health():
|
||||
print("\n" + "=" * 60)
|
||||
print("Server not running. Start it with:")
|
||||
print("=" * 60)
|
||||
print(f"""
|
||||
# Basic start
|
||||
praisonai recipe serve
|
||||
|
||||
# With all features enabled
|
||||
export PRAISONAI_API_KEY={API_KEY}
|
||||
praisonai recipe serve \\
|
||||
--auth api-key \\
|
||||
--rate_limit 100 \\
|
||||
--enable_metrics \\
|
||||
--enable_admin \\
|
||||
--workers 2
|
||||
""")
|
||||
# Still demonstrate programmatic usage
|
||||
demonstrate_programmatic_usage()
|
||||
return
|
||||
|
||||
# Run all tests
|
||||
test_openapi()
|
||||
test_metrics()
|
||||
test_admin_reload()
|
||||
test_rate_limiting()
|
||||
test_request_size_limit()
|
||||
test_list_recipes()
|
||||
|
||||
# Demonstrate programmatic usage
|
||||
demonstrate_programmatic_usage()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("All tests completed!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tools as MCP Server Example
|
||||
|
||||
Demonstrates exposing Python tools as an MCP server:
|
||||
- Register custom functions as MCP tools
|
||||
- SSE transport for Claude Desktop/Cursor
|
||||
- Stdio transport for local integration
|
||||
|
||||
Usage:
|
||||
# Run this example (SSE mode)
|
||||
python tools_as_mcp_server.py
|
||||
|
||||
# Or use CLI:
|
||||
praisonai serve tools --port 8081
|
||||
|
||||
# Test with curl:
|
||||
curl http://localhost:8081/sse
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Tools as MCP Server Example")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
from praisonaiagents.mcp import ToolsMCPServer
|
||||
except ImportError:
|
||||
print("Error: praisonaiagents[mcp] not installed")
|
||||
print("Install with: pip install 'praisonaiagents[mcp]'")
|
||||
print("\nFallback: Using manual implementation...")
|
||||
run_manual_server()
|
||||
return
|
||||
|
||||
# Define custom tools
|
||||
def search_web(query: str) -> str:
|
||||
"""Search the web for information."""
|
||||
return f"Search results for: {query}"
|
||||
|
||||
def analyze_text(text: str) -> str:
|
||||
"""Analyze text for sentiment and key points."""
|
||||
word_count = len(text.split())
|
||||
return f"Analysis: {word_count} words, sentiment: neutral"
|
||||
|
||||
def translate(text: str, target_language: str = "Spanish") -> str:
|
||||
"""Translate text to target language (mock)."""
|
||||
return f"[Translated to {target_language}]: {text}"
|
||||
|
||||
def summarize(text: str) -> str:
|
||||
"""Summarize long text."""
|
||||
words = text.split()
|
||||
if len(words) > 20:
|
||||
return " ".join(words[:20]) + "..."
|
||||
return text
|
||||
|
||||
# Create MCP server
|
||||
server = ToolsMCPServer(name="praisonai-tools")
|
||||
|
||||
# Register tools
|
||||
server.register_tool(search_web)
|
||||
server.register_tool(analyze_text)
|
||||
server.register_tool(translate)
|
||||
server.register_tool(summarize)
|
||||
|
||||
print(f"Registered tools: {[search_web.__name__, analyze_text.__name__, translate.__name__, summarize.__name__]}")
|
||||
print("\nStarting MCP server with SSE transport...")
|
||||
print("SSE endpoint: http://localhost:8081/sse")
|
||||
print("\nClaude Desktop config (claude_desktop_config.json):")
|
||||
print(''' {
|
||||
"mcpServers": {
|
||||
"praisonai-tools": {
|
||||
"url": "http://localhost:8081/sse"
|
||||
}
|
||||
}
|
||||
}''')
|
||||
print("\nPress Ctrl+C to stop")
|
||||
|
||||
server.run(transport="sse", host="0.0.0.0", port=8081)
|
||||
|
||||
|
||||
def run_manual_server():
|
||||
"""Manual MCP server implementation as fallback."""
|
||||
try:
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
import uvicorn
|
||||
import json
|
||||
import asyncio
|
||||
except ImportError:
|
||||
print("Error: FastAPI/uvicorn not installed")
|
||||
print("Install with: pip install fastapi uvicorn")
|
||||
sys.exit(1)
|
||||
|
||||
# Define tools
|
||||
tools = {
|
||||
"search_web": {
|
||||
"func": lambda query: f"Search results for: {query}",
|
||||
"description": "Search the web for information",
|
||||
"params": {"query": "string"}
|
||||
},
|
||||
"analyze_text": {
|
||||
"func": lambda text: f"Analysis: {len(text.split())} words",
|
||||
"description": "Analyze text for sentiment and key points",
|
||||
"params": {"text": "string"}
|
||||
},
|
||||
}
|
||||
|
||||
app = FastAPI(title="PraisonAI Tools MCP Server")
|
||||
|
||||
@app.get("/sse")
|
||||
async def sse_endpoint():
|
||||
"""SSE endpoint for MCP."""
|
||||
async def event_stream():
|
||||
# Send initial tools list
|
||||
tools_list = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tools/list",
|
||||
"result": {
|
||||
"tools": [
|
||||
{
|
||||
"name": name,
|
||||
"description": info["description"],
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {k: {"type": v} for k, v in info["params"].items()},
|
||||
"required": list(info["params"].keys())
|
||||
}
|
||||
}
|
||||
for name, info in tools.items()
|
||||
]
|
||||
}
|
||||
}
|
||||
yield f"data: {json.dumps(tools_list)}\n\n"
|
||||
|
||||
# Keep connection alive
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
yield f"data: {json.dumps({'type': 'ping'})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}
|
||||
)
|
||||
|
||||
@app.post("/messages/")
|
||||
async def handle_message(request: dict):
|
||||
"""Handle MCP messages."""
|
||||
method = request.get("method")
|
||||
params = request.get("params", {})
|
||||
req_id = request.get("id")
|
||||
|
||||
if method == "tools/list":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"tools": [
|
||||
{
|
||||
"name": name,
|
||||
"description": info["description"],
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {k: {"type": v} for k, v in info["params"].items()}
|
||||
}
|
||||
}
|
||||
for name, info in tools.items()
|
||||
]
|
||||
}
|
||||
}
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
arguments = params.get("arguments", {})
|
||||
|
||||
if tool_name not in tools:
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"Tool not found: {tool_name}"}}
|
||||
|
||||
try:
|
||||
func = tools[tool_name]["func"]
|
||||
arg = list(arguments.values())[0] if arguments else ""
|
||||
result = func(arg)
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {"content": [{"type": "text", "text": result}]}
|
||||
}
|
||||
except Exception as e:
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32000, "message": str(e)}}
|
||||
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"Unknown method: {method}"}}
|
||||
|
||||
@app.get("/__praisonai__/discovery")
|
||||
async def discovery():
|
||||
return {
|
||||
"schema_version": "1.0.0",
|
||||
"server_name": "praisonai-tools-mcp",
|
||||
"providers": [{"type": "tools-mcp", "name": "Tools MCP Server", "capabilities": ["list-tools", "call-tool"]}],
|
||||
"endpoints": [{"name": "tools", "provider_type": "tools-mcp"}]
|
||||
}
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": "PraisonAI Tools MCP Server",
|
||||
"tools": list(tools.keys()),
|
||||
"sse": "/sse",
|
||||
"messages": "/messages/"
|
||||
}
|
||||
|
||||
print(f"Tools: {list(tools.keys())}")
|
||||
print("\nStarting Tools MCP server on http://localhost:8081")
|
||||
print("SSE endpoint: http://localhost:8081/sse")
|
||||
print("\nPress Ctrl+C to stop")
|
||||
|
||||
uvicorn.run(app, host="127.0.0.1", port=8081)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unified Server Example
|
||||
|
||||
Demonstrates launching a unified server with all provider types:
|
||||
- agents-api: Single/multi-agent HTTP API
|
||||
- recipe: Recipe runner endpoints
|
||||
- mcp: MCP server
|
||||
- a2a: Agent-to-agent protocol
|
||||
- a2u: Agent-to-user event stream
|
||||
|
||||
Usage:
|
||||
# Run this example
|
||||
python unified_server.py
|
||||
|
||||
# Or use CLI:
|
||||
praisonai serve unified --port 8765
|
||||
|
||||
# Test with curl:
|
||||
curl http://localhost:8765/__praisonai__/discovery
|
||||
curl http://localhost:8765/health
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("Error: OPENAI_API_KEY environment variable not set")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Unified Server Example")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI
|
||||
import uvicorn
|
||||
from praisonaiagents import Agent
|
||||
except ImportError as e:
|
||||
print(f"Error: Missing dependency - {e}")
|
||||
print("Install with: pip install praisonaiagents fastapi uvicorn")
|
||||
sys.exit(1)
|
||||
|
||||
# Create agents
|
||||
assistant = Agent(
|
||||
name="Assistant",
|
||||
role="Helpful AI Assistant",
|
||||
goal="Help users with their questions",
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
researcher = Agent(
|
||||
name="Researcher",
|
||||
role="Research Specialist",
|
||||
goal="Find accurate information",
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
agents = [assistant, researcher]
|
||||
agent_map = {a.name.lower(): a for a in agents}
|
||||
|
||||
# Create unified app
|
||||
app = FastAPI(
|
||||
title="PraisonAI Unified Server",
|
||||
description="Unified server with all PraisonAI providers"
|
||||
)
|
||||
|
||||
# Discovery endpoint
|
||||
@app.get("/__praisonai__/discovery")
|
||||
async def discovery():
|
||||
return {
|
||||
"schema_version": "1.0.0",
|
||||
"server_name": "praisonai-unified",
|
||||
"providers": [
|
||||
{"type": "agents-api", "name": "Agents API", "capabilities": ["invoke", "health"]},
|
||||
{"type": "recipe", "name": "Recipe Runner", "capabilities": ["list", "describe", "invoke"]},
|
||||
{"type": "mcp", "name": "MCP Server", "capabilities": ["list-tools", "call-tool"]},
|
||||
{"type": "a2a", "name": "A2A Protocol", "capabilities": ["agent-card", "message-send"]},
|
||||
{"type": "a2u", "name": "A2U Event Stream", "capabilities": ["subscribe", "stream"]},
|
||||
],
|
||||
"endpoints": [
|
||||
{"name": "agents", "provider_type": "agents-api", "description": "Multi-agent router"},
|
||||
{"name": "agent", "provider_type": "agents-api", "description": "Single agent endpoint"},
|
||||
{"name": "mcp/tools", "provider_type": "mcp", "description": "MCP tools"},
|
||||
{"name": "a2a", "provider_type": "a2a", "description": "A2A protocol"},
|
||||
{"name": "a2u/events", "provider_type": "a2u", "description": "Event stream"},
|
||||
]
|
||||
}
|
||||
|
||||
# Health endpoint
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"providers": ["agents-api", "recipe", "mcp", "a2a", "a2u"],
|
||||
"agents": len(agents)
|
||||
}
|
||||
|
||||
# Agents API endpoints
|
||||
@app.post("/agents")
|
||||
async def route_agents(request: dict):
|
||||
query = request.get("query", "")
|
||||
agent = agents[0] # Simple routing
|
||||
try:
|
||||
response = agent.chat(query)
|
||||
return {"agent": agent.name, "response": response}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.post("/agents/{name}")
|
||||
async def query_agent(name: str, request: dict):
|
||||
agent = agent_map.get(name.lower())
|
||||
if not agent:
|
||||
return {"error": f"Agent not found: {name}"}
|
||||
try:
|
||||
response = agent.chat(request.get("query", ""))
|
||||
return {"agent": agent.name, "response": response}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.get("/agents/list")
|
||||
async def list_agents():
|
||||
return {"agents": [{"name": a.name, "role": a.role} for a in agents]}
|
||||
|
||||
# Single agent endpoint
|
||||
@app.post("/agent")
|
||||
async def single_agent(request: dict):
|
||||
query = request.get("query", "")
|
||||
try:
|
||||
response = assistant.chat(query)
|
||||
return {"response": response}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# MCP endpoints
|
||||
mcp_tools = {
|
||||
"search": {"description": "Search the web", "func": lambda q: f"Results for: {q}"},
|
||||
"calculate": {"description": "Calculate expression", "func": lambda e: str(eval(e)) if e.replace(" ", "").replace("+", "").replace("-", "").replace("*", "").replace("/", "").replace(".", "").isdigit() or True else "Error"},
|
||||
}
|
||||
|
||||
@app.get("/mcp/tools")
|
||||
async def list_mcp_tools():
|
||||
return {
|
||||
"tools": [
|
||||
{"name": name, "description": info["description"]}
|
||||
for name, info in mcp_tools.items()
|
||||
]
|
||||
}
|
||||
|
||||
@app.post("/mcp/tools/call")
|
||||
async def call_mcp_tool(request: dict):
|
||||
tool_name = request.get("tool")
|
||||
args = request.get("arguments", {})
|
||||
if tool_name not in mcp_tools:
|
||||
return {"error": f"Tool not found: {tool_name}"}
|
||||
try:
|
||||
result = mcp_tools[tool_name]["func"](list(args.values())[0] if args else "")
|
||||
return {"result": result}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# A2A endpoints
|
||||
@app.get("/.well-known/agent.json")
|
||||
async def agent_card():
|
||||
return {
|
||||
"name": "PraisonAI Unified",
|
||||
"description": "Unified PraisonAI server with multiple agents",
|
||||
"url": "http://localhost:8765/a2a",
|
||||
"version": "1.0.0",
|
||||
"capabilities": {"streaming": True}
|
||||
}
|
||||
|
||||
@app.post("/a2a")
|
||||
async def a2a_endpoint(request: dict):
|
||||
method = request.get("method")
|
||||
params = request.get("params", {})
|
||||
req_id = request.get("id")
|
||||
|
||||
if method == "message/send":
|
||||
message = params.get("message", {})
|
||||
parts = message.get("parts", [])
|
||||
text = "".join(p.get("text", "") for p in parts if p.get("type") == "text")
|
||||
|
||||
try:
|
||||
response = assistant.chat(text)
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"message": {
|
||||
"role": "agent",
|
||||
"parts": [{"type": "text", "text": response}]
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32000, "message": str(e)}}
|
||||
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"Unknown method: {method}"}}
|
||||
|
||||
# A2U endpoints
|
||||
@app.get("/a2u/info")
|
||||
async def a2u_info():
|
||||
return {
|
||||
"name": "A2U Event Stream",
|
||||
"version": "1.0.0",
|
||||
"streams": ["events"],
|
||||
"event_types": ["agent.started", "agent.response", "agent.completed"]
|
||||
}
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": "PraisonAI Unified Server",
|
||||
"providers": ["agents-api", "recipe", "mcp", "a2a", "a2u"],
|
||||
"discovery": "/__praisonai__/discovery",
|
||||
"health": "/health",
|
||||
"docs": "/docs"
|
||||
}
|
||||
|
||||
print(f"Agents: {[a.name for a in agents]}")
|
||||
print("\nStarting unified server on http://localhost:8765")
|
||||
print("\nEndpoints:")
|
||||
print(" GET /__praisonai__/discovery - Discovery document")
|
||||
print(" GET /health - Health check")
|
||||
print(" POST /agents - Multi-agent router")
|
||||
print(" POST /agent - Single agent")
|
||||
print(" GET /mcp/tools - List MCP tools")
|
||||
print(" GET /.well-known/agent.json - A2A agent card")
|
||||
print(" POST /a2a - A2A messages")
|
||||
print(" GET /a2u/info - A2U info")
|
||||
print("\nPress Ctrl+C to stop")
|
||||
|
||||
uvicorn.run(app, host="127.0.0.1", port=8765)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,66 @@
|
||||
# Recipe Background Tasks Example
|
||||
|
||||
This example demonstrates how to run recipes and agents as background tasks.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
pip install praisonai praisonaiagents
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
## Python Example
|
||||
|
||||
```bash
|
||||
python example_background.py
|
||||
```
|
||||
|
||||
## CLI Examples
|
||||
|
||||
### Submit a recipe as background task
|
||||
|
||||
```bash
|
||||
# Submit recipe to background
|
||||
praisonai background submit --recipe my-recipe --input '{"query": "test"}'
|
||||
|
||||
# Check status
|
||||
praisonai background status <task_id>
|
||||
|
||||
# List all tasks
|
||||
praisonai background list
|
||||
|
||||
# Cancel a task
|
||||
praisonai background cancel <task_id>
|
||||
|
||||
# Clear completed tasks
|
||||
praisonai background clear
|
||||
```
|
||||
|
||||
### Run recipe with --background flag
|
||||
|
||||
```bash
|
||||
# Run recipe in background
|
||||
praisonai recipe run my-recipe --background
|
||||
|
||||
# With input
|
||||
praisonai recipe run my-recipe --background --input '{"topic": "AI"}'
|
||||
|
||||
# With session ID
|
||||
praisonai recipe run my-recipe --background --session-id session_123
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Async Execution**: Tasks run in the background without blocking
|
||||
- **Progress Tracking**: Monitor task progress and status
|
||||
- **Cancellation**: Cancel running tasks when needed
|
||||
- **Result Retrieval**: Get results when tasks complete
|
||||
- **Concurrency Control**: Limit concurrent tasks
|
||||
|
||||
## Safe Defaults
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `timeout_sec` | 300 | Maximum execution time |
|
||||
| `max_concurrent` | 5 | Maximum concurrent tasks |
|
||||
| `cleanup_delay_sec` | 3600 | Auto-cleanup delay |
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Recipe Background Tasks Example
|
||||
|
||||
This example demonstrates how to run recipes as background tasks
|
||||
with progress tracking and result retrieval.
|
||||
|
||||
Requirements:
|
||||
pip install praisonai
|
||||
|
||||
Usage:
|
||||
python example_background.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run a recipe as a background task."""
|
||||
|
||||
print("=" * 60)
|
||||
print("Recipe Background Tasks Example")
|
||||
print("=" * 60)
|
||||
|
||||
# For this example, we'll use a simple inline agent instead of a recipe
|
||||
# In production, you would use an actual recipe name
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.background import BackgroundRunner
|
||||
|
||||
# Create an agent
|
||||
agent = Agent(
|
||||
name="Research Assistant",
|
||||
instructions="You are a helpful research assistant. Provide concise answers.",
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Create background runner
|
||||
runner = BackgroundRunner(max_concurrent_tasks=3)
|
||||
|
||||
print("\n1. Submitting task to background...")
|
||||
|
||||
# Submit task
|
||||
task = await runner.submit(
|
||||
lambda: agent.start("What are the top 3 benefits of AI in healthcare?"),
|
||||
name="healthcare-research",
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
print(f" Task ID: {task.id}")
|
||||
print(f" Status: {task.status.value}")
|
||||
|
||||
print("\n2. Waiting for completion...")
|
||||
|
||||
# Wait for result
|
||||
result = await runner.wait_for_task(task.id, timeout=120)
|
||||
|
||||
print("\n3. Task completed!")
|
||||
print(f" Status: {task.status.value}")
|
||||
print(f" Result preview: {str(result)[:200]}...")
|
||||
|
||||
# List all tasks
|
||||
print("\n4. Listing all tasks:")
|
||||
tasks = runner.list_tasks()
|
||||
for t in tasks:
|
||||
print(" - %s: %s" % (t['id'], t['status']))
|
||||
|
||||
# Clear completed tasks
|
||||
print("\n5. Clearing completed tasks...")
|
||||
runner.clear_completed()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,97 @@
|
||||
# Recipe Async Jobs Example
|
||||
|
||||
This example demonstrates how to submit recipes as async jobs to a jobs server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
pip install praisonai praisonaiagents httpx uvicorn
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
## Start the Jobs Server
|
||||
|
||||
```bash
|
||||
python -m uvicorn praisonai.jobs.server:create_app --port 8005 --factory
|
||||
```
|
||||
|
||||
## Python Example
|
||||
|
||||
```bash
|
||||
python example_jobs.py
|
||||
```
|
||||
|
||||
## CLI Examples
|
||||
|
||||
### Submit a job
|
||||
|
||||
```bash
|
||||
# Basic submission
|
||||
praisonai run submit "Analyze AI trends"
|
||||
|
||||
# Submit with recipe
|
||||
praisonai run submit "Analyze news" --recipe news-analyzer
|
||||
|
||||
# Wait for completion
|
||||
praisonai run submit "Quick task" --wait
|
||||
|
||||
# Stream progress
|
||||
praisonai run submit "Long task" --stream
|
||||
|
||||
# With webhook
|
||||
praisonai run submit "Task" --webhook-url https://example.com/callback
|
||||
|
||||
# With idempotency
|
||||
praisonai run submit "Task" --idempotency-key order-123
|
||||
|
||||
# With metadata
|
||||
praisonai run submit "Task" --metadata user=john --metadata priority=high
|
||||
```
|
||||
|
||||
### Check status
|
||||
|
||||
```bash
|
||||
praisonai run status <job_id>
|
||||
praisonai run status <job_id> --json
|
||||
```
|
||||
|
||||
### Get result
|
||||
|
||||
```bash
|
||||
praisonai run result <job_id>
|
||||
praisonai run result <job_id> --json
|
||||
```
|
||||
|
||||
### Stream progress
|
||||
|
||||
```bash
|
||||
praisonai run stream <job_id>
|
||||
```
|
||||
|
||||
### List jobs
|
||||
|
||||
```bash
|
||||
praisonai run list
|
||||
praisonai run list --status running
|
||||
```
|
||||
|
||||
### Cancel job
|
||||
|
||||
```bash
|
||||
praisonai run cancel <job_id>
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Server-based**: Jobs persist across restarts
|
||||
- **Webhooks**: Get notified when jobs complete
|
||||
- **Idempotency**: Prevent duplicate submissions
|
||||
- **Streaming**: Real-time progress updates
|
||||
- **Metadata**: Attach custom data to jobs
|
||||
|
||||
## Safe Defaults
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `timeout` | 3600 | Maximum execution time (1 hour) |
|
||||
| `api_url` | http://127.0.0.1:8005 | Jobs server URL |
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Recipe Async Jobs Example
|
||||
|
||||
This example demonstrates how to submit recipes as async jobs
|
||||
to a jobs server with webhooks and idempotency.
|
||||
|
||||
Requirements:
|
||||
pip install praisonai httpx
|
||||
|
||||
Usage:
|
||||
1. Start the jobs server:
|
||||
python -m uvicorn praisonai.jobs.server:create_app --port 8005 --factory
|
||||
|
||||
2. Run this example:
|
||||
python example_jobs.py
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
|
||||
def main():
|
||||
"""Submit and monitor an async job."""
|
||||
|
||||
print("=" * 60)
|
||||
print("Recipe Async Jobs Example")
|
||||
print("=" * 60)
|
||||
|
||||
# Import the jobs handler
|
||||
from praisonai.cli.features.jobs import JobsHandler
|
||||
|
||||
# Create handler
|
||||
handler = JobsHandler(
|
||||
api_url="http://127.0.0.1:8005",
|
||||
verbose=True
|
||||
)
|
||||
|
||||
print("\n1. Submitting job...")
|
||||
|
||||
try:
|
||||
# Submit a job
|
||||
result = handler.submit(
|
||||
prompt="What are the key trends in AI for 2024?",
|
||||
framework="praisonai",
|
||||
timeout=300,
|
||||
wait=False, # Don't wait, we'll poll manually
|
||||
output_json=False
|
||||
)
|
||||
|
||||
job_id = result.get("job_id")
|
||||
print(f" Job ID: {job_id}")
|
||||
print(f" Status: {result.get('status')}")
|
||||
|
||||
print("\n2. Polling for completion...")
|
||||
|
||||
# Poll for completion
|
||||
max_polls = 60
|
||||
for i in range(max_polls):
|
||||
status_result = handler.status(job_id)
|
||||
status = status_result.get("status")
|
||||
progress = status_result.get("progress", 0)
|
||||
|
||||
print(f" [{i+1}/{max_polls}] Status: {status}, Progress: {progress}%")
|
||||
|
||||
if status in ["succeeded", "failed", "cancelled"]:
|
||||
break
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
print("\n3. Getting result...")
|
||||
|
||||
# Get result
|
||||
result = handler.result(job_id)
|
||||
print(f" Status: {result.get('status')}")
|
||||
|
||||
if result.get("result"):
|
||||
output = str(result.get("result"))
|
||||
print(f" Result preview: {output[:200]}...")
|
||||
|
||||
print("\n4. Listing all jobs...")
|
||||
|
||||
# List jobs
|
||||
handler.list_jobs(page=1, page_size=10)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n Error: {e}")
|
||||
print("\n Make sure the jobs server is running:")
|
||||
print(" python -m uvicorn praisonai.jobs.server:create_app --port 8005 --factory")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
# Recipe Scheduler Example
|
||||
|
||||
This example demonstrates how to schedule recipes and agents to run periodically.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
pip install praisonai praisonaiagents
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
## Python Example
|
||||
|
||||
```bash
|
||||
python example_scheduler.py
|
||||
```
|
||||
|
||||
Press Ctrl+C to stop the scheduler and see statistics.
|
||||
|
||||
## CLI Examples
|
||||
|
||||
### Start a scheduler with task
|
||||
|
||||
```bash
|
||||
# Basic scheduler
|
||||
praisonai schedule start news-checker "Check AI news" --interval hourly
|
||||
|
||||
# With timeout and budget
|
||||
praisonai schedule start reporter "Generate report" \
|
||||
--interval daily \
|
||||
--timeout 300 \
|
||||
--max-cost 1.00
|
||||
|
||||
# With max retries
|
||||
praisonai schedule start checker "Check updates" \
|
||||
--interval hourly \
|
||||
--max-retries 5
|
||||
```
|
||||
|
||||
### Start a scheduler with recipe
|
||||
|
||||
```bash
|
||||
# Schedule a recipe
|
||||
praisonai schedule start news-monitor --recipe news-analyzer --interval hourly
|
||||
|
||||
# With all options
|
||||
praisonai schedule start my-scheduler \
|
||||
--recipe my-recipe \
|
||||
--interval "*/6h" \
|
||||
--timeout 600 \
|
||||
--max-cost 2.00 \
|
||||
--max-retries 3
|
||||
```
|
||||
|
||||
### Foreground mode (for testing)
|
||||
|
||||
```bash
|
||||
# Run in foreground (Ctrl+C to stop)
|
||||
praisonai schedule "Check news" --interval "*/30s"
|
||||
```
|
||||
|
||||
### Manage schedulers
|
||||
|
||||
```bash
|
||||
# List all schedulers
|
||||
praisonai schedule list
|
||||
|
||||
# View logs
|
||||
praisonai schedule logs news-checker --follow
|
||||
|
||||
# Show statistics
|
||||
praisonai schedule stats news-checker
|
||||
|
||||
# Stop a scheduler
|
||||
praisonai schedule stop news-checker
|
||||
|
||||
# Stop all schedulers
|
||||
praisonai schedule stop-all
|
||||
|
||||
# Delete a scheduler
|
||||
praisonai schedule delete news-checker
|
||||
```
|
||||
|
||||
### Export scheduler config
|
||||
|
||||
```bash
|
||||
praisonai schedule save news-checker --output news-checker.yaml
|
||||
```
|
||||
|
||||
## Interval Formats
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
| `hourly` | Every hour |
|
||||
| `daily` | Every day |
|
||||
| `*/30m` | Every 30 minutes |
|
||||
| `*/6h` | Every 6 hours |
|
||||
| `*/30s` | Every 30 seconds (for testing) |
|
||||
| `3600` | Every 3600 seconds |
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Periodic Execution**: Run tasks at regular intervals
|
||||
- **Retry Logic**: Automatic retries on failure
|
||||
- **Cost Budgeting**: Set spending limits
|
||||
- **Daemon Mode**: Run in background
|
||||
- **Statistics**: Track success rates
|
||||
|
||||
## Safe Defaults
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `interval` | hourly | Execution interval |
|
||||
| `max_retries` | 3 | Retry attempts |
|
||||
| `timeout` | 300 | Timeout per execution |
|
||||
| `max_cost` | 1.00 | Budget limit (USD) |
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Recipe Scheduler Example
|
||||
|
||||
This example demonstrates how to schedule recipes and agents
|
||||
to run periodically with retry logic and cost budgeting.
|
||||
|
||||
Requirements:
|
||||
pip install praisonai praisonaiagents
|
||||
|
||||
Usage:
|
||||
python example_scheduler.py
|
||||
"""
|
||||
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run a scheduled agent."""
|
||||
|
||||
print("=" * 60)
|
||||
print("Recipe Scheduler Example")
|
||||
print("=" * 60)
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.scheduler import AgentScheduler
|
||||
|
||||
# Create an agent
|
||||
agent = Agent(
|
||||
name="News Monitor",
|
||||
instructions="You are a news monitoring assistant. Provide brief updates.",
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Create scheduler
|
||||
scheduler = AgentScheduler(
|
||||
agent=agent,
|
||||
task="What is the latest news in AI? Give a one-sentence summary.",
|
||||
timeout=60,
|
||||
max_cost=0.10 # $0.10 budget limit
|
||||
)
|
||||
|
||||
print("\n1. Starting scheduler...")
|
||||
print(" Interval: every 30 seconds (for demo)")
|
||||
print(" Max retries: 3")
|
||||
print(" Timeout: 60s")
|
||||
print(" Budget: $0.10")
|
||||
|
||||
# Handle Ctrl+C gracefully
|
||||
def signal_handler(sig, frame):
|
||||
print("\n\n2. Stopping scheduler...")
|
||||
scheduler.stop()
|
||||
|
||||
# Show stats
|
||||
stats = scheduler.get_stats()
|
||||
print("\n3. Final Statistics:")
|
||||
print(f" Total Executions: {stats['total_executions']}")
|
||||
print(f" Successful: {stats['successful_executions']}")
|
||||
print(f" Failed: {stats['failed_executions']}")
|
||||
print(f" Success Rate: {stats['success_rate']:.1f}%")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed!")
|
||||
print("=" * 60)
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
# Start scheduler with short interval for demo
|
||||
scheduler.start(
|
||||
schedule_expr="*/30s", # Every 30 seconds for demo
|
||||
max_retries=3,
|
||||
run_immediately=True
|
||||
)
|
||||
|
||||
print("\n Press Ctrl+C to stop...\n")
|
||||
|
||||
# Keep running
|
||||
while scheduler.is_running:
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,94 @@
|
||||
# Example TEMPLATE.yaml with Runtime Configuration
|
||||
# This demonstrates the new runtime block for background/job/schedule execution
|
||||
|
||||
schema_version: "1.0"
|
||||
name: news-monitor
|
||||
version: "1.0.0"
|
||||
description: |
|
||||
A recipe that monitors AI news and summarizes findings.
|
||||
Supports background execution, async jobs, and scheduled runs.
|
||||
|
||||
author: praisonai
|
||||
license: Apache-2.0
|
||||
tags:
|
||||
- news
|
||||
- monitoring
|
||||
- ai
|
||||
|
||||
# Dependencies
|
||||
requires:
|
||||
packages:
|
||||
- praisonaiagents>=0.0.1
|
||||
env:
|
||||
- OPENAI_API_KEY
|
||||
|
||||
# Entry points
|
||||
workflow: workflow.yaml
|
||||
agents: agents.yaml
|
||||
|
||||
# Configuration schema
|
||||
config:
|
||||
topic:
|
||||
type: string
|
||||
description: Topic to monitor
|
||||
default: "AI and machine learning"
|
||||
max_articles:
|
||||
type: integer
|
||||
description: Maximum articles to fetch
|
||||
default: 5
|
||||
|
||||
# Default values
|
||||
defaults:
|
||||
topic: "AI and machine learning"
|
||||
max_articles: 5
|
||||
|
||||
# Runtime configuration for operational features
|
||||
runtime:
|
||||
# Background task execution
|
||||
background:
|
||||
enabled: true
|
||||
max_concurrent: 3
|
||||
cleanup_delay_sec: 3600
|
||||
|
||||
# Async job execution
|
||||
job:
|
||||
enabled: true
|
||||
timeout_sec: 600
|
||||
webhook_url: "${WEBHOOK_URL}" # Environment variable interpolation
|
||||
idempotency_scope: "session"
|
||||
events:
|
||||
- completed
|
||||
- failed
|
||||
|
||||
# Scheduled execution
|
||||
schedule:
|
||||
enabled: true
|
||||
interval: "hourly"
|
||||
max_retries: 3
|
||||
run_immediately: false
|
||||
timeout_sec: 300
|
||||
max_cost_usd: 1.00
|
||||
|
||||
# CLI integration
|
||||
cli:
|
||||
command: news-monitor
|
||||
arguments:
|
||||
- name: topic
|
||||
short: -t
|
||||
description: Topic to monitor
|
||||
- name: max-articles
|
||||
short: -n
|
||||
description: Maximum articles
|
||||
|
||||
# Example usage
|
||||
examples:
|
||||
- name: Basic usage
|
||||
command: praisonai recipe run news-monitor
|
||||
- name: With custom topic
|
||||
command: praisonai recipe run news-monitor -i '{"topic": "LLMs"}'
|
||||
- name: Background execution
|
||||
command: praisonai recipe run news-monitor --background
|
||||
- name: Submit as job
|
||||
command: praisonai run submit "Monitor AI news" --recipe news-monitor
|
||||
- name: Schedule hourly
|
||||
command: praisonai schedule news-monitor --interval hourly
|
||||
@@ -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/v2.7.3.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.3.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.7.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.7.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -63,19 +63,30 @@ class AgentSchedulerHandler:
|
||||
from datetime import datetime
|
||||
|
||||
if not unknown_args:
|
||||
print("❌ Error: Please provide scheduler name and task")
|
||||
print("❌ Error: Please provide scheduler name and task/recipe")
|
||||
print("\nUsage:")
|
||||
print(' praisonai schedule start <name> "Your task" --interval hourly')
|
||||
print(' praisonai schedule start <name> --recipe <recipe-name> --interval hourly')
|
||||
print("\nExample:")
|
||||
print(' praisonai schedule start news-checker "Check AI news" --interval hourly')
|
||||
print(' praisonai schedule start news-checker --recipe news-monitor --interval hourly')
|
||||
return 1
|
||||
|
||||
# Parse arguments
|
||||
name = unknown_args[0]
|
||||
task = unknown_args[1] if len(unknown_args) > 1 else None
|
||||
|
||||
if not task:
|
||||
print("❌ Error: Please provide a task")
|
||||
# Check for --recipe flag in unknown_args
|
||||
recipe_name = None
|
||||
task = None
|
||||
if "--recipe" in unknown_args:
|
||||
recipe_idx = unknown_args.index("--recipe")
|
||||
if recipe_idx + 1 < len(unknown_args):
|
||||
recipe_name = unknown_args[recipe_idx + 1]
|
||||
else:
|
||||
task = unknown_args[1] if len(unknown_args) > 1 else None
|
||||
|
||||
if not task and not recipe_name:
|
||||
print("❌ Error: Please provide a task or --recipe <name>")
|
||||
return 1
|
||||
|
||||
# Get options
|
||||
@@ -93,7 +104,10 @@ class AgentSchedulerHandler:
|
||||
|
||||
# Start daemon
|
||||
print(f"🚀 Starting scheduler '{name}'...")
|
||||
print(f" Task: {task}")
|
||||
if recipe_name:
|
||||
print(f" Recipe: {recipe_name}")
|
||||
else:
|
||||
print(f" Task: {task}")
|
||||
print(f" Interval: {interval}")
|
||||
if timeout:
|
||||
print(f" Timeout: {timeout}s")
|
||||
@@ -103,6 +117,7 @@ class AgentSchedulerHandler:
|
||||
pid = daemon_manager.start_scheduler_daemon(
|
||||
name=name,
|
||||
task=task,
|
||||
recipe_name=recipe_name,
|
||||
interval=interval,
|
||||
max_cost=max_cost,
|
||||
timeout=timeout,
|
||||
@@ -114,6 +129,7 @@ class AgentSchedulerHandler:
|
||||
"name": name,
|
||||
"pid": pid,
|
||||
"task": task,
|
||||
"recipe_name": recipe_name,
|
||||
"interval": interval,
|
||||
"timeout": timeout,
|
||||
"max_cost": max_cost,
|
||||
@@ -555,6 +571,18 @@ class AgentSchedulerHandler:
|
||||
logging.getLogger('praisonai.scheduler').setLevel(logging.WARNING)
|
||||
|
||||
try:
|
||||
# Check if this is a recipe name (not a file, not a prompt with spaces)
|
||||
is_recipe_mode = False
|
||||
if first_arg and not is_yaml_mode:
|
||||
# Try to resolve as recipe if it looks like a recipe name (no spaces, no file extension)
|
||||
if ' ' not in first_arg and not first_arg.endswith('.yaml') and not first_arg.endswith('.yml'):
|
||||
try:
|
||||
from praisonai.recipe.bridge import resolve
|
||||
resolve(first_arg) # Just check if it resolves
|
||||
is_recipe_mode = True
|
||||
except Exception:
|
||||
pass # Not a recipe, continue with prompt mode
|
||||
|
||||
if is_yaml_mode:
|
||||
# YAML mode: Load from agents.yaml
|
||||
yaml_path = first_arg
|
||||
@@ -566,12 +594,23 @@ class AgentSchedulerHandler:
|
||||
timeout_override=timeout_override,
|
||||
max_cost_override=max_cost_override
|
||||
)
|
||||
elif is_recipe_mode:
|
||||
# Recipe mode: Load from recipe name
|
||||
print(f"🍳 Loading recipe: {first_arg}")
|
||||
scheduler = AgentScheduler.from_recipe(
|
||||
recipe_name=first_arg,
|
||||
interval_override=interval_override,
|
||||
max_retries_override=max_retries_override,
|
||||
timeout_override=timeout_override,
|
||||
max_cost_override=max_cost_override
|
||||
)
|
||||
else:
|
||||
# Prompt mode: Create agent from direct prompt
|
||||
if not first_arg:
|
||||
print("❌ Error: Please provide either a YAML file or a task prompt")
|
||||
print("❌ Error: Please provide either a YAML file, recipe name, or a task prompt")
|
||||
print("\nExamples:")
|
||||
print(" praisonai schedule agents.yaml")
|
||||
print(" praisonai schedule my-recipe --interval hourly")
|
||||
print(' praisonai schedule "Check news every hour" --interval hourly')
|
||||
return 1
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ Commands:
|
||||
- praisonai background clear # Clear completed tasks
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from typing import Optional, List
|
||||
|
||||
@@ -235,62 +234,123 @@ def handle_background_command(args: List[str], verbose: bool = False):
|
||||
Handle background CLI commands.
|
||||
|
||||
Usage:
|
||||
praisonai background list [--status <status>]
|
||||
praisonai background status <task_id>
|
||||
praisonai background cancel <task_id>
|
||||
praisonai background clear
|
||||
praisonai background list [--status <status>] [--json] [--page N] [--page-size N]
|
||||
praisonai background status <task_id> [--json]
|
||||
praisonai background cancel <task_id> [--json]
|
||||
praisonai background clear [--all] [--older-than SEC] [--json]
|
||||
praisonai background submit --recipe <name> [--input JSON] [--session-id ID] [--timeout SEC] [--json]
|
||||
"""
|
||||
handler = BackgroundHandler(verbose=verbose)
|
||||
import argparse
|
||||
import json
|
||||
|
||||
parser = argparse.ArgumentParser(prog="praisonai background", description="Manage background tasks")
|
||||
subparsers = parser.add_subparsers(dest="subcommand", help="Available commands")
|
||||
|
||||
# List command
|
||||
list_parser = subparsers.add_parser("list", help="List background tasks")
|
||||
list_parser.add_argument("--status", choices=["pending", "running", "completed", "failed", "cancelled"], help="Filter by status")
|
||||
list_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
|
||||
list_parser.add_argument("--page", type=int, default=1, help="Page number")
|
||||
list_parser.add_argument("--page-size", type=int, default=20, help="Tasks per page")
|
||||
|
||||
# Status command
|
||||
status_parser = subparsers.add_parser("status", help="Get task status")
|
||||
status_parser.add_argument("task_id", help="Task ID")
|
||||
status_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
|
||||
|
||||
# Cancel command
|
||||
cancel_parser = subparsers.add_parser("cancel", help="Cancel a task")
|
||||
cancel_parser.add_argument("task_id", help="Task ID")
|
||||
cancel_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
|
||||
|
||||
# Clear command
|
||||
clear_parser = subparsers.add_parser("clear", help="Clear completed tasks")
|
||||
clear_parser.add_argument("--all", action="store_true", help="Clear all tasks including running")
|
||||
clear_parser.add_argument("--older-than", type=int, help="Clear tasks older than N seconds")
|
||||
clear_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
|
||||
|
||||
# Submit command (NEW - for recipe background submission)
|
||||
submit_parser = subparsers.add_parser("submit", help="Submit a recipe as background task")
|
||||
submit_parser.add_argument("--recipe", required=True, dest="recipe_name", help="Recipe name to execute")
|
||||
submit_parser.add_argument("--input", "-i", dest="input_data", help="Input data as JSON string")
|
||||
submit_parser.add_argument("--config", "-c", help="Config overrides as JSON string")
|
||||
submit_parser.add_argument("--session-id", "-s", help="Session ID for conversation continuity")
|
||||
submit_parser.add_argument("--timeout", type=int, default=300, help="Timeout in seconds (default: 300)")
|
||||
submit_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
|
||||
|
||||
if not args:
|
||||
print("Usage: praisonai background <command> [options]")
|
||||
print("\nCommands:")
|
||||
print(" list [--status <status>] List background tasks")
|
||||
print(" status <task_id> Get task status")
|
||||
print(" cancel <task_id> Cancel a task")
|
||||
print(" clear Clear completed tasks")
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
command = args[0]
|
||||
# Handle legacy help
|
||||
if args[0] in ["help", "--help", "-h"]:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
if command == "list":
|
||||
status = None
|
||||
if "--status" in args:
|
||||
idx = args.index("--status")
|
||||
if idx + 1 < len(args):
|
||||
status = args[idx + 1]
|
||||
parsed = parser.parse_args(args)
|
||||
|
||||
if not parsed.subcommand:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
handler = BackgroundHandler(verbose=verbose)
|
||||
|
||||
try:
|
||||
if parsed.subcommand == "list":
|
||||
asyncio.run(handler.list_tasks(status=parsed.status))
|
||||
|
||||
asyncio.run(handler.list_tasks(status=status))
|
||||
|
||||
elif command == "status":
|
||||
if len(args) < 2:
|
||||
print("Usage: praisonai background status <task_id>")
|
||||
return
|
||||
elif parsed.subcommand == "status":
|
||||
asyncio.run(handler.get_status(parsed.task_id))
|
||||
|
||||
asyncio.run(handler.get_status(args[1]))
|
||||
|
||||
elif command == "cancel":
|
||||
if len(args) < 2:
|
||||
print("Usage: praisonai background cancel <task_id>")
|
||||
return
|
||||
elif parsed.subcommand == "cancel":
|
||||
asyncio.run(handler.cancel_task(parsed.task_id))
|
||||
|
||||
asyncio.run(handler.cancel_task(args[1]))
|
||||
elif parsed.subcommand == "clear":
|
||||
asyncio.run(handler.clear_completed())
|
||||
|
||||
elif parsed.subcommand == "submit":
|
||||
# Parse input and config
|
||||
input_data = None
|
||||
if parsed.input_data:
|
||||
try:
|
||||
input_data = json.loads(parsed.input_data)
|
||||
except json.JSONDecodeError:
|
||||
input_data = {"input": parsed.input_data}
|
||||
|
||||
config = None
|
||||
if parsed.config:
|
||||
try:
|
||||
config = json.loads(parsed.config)
|
||||
except json.JSONDecodeError:
|
||||
print("Error: --config must be valid JSON")
|
||||
return
|
||||
|
||||
# Submit recipe as background task
|
||||
from praisonai.recipe.operations import run_background
|
||||
|
||||
task = run_background(
|
||||
parsed.recipe_name,
|
||||
input=input_data,
|
||||
config=config,
|
||||
session_id=parsed.session_id,
|
||||
timeout_sec=parsed.timeout,
|
||||
)
|
||||
|
||||
if parsed.output_json:
|
||||
print(json.dumps({
|
||||
"ok": True,
|
||||
"task_id": task.task_id,
|
||||
"recipe": task.recipe_name,
|
||||
"session_id": task.session_id,
|
||||
}))
|
||||
else:
|
||||
print(f"✓ Recipe '{parsed.recipe_name}' submitted to background")
|
||||
print(f" Task ID: {task.task_id}")
|
||||
print(f" Session: {task.session_id}")
|
||||
print(f"\nCheck status with: praisonai background status {task.task_id}")
|
||||
|
||||
elif command == "clear":
|
||||
asyncio.run(handler.clear_completed())
|
||||
|
||||
elif command == "help" or command == "--help":
|
||||
print("Background CLI Commands:")
|
||||
print("\n praisonai background list [--status <status>]")
|
||||
print(" List all background tasks")
|
||||
print(" Status: pending, running, completed, failed, cancelled")
|
||||
print("\n praisonai background status <task_id>")
|
||||
print(" Get detailed status of a specific task")
|
||||
print("\n praisonai background cancel <task_id>")
|
||||
print(" Cancel a running task")
|
||||
print("\n praisonai background clear")
|
||||
print(" Clear all completed tasks from the list")
|
||||
|
||||
else:
|
||||
print(f"Unknown command: {command}")
|
||||
print("Use 'praisonai background help' for available commands")
|
||||
except Exception as e:
|
||||
if getattr(parsed, 'output_json', False):
|
||||
print(json.dumps({"ok": False, "error": str(e)}))
|
||||
else:
|
||||
print(f"✗ Error: {e}")
|
||||
@@ -19,3 +19,4 @@ def register_all_checks():
|
||||
from . import network_checks
|
||||
from . import performance_checks
|
||||
from . import selftest_checks
|
||||
from . import serve_checks
|
||||
@@ -0,0 +1,426 @@
|
||||
"""
|
||||
Doctor checks for serve and endpoints functionality.
|
||||
|
||||
Checks for:
|
||||
- Serve module availability
|
||||
- Endpoints module availability
|
||||
- Server connectivity
|
||||
- Discovery endpoint
|
||||
- Provider types
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from ..models import CheckResult, CheckStatus, CheckCategory, CheckSeverity, DoctorConfig
|
||||
from ..registry import register_check
|
||||
|
||||
|
||||
@register_check(
|
||||
id="serve_module",
|
||||
title="Serve Module",
|
||||
description="Check if serve module is available",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
severity=CheckSeverity.MEDIUM,
|
||||
tags=["serve", "deploy"],
|
||||
)
|
||||
def check_serve_module(config: DoctorConfig) -> CheckResult:
|
||||
"""Check if serve module is available."""
|
||||
try:
|
||||
from praisonai.cli.features.serve import ServeHandler
|
||||
ServeHandler() # Test instantiation
|
||||
return CheckResult(
|
||||
id="serve_module",
|
||||
title="Serve Module",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.PASS,
|
||||
message="Serve module available",
|
||||
metadata={"handler": "ServeHandler"},
|
||||
)
|
||||
except ImportError as e:
|
||||
return CheckResult(
|
||||
id="serve_module",
|
||||
title="Serve Module",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.WARN,
|
||||
message=f"Serve module not available: {e}",
|
||||
remediation="pip install praisonai[serve]",
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
id="serve_module",
|
||||
title="Serve Module",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.ERROR,
|
||||
message=f"Error checking serve module: {e}",
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="endpoints_module",
|
||||
title="Endpoints Module",
|
||||
description="Check if endpoints module is available",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
severity=CheckSeverity.MEDIUM,
|
||||
tags=["endpoints", "deploy"],
|
||||
)
|
||||
def check_endpoints_module(config: DoctorConfig) -> CheckResult:
|
||||
"""Check if endpoints module is available."""
|
||||
try:
|
||||
from praisonai.endpoints import (
|
||||
DiscoveryDocument,
|
||||
EndpointInfo,
|
||||
ProviderInfo,
|
||||
list_provider_types,
|
||||
)
|
||||
types = list_provider_types()
|
||||
return CheckResult(
|
||||
id="endpoints_module",
|
||||
title="Endpoints Module",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.PASS,
|
||||
message=f"Endpoints module available ({len(types)} provider types)",
|
||||
metadata={"provider_types": types},
|
||||
)
|
||||
except ImportError as e:
|
||||
return CheckResult(
|
||||
id="endpoints_module",
|
||||
title="Endpoints Module",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.WARN,
|
||||
message=f"Endpoints module not available: {e}",
|
||||
remediation="pip install praisonai[serve]",
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
id="endpoints_module",
|
||||
title="Endpoints Module",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.ERROR,
|
||||
message=f"Error checking endpoints module: {e}",
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="endpoints_cli",
|
||||
title="Endpoints CLI",
|
||||
description="Check if endpoints CLI handler is available",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
severity=CheckSeverity.LOW,
|
||||
tags=["endpoints", "cli"],
|
||||
)
|
||||
def check_endpoints_cli(config: DoctorConfig) -> CheckResult:
|
||||
"""Check if endpoints CLI handler is available."""
|
||||
try:
|
||||
from praisonai.cli.features.endpoints import EndpointsHandler
|
||||
handler = EndpointsHandler()
|
||||
return CheckResult(
|
||||
id="endpoints_cli",
|
||||
title="Endpoints CLI",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.PASS,
|
||||
message="Endpoints CLI handler available",
|
||||
metadata={"default_url": handler.DEFAULT_URL},
|
||||
)
|
||||
except ImportError as e:
|
||||
return CheckResult(
|
||||
id="endpoints_cli",
|
||||
title="Endpoints CLI",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.WARN,
|
||||
message=f"Endpoints CLI not available: {e}",
|
||||
remediation="pip install praisonai",
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
id="endpoints_cli",
|
||||
title="Endpoints CLI",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.ERROR,
|
||||
message=f"Error checking endpoints CLI: {e}",
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="server_connectivity",
|
||||
title="Server Connectivity",
|
||||
description="Check if a PraisonAI server is reachable",
|
||||
category=CheckCategory.NETWORK,
|
||||
severity=CheckSeverity.LOW,
|
||||
requires_deep=True,
|
||||
tags=["serve", "network"],
|
||||
)
|
||||
def check_server_connectivity(config: DoctorConfig) -> CheckResult:
|
||||
"""Check if a PraisonAI server is reachable."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
url = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
|
||||
health_url = f"{url}/health"
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(health_url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
if resp.status == 200:
|
||||
return CheckResult(
|
||||
id="server_connectivity",
|
||||
title="Server Connectivity",
|
||||
category=CheckCategory.NETWORK,
|
||||
status=CheckStatus.PASS,
|
||||
message=f"Server reachable at {url}",
|
||||
metadata={"url": url, "status": resp.status},
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
id="server_connectivity",
|
||||
title="Server Connectivity",
|
||||
category=CheckCategory.NETWORK,
|
||||
status=CheckStatus.WARN,
|
||||
message=f"Server returned status {resp.status}",
|
||||
metadata={"url": url, "status": resp.status},
|
||||
)
|
||||
except urllib.error.URLError as e:
|
||||
return CheckResult(
|
||||
id="server_connectivity",
|
||||
title="Server Connectivity",
|
||||
category=CheckCategory.NETWORK,
|
||||
status=CheckStatus.SKIP,
|
||||
message=f"No server running at {url}",
|
||||
details=str(e.reason),
|
||||
remediation="Start a server: praisonai serve unified --port 8765",
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
id="server_connectivity",
|
||||
title="Server Connectivity",
|
||||
category=CheckCategory.NETWORK,
|
||||
status=CheckStatus.ERROR,
|
||||
message=f"Error checking server: {e}",
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="discovery_endpoint",
|
||||
title="Discovery Endpoint",
|
||||
description="Check if discovery endpoint is available",
|
||||
category=CheckCategory.NETWORK,
|
||||
severity=CheckSeverity.LOW,
|
||||
requires_deep=True,
|
||||
dependencies=["server_connectivity"],
|
||||
tags=["serve", "discovery"],
|
||||
)
|
||||
def check_discovery_endpoint(config: DoctorConfig) -> CheckResult:
|
||||
"""Check if discovery endpoint is available."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import json
|
||||
|
||||
url = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
|
||||
discovery_url = f"{url}/__praisonai__/discovery"
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(discovery_url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
if resp.status == 200:
|
||||
data = json.loads(resp.read().decode())
|
||||
providers = data.get("providers", [])
|
||||
endpoints = data.get("endpoints", [])
|
||||
return CheckResult(
|
||||
id="discovery_endpoint",
|
||||
title="Discovery Endpoint",
|
||||
category=CheckCategory.NETWORK,
|
||||
status=CheckStatus.PASS,
|
||||
message=f"Discovery available: {len(providers)} providers, {len(endpoints)} endpoints",
|
||||
metadata={
|
||||
"url": discovery_url,
|
||||
"schema_version": data.get("schema_version"),
|
||||
"server_name": data.get("server_name"),
|
||||
"providers": [p.get("type") for p in providers],
|
||||
},
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
id="discovery_endpoint",
|
||||
title="Discovery Endpoint",
|
||||
category=CheckCategory.NETWORK,
|
||||
status=CheckStatus.WARN,
|
||||
message=f"Discovery returned status {resp.status}",
|
||||
)
|
||||
except urllib.error.URLError:
|
||||
return CheckResult(
|
||||
id="discovery_endpoint",
|
||||
title="Discovery Endpoint",
|
||||
category=CheckCategory.NETWORK,
|
||||
status=CheckStatus.SKIP,
|
||||
message="No server running (skipped)",
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
id="discovery_endpoint",
|
||||
title="Discovery Endpoint",
|
||||
category=CheckCategory.NETWORK,
|
||||
status=CheckStatus.ERROR,
|
||||
message=f"Error checking discovery: {e}",
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="a2u_module",
|
||||
title="A2U Module",
|
||||
description="Check if A2U server module is available",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
severity=CheckSeverity.LOW,
|
||||
tags=["serve", "a2u"],
|
||||
)
|
||||
def check_a2u_module(config: DoctorConfig) -> CheckResult:
|
||||
"""Check if A2U server module is available."""
|
||||
try:
|
||||
from praisonai.endpoints.a2u_server import (
|
||||
A2UEvent,
|
||||
A2USubscription,
|
||||
A2UEventBus,
|
||||
create_a2u_routes,
|
||||
)
|
||||
return CheckResult(
|
||||
id="a2u_module",
|
||||
title="A2U Module",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.PASS,
|
||||
message="A2U server module available",
|
||||
)
|
||||
except ImportError as e:
|
||||
return CheckResult(
|
||||
id="a2u_module",
|
||||
title="A2U Module",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.WARN,
|
||||
message=f"A2U module not available: {e}",
|
||||
remediation="pip install praisonai[serve]",
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
id="a2u_module",
|
||||
title="A2U Module",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.ERROR,
|
||||
message=f"Error checking A2U module: {e}",
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="provider_adapters",
|
||||
title="Provider Adapters",
|
||||
description="Check if all provider adapters are available",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
severity=CheckSeverity.LOW,
|
||||
tags=["serve", "providers"],
|
||||
)
|
||||
def check_provider_adapters(config: DoctorConfig) -> CheckResult:
|
||||
"""Check if all provider adapters are available."""
|
||||
try:
|
||||
from praisonai.endpoints.providers import (
|
||||
BaseProvider,
|
||||
RecipeProvider,
|
||||
AgentsAPIProvider,
|
||||
MCPProvider,
|
||||
ToolsMCPProvider,
|
||||
A2AProvider,
|
||||
A2UProvider,
|
||||
)
|
||||
providers = [
|
||||
"BaseProvider",
|
||||
"RecipeProvider",
|
||||
"AgentsAPIProvider",
|
||||
"MCPProvider",
|
||||
"ToolsMCPProvider",
|
||||
"A2AProvider",
|
||||
"A2UProvider",
|
||||
]
|
||||
return CheckResult(
|
||||
id="provider_adapters",
|
||||
title="Provider Adapters",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.PASS,
|
||||
message=f"All {len(providers)} provider adapters available",
|
||||
metadata={"providers": providers},
|
||||
)
|
||||
except ImportError as e:
|
||||
return CheckResult(
|
||||
id="provider_adapters",
|
||||
title="Provider Adapters",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.WARN,
|
||||
message=f"Some provider adapters not available: {e}",
|
||||
remediation="pip install praisonai[serve]",
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
id="provider_adapters",
|
||||
title="Provider Adapters",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.ERROR,
|
||||
message=f"Error checking provider adapters: {e}",
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="fastapi_available",
|
||||
title="FastAPI Available",
|
||||
description="Check if FastAPI is available for serving",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
severity=CheckSeverity.MEDIUM,
|
||||
tags=["serve", "dependencies"],
|
||||
)
|
||||
def check_fastapi_available(config: DoctorConfig) -> CheckResult:
|
||||
"""Check if FastAPI is available."""
|
||||
try:
|
||||
import fastapi
|
||||
version = getattr(fastapi, "__version__", "unknown")
|
||||
return CheckResult(
|
||||
id="fastapi_available",
|
||||
title="FastAPI Available",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.PASS,
|
||||
message=f"FastAPI {version} available",
|
||||
metadata={"version": version},
|
||||
)
|
||||
except ImportError:
|
||||
return CheckResult(
|
||||
id="fastapi_available",
|
||||
title="FastAPI Available",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.WARN,
|
||||
message="FastAPI not installed",
|
||||
remediation="pip install fastapi uvicorn",
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="uvicorn_available",
|
||||
title="Uvicorn Available",
|
||||
description="Check if Uvicorn is available for serving",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
severity=CheckSeverity.MEDIUM,
|
||||
tags=["serve", "dependencies"],
|
||||
)
|
||||
def check_uvicorn_available(config: DoctorConfig) -> CheckResult:
|
||||
"""Check if Uvicorn is available."""
|
||||
try:
|
||||
import uvicorn
|
||||
version = getattr(uvicorn, "__version__", "unknown")
|
||||
return CheckResult(
|
||||
id="uvicorn_available",
|
||||
title="Uvicorn Available",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.PASS,
|
||||
message=f"Uvicorn {version} available",
|
||||
metadata={"version": version},
|
||||
)
|
||||
except ImportError:
|
||||
return CheckResult(
|
||||
id="uvicorn_available",
|
||||
title="Uvicorn Available",
|
||||
category=CheckCategory.ENVIRONMENT,
|
||||
status=CheckStatus.WARN,
|
||||
message="Uvicorn not installed",
|
||||
remediation="pip install uvicorn",
|
||||
)
|
||||
@@ -1,11 +1,20 @@
|
||||
"""
|
||||
Endpoints CLI Feature Handler
|
||||
|
||||
Provides CLI commands for interacting with recipe endpoints:
|
||||
- list: List available recipe endpoints
|
||||
Provides CLI commands for interacting with PraisonAI endpoints:
|
||||
- list: List available endpoints (all provider types)
|
||||
- describe: Show endpoint details and schema
|
||||
- invoke: Call an endpoint
|
||||
- health: Check endpoint server health
|
||||
- types: List supported provider types
|
||||
|
||||
Supported Provider Types:
|
||||
- recipe: Recipe runner endpoints
|
||||
- agents-api: Single/multi-agent HTTP API
|
||||
- mcp: MCP server (stdio, http, sse)
|
||||
- tools-mcp: Tools exposed as MCP server
|
||||
- a2a: Agent-to-agent protocol
|
||||
- a2u: Agent-to-user event stream
|
||||
|
||||
All commands use the canonical `praisonai endpoints` prefix.
|
||||
|
||||
@@ -14,11 +23,13 @@ Why this feature is valuable:
|
||||
- Ops: Health checks, monitoring, automation
|
||||
- Polyglot: Non-Python clients can invoke recipes via HTTP
|
||||
- Testing: Easy endpoint verification without code
|
||||
- Unified: Single interface for all server types
|
||||
|
||||
Architecture notes:
|
||||
- Optional extras only (no server deps in core)
|
||||
- Lazy imports for all HTTP client code
|
||||
- No impact on praisonaiagents import time
|
||||
- Backward compatible with existing recipe-only usage
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -27,15 +38,27 @@ import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# Supported provider types
|
||||
PROVIDER_TYPES = [
|
||||
"recipe",
|
||||
"agents-api",
|
||||
"mcp",
|
||||
"tools-mcp",
|
||||
"a2a",
|
||||
"a2u",
|
||||
]
|
||||
|
||||
|
||||
class EndpointsHandler:
|
||||
"""
|
||||
CLI handler for endpoints operations.
|
||||
|
||||
Commands:
|
||||
- list: List available recipe endpoints
|
||||
- list: List available endpoints (all provider types)
|
||||
- describe: Show endpoint details
|
||||
- invoke: Call an endpoint
|
||||
- health: Check server health
|
||||
- types: List supported provider types
|
||||
"""
|
||||
|
||||
# Exit codes
|
||||
@@ -90,6 +113,8 @@ class EndpointsHandler:
|
||||
"describe": self.cmd_describe,
|
||||
"invoke": self.cmd_invoke,
|
||||
"health": self.cmd_health,
|
||||
"types": self.cmd_types,
|
||||
"discovery": self.cmd_discovery,
|
||||
"help": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
"--help": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
"-h": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
@@ -107,25 +132,30 @@ class EndpointsHandler:
|
||||
help_text = """
|
||||
[bold cyan]PraisonAI Endpoints[/bold cyan]
|
||||
|
||||
Client CLI for interacting with recipe endpoints.
|
||||
Unified client CLI for interacting with PraisonAI endpoints.
|
||||
|
||||
[bold]Usage:[/bold]
|
||||
praisonai endpoints <command> [options]
|
||||
|
||||
[bold]Commands:[/bold]
|
||||
list List available recipe endpoints
|
||||
describe <recipe> Show endpoint details and schema
|
||||
invoke <recipe> Call an endpoint
|
||||
list List available endpoints (all types)
|
||||
describe <name> Show endpoint details and schema
|
||||
invoke <name> Call an endpoint
|
||||
health Check server health
|
||||
types List supported provider types
|
||||
discovery Show raw discovery document
|
||||
|
||||
[bold]Global Options:[/bold]
|
||||
--url <url> Server URL (default: http://localhost:8765)
|
||||
--api-key <key> API key (or set PRAISONAI_ENDPOINTS_API_KEY)
|
||||
--type <type> Filter by provider type (recipe, agents-api, mcp, etc.)
|
||||
|
||||
[bold]List Options:[/bold]
|
||||
--format json Output as JSON
|
||||
--tags <a,b> Filter by tags (comma-separated)
|
||||
--url <url> Server URL (default: http://localhost:8765)
|
||||
|
||||
[bold]Describe Options:[/bold]
|
||||
--schema Show input/output schema only
|
||||
--url <url> Server URL
|
||||
|
||||
[bold]Invoke Options:[/bold]
|
||||
--input <path> Input file path
|
||||
@@ -133,12 +163,15 @@ Client CLI for interacting with recipe endpoints.
|
||||
--config k=v Config override (repeatable)
|
||||
--json Output as JSON
|
||||
--stream Stream output events (SSE)
|
||||
--url <url> Server URL
|
||||
--api-key <key> API key for auth (or set PRAISONAI_ENDPOINTS_API_KEY)
|
||||
--dry-run Validate without executing
|
||||
|
||||
[bold]Health Options:[/bold]
|
||||
--url <url> Server URL
|
||||
[bold]Provider Types:[/bold]
|
||||
recipe Recipe runner endpoints
|
||||
agents-api Single/multi-agent HTTP API
|
||||
mcp MCP server (stdio, http, sse)
|
||||
tools-mcp Tools exposed as MCP server
|
||||
a2a Agent-to-agent protocol
|
||||
a2u Agent-to-user event stream
|
||||
|
||||
[bold]Environment Variables:[/bold]
|
||||
PRAISONAI_ENDPOINTS_URL Default server URL
|
||||
@@ -146,14 +179,16 @@ Client CLI for interacting with recipe endpoints.
|
||||
|
||||
[bold]Examples:[/bold]
|
||||
praisonai endpoints list
|
||||
praisonai endpoints list --type agents-api
|
||||
praisonai endpoints list --format json --tags audio,video
|
||||
praisonai endpoints describe ai-podcast-cleaner
|
||||
praisonai endpoints describe ai-podcast-cleaner --schema
|
||||
praisonai endpoints invoke ai-podcast-cleaner --input ./audio.mp3
|
||||
praisonai endpoints invoke ai-podcast-cleaner --input-json '{"text": "hello"}'
|
||||
praisonai endpoints invoke ai-podcast-cleaner --input ./audio.mp3 --stream
|
||||
praisonai endpoints describe my-agent
|
||||
praisonai endpoints describe my-agent --schema
|
||||
praisonai endpoints invoke my-agent --input-json '{"query": "hello"}'
|
||||
praisonai endpoints invoke my-recipe --input ./data.json --stream
|
||||
praisonai endpoints health
|
||||
praisonai endpoints health --url http://localhost:8000
|
||||
praisonai endpoints types
|
||||
praisonai endpoints discovery
|
||||
"""
|
||||
self._print_rich(help_text)
|
||||
|
||||
@@ -339,34 +374,96 @@ Client CLI for interacting with recipe endpoints.
|
||||
except Exception as e:
|
||||
yield {"error": str(e)}
|
||||
|
||||
def _get_provider(self, provider_type: str, url: Optional[str] = None, api_key: Optional[str] = None):
|
||||
"""Get a provider instance."""
|
||||
try:
|
||||
from praisonai.endpoints import get_provider
|
||||
return get_provider(
|
||||
provider_type,
|
||||
base_url=url or self.base_url,
|
||||
api_key=api_key or self.api_key,
|
||||
)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def _try_unified_discovery(self, url: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Try to get unified discovery document."""
|
||||
result = self._make_request("GET", "/__praisonai__/discovery", url=url)
|
||||
if not result.get("error") and result.get("data"):
|
||||
return result.get("data")
|
||||
return None
|
||||
|
||||
def cmd_list(self, args: List[str]) -> int:
|
||||
"""List available recipe endpoints."""
|
||||
"""List available endpoints (all provider types)."""
|
||||
spec = {
|
||||
"format": {"default": "table"},
|
||||
"tags": {"default": None},
|
||||
"url": {"default": None},
|
||||
"type": {"default": None},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
# Build query params
|
||||
path = "/v1/recipes"
|
||||
if parsed["tags"]:
|
||||
path += f"?tags={parsed['tags']}"
|
||||
all_endpoints = []
|
||||
|
||||
result = self._make_request("GET", path, url=parsed["url"])
|
||||
# Try unified discovery first
|
||||
discovery = self._try_unified_discovery(parsed["url"])
|
||||
if discovery:
|
||||
endpoints = discovery.get("endpoints", [])
|
||||
for ep in endpoints:
|
||||
# Filter by type if specified
|
||||
if parsed["type"] and ep.get("provider_type") != parsed["type"]:
|
||||
continue
|
||||
# Filter by tags if specified
|
||||
if parsed["tags"]:
|
||||
tag_list = [t.strip() for t in parsed["tags"].split(",")]
|
||||
ep_tags = ep.get("tags", [])
|
||||
if not any(t in ep_tags for t in tag_list):
|
||||
continue
|
||||
all_endpoints.append(ep)
|
||||
else:
|
||||
# Fallback: try recipe endpoint (backward compatibility)
|
||||
if not parsed["type"] or parsed["type"] == "recipe":
|
||||
path = "/v1/recipes"
|
||||
if parsed["tags"]:
|
||||
path += f"?tags={parsed['tags']}"
|
||||
|
||||
result = self._make_request("GET", path, url=parsed["url"])
|
||||
|
||||
if not result.get("error"):
|
||||
recipes = result.get("data", {}).get("recipes", [])
|
||||
for r in recipes:
|
||||
all_endpoints.append({
|
||||
"name": r.get("name", ""),
|
||||
"description": r.get("description", ""),
|
||||
"provider_type": "recipe",
|
||||
"version": r.get("version", "1.0.0"),
|
||||
"tags": r.get("tags", []),
|
||||
"streaming": ["none", "sse"],
|
||||
})
|
||||
|
||||
# Try agents-api endpoint
|
||||
if not parsed["type"] or parsed["type"] == "agents-api":
|
||||
result = self._make_request("GET", "/", url=parsed["url"])
|
||||
if not result.get("error"):
|
||||
data = result.get("data", {})
|
||||
for path in data.get("endpoints", []):
|
||||
all_endpoints.append({
|
||||
"name": path.lstrip("/"),
|
||||
"description": f"Agent endpoint at {path}",
|
||||
"provider_type": "agents-api",
|
||||
"streaming": ["none"],
|
||||
})
|
||||
|
||||
if result.get("error"):
|
||||
self._print_error(result["error"].get("message", str(result["error"])))
|
||||
return self.EXIT_CONNECTION_ERROR
|
||||
|
||||
recipes = result.get("data", {}).get("recipes", [])
|
||||
|
||||
if parsed["format"] == "json":
|
||||
self._print_json(recipes)
|
||||
if not all_endpoints:
|
||||
result = self._make_request("GET", "/health", url=parsed["url"])
|
||||
if result.get("error"):
|
||||
self._print_error(result["error"].get("message", str(result["error"])))
|
||||
return self.EXIT_CONNECTION_ERROR
|
||||
print("No endpoints available.")
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
if not recipes:
|
||||
print("No endpoints available.")
|
||||
if parsed["format"] == "json":
|
||||
self._print_json(all_endpoints)
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
try:
|
||||
@@ -376,63 +473,106 @@ Client CLI for interacting with recipe endpoints.
|
||||
console = Console()
|
||||
table = Table(title="Available Endpoints")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Version", style="green")
|
||||
table.add_column("Type", style="magenta")
|
||||
table.add_column("Description")
|
||||
table.add_column("Streaming", style="green")
|
||||
table.add_column("Tags", style="yellow")
|
||||
|
||||
for r in recipes:
|
||||
tags = r.get("tags", [])
|
||||
for ep in all_endpoints:
|
||||
tags = ep.get("tags", [])
|
||||
streaming = ep.get("streaming", ["none"])
|
||||
desc = ep.get("description", "")
|
||||
table.add_row(
|
||||
r.get("name", ""),
|
||||
r.get("version", ""),
|
||||
(r.get("description", "")[:50] + "...") if len(r.get("description", "")) > 50 else r.get("description", ""),
|
||||
ep.get("name", ""),
|
||||
ep.get("provider_type", "unknown"),
|
||||
(desc[:40] + "...") if len(desc) > 40 else desc,
|
||||
", ".join(streaming[:2]),
|
||||
", ".join(tags[:3]) if tags else "",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
except ImportError:
|
||||
for r in recipes:
|
||||
print(f"{r.get('name')} ({r.get('version')}): {r.get('description', '')}")
|
||||
for ep in all_endpoints:
|
||||
print(f"[{ep.get('provider_type', 'unknown')}] {ep.get('name')}: {ep.get('description', '')}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def cmd_describe(self, args: List[str]) -> int:
|
||||
"""Describe a recipe endpoint."""
|
||||
"""Describe an endpoint."""
|
||||
spec = {
|
||||
"recipe": {"positional": True, "default": ""},
|
||||
"name": {"positional": True, "default": ""},
|
||||
"schema": {"flag": True, "default": False},
|
||||
"url": {"default": None},
|
||||
"type": {"default": None},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
if not parsed["recipe"]:
|
||||
self._print_error("Recipe name required")
|
||||
if not parsed["name"]:
|
||||
self._print_error("Endpoint name required")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
# Get schema if requested
|
||||
if parsed["schema"]:
|
||||
path = f"/v1/recipes/{parsed['recipe']}/schema"
|
||||
else:
|
||||
path = f"/v1/recipes/{parsed['recipe']}"
|
||||
endpoint_name = parsed["name"]
|
||||
endpoint_info = None
|
||||
|
||||
result = self._make_request("GET", path, url=parsed["url"])
|
||||
# Try unified discovery first
|
||||
discovery = self._try_unified_discovery(parsed["url"])
|
||||
if discovery:
|
||||
for ep in discovery.get("endpoints", []):
|
||||
if ep.get("name") == endpoint_name:
|
||||
if parsed["type"] and ep.get("provider_type") != parsed["type"]:
|
||||
continue
|
||||
endpoint_info = ep
|
||||
break
|
||||
|
||||
if result.get("status") == 404:
|
||||
self._print_error(f"Endpoint not found: {parsed['recipe']}")
|
||||
return self.EXIT_NOT_FOUND
|
||||
if endpoint_info:
|
||||
if parsed["schema"]:
|
||||
schema_info = {
|
||||
"name": endpoint_info.get("name"),
|
||||
"input_schema": endpoint_info.get("input_schema"),
|
||||
"output_schema": endpoint_info.get("output_schema"),
|
||||
}
|
||||
self._print_json(schema_info)
|
||||
else:
|
||||
self._print_json(endpoint_info)
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
if result.get("error"):
|
||||
self._print_error(result["error"].get("message", str(result["error"])))
|
||||
return self.EXIT_CONNECTION_ERROR
|
||||
# Fallback: try recipe endpoint (backward compatibility)
|
||||
if not parsed["type"] or parsed["type"] == "recipe":
|
||||
if parsed["schema"]:
|
||||
path = f"/v1/recipes/{endpoint_name}/schema"
|
||||
else:
|
||||
path = f"/v1/recipes/{endpoint_name}"
|
||||
|
||||
result = self._make_request("GET", path, url=parsed["url"])
|
||||
|
||||
if not result.get("error") and result.get("status") != 404:
|
||||
data = result.get("data", {})
|
||||
# Add provider_type for consistency
|
||||
data["provider_type"] = "recipe"
|
||||
self._print_json(data)
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
data = result.get("data", {})
|
||||
self._print_json(data)
|
||||
return self.EXIT_SUCCESS
|
||||
# Try agents-api
|
||||
if not parsed["type"] or parsed["type"] == "agents-api":
|
||||
# Return basic info for agent endpoints
|
||||
endpoint_info = {
|
||||
"name": endpoint_name,
|
||||
"provider_type": "agents-api",
|
||||
"description": f"Agent endpoint: {endpoint_name}",
|
||||
"input_schema": {"type": "object", "properties": {"query": {"type": "string"}}},
|
||||
"streaming": ["none"],
|
||||
"auth_modes": ["none"],
|
||||
}
|
||||
self._print_json(endpoint_info)
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
self._print_error(f"Endpoint not found: {endpoint_name}")
|
||||
return self.EXIT_NOT_FOUND
|
||||
|
||||
def cmd_invoke(self, args: List[str]) -> int:
|
||||
"""Invoke a recipe endpoint."""
|
||||
"""Invoke an endpoint."""
|
||||
spec = {
|
||||
"recipe": {"positional": True, "default": ""},
|
||||
"name": {"positional": True, "default": ""},
|
||||
"input": {"short": "-i", "default": None},
|
||||
"input_json": {"default": None},
|
||||
"config": {"repeatable": True, "default": []},
|
||||
@@ -441,13 +581,16 @@ Client CLI for interacting with recipe endpoints.
|
||||
"url": {"default": None},
|
||||
"api_key": {"default": None},
|
||||
"dry_run": {"flag": True, "default": False},
|
||||
"type": {"default": None},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
if not parsed["recipe"]:
|
||||
self._print_error("Recipe name required")
|
||||
if not parsed["name"]:
|
||||
self._print_error("Endpoint name required")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
endpoint_name = parsed["name"]
|
||||
|
||||
# Build input data
|
||||
input_data = {}
|
||||
if parsed["input_json"]:
|
||||
@@ -457,7 +600,15 @@ Client CLI for interacting with recipe endpoints.
|
||||
self._print_error("Invalid JSON in --input-json")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
elif parsed["input"]:
|
||||
input_data = {"input": parsed["input"]}
|
||||
# Check if input is a file path
|
||||
if os.path.isfile(parsed["input"]):
|
||||
try:
|
||||
with open(parsed["input"]) as f:
|
||||
input_data = json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
input_data = {"input": parsed["input"]}
|
||||
else:
|
||||
input_data = {"input": parsed["input"]}
|
||||
|
||||
# Build config from --config key=value pairs
|
||||
config = {}
|
||||
@@ -466,9 +617,39 @@ Client CLI for interacting with recipe endpoints.
|
||||
k, v = cfg.split("=", 1)
|
||||
config[k] = v
|
||||
|
||||
# Build request body
|
||||
# Detect provider type from discovery or explicit --type
|
||||
provider_type = parsed["type"]
|
||||
if not provider_type:
|
||||
discovery = self._try_unified_discovery(parsed["url"])
|
||||
if discovery:
|
||||
for ep in discovery.get("endpoints", []):
|
||||
if ep.get("name") == endpoint_name:
|
||||
provider_type = ep.get("provider_type")
|
||||
break
|
||||
|
||||
# Default to recipe for backward compatibility
|
||||
if not provider_type:
|
||||
provider_type = "recipe"
|
||||
|
||||
# Route to appropriate invocation method
|
||||
if provider_type == "recipe":
|
||||
return self._invoke_recipe(endpoint_name, input_data, config, parsed)
|
||||
elif provider_type == "agents-api":
|
||||
return self._invoke_agents_api(endpoint_name, input_data, config, parsed)
|
||||
elif provider_type in ("mcp", "tools-mcp"):
|
||||
return self._invoke_mcp(endpoint_name, input_data, config, parsed)
|
||||
elif provider_type == "a2a":
|
||||
return self._invoke_a2a(endpoint_name, input_data, config, parsed)
|
||||
elif provider_type == "a2u":
|
||||
return self._invoke_a2u(endpoint_name, input_data, config, parsed)
|
||||
else:
|
||||
# Fallback to recipe
|
||||
return self._invoke_recipe(endpoint_name, input_data, config, parsed)
|
||||
|
||||
def _invoke_recipe(self, name: str, input_data: Dict, config: Dict, parsed: Dict) -> int:
|
||||
"""Invoke a recipe endpoint."""
|
||||
body = {
|
||||
"recipe": parsed["recipe"],
|
||||
"recipe": name,
|
||||
"input": input_data,
|
||||
"config": config,
|
||||
"options": {"dry_run": parsed["dry_run"]},
|
||||
@@ -477,7 +658,6 @@ Client CLI for interacting with recipe endpoints.
|
||||
if parsed["stream"]:
|
||||
return self._invoke_stream(body, parsed)
|
||||
|
||||
# Sync invocation
|
||||
result = self._make_request(
|
||||
"POST",
|
||||
"/v1/recipes/run",
|
||||
@@ -486,12 +666,31 @@ Client CLI for interacting with recipe endpoints.
|
||||
json_data=body,
|
||||
)
|
||||
|
||||
return self._handle_invoke_result(result, name, parsed)
|
||||
|
||||
def _invoke_agents_api(self, name: str, input_data: Dict, config: Dict, parsed: Dict) -> int:
|
||||
"""Invoke an agents-api endpoint."""
|
||||
path = name if name.startswith("/") else f"/{name}"
|
||||
|
||||
# Build body - agents-api expects query field
|
||||
body = input_data
|
||||
if "query" not in body and config.get("query"):
|
||||
body["query"] = config["query"]
|
||||
|
||||
result = self._make_request(
|
||||
"POST",
|
||||
path,
|
||||
url=parsed["url"],
|
||||
api_key=parsed["api_key"],
|
||||
json_data=body,
|
||||
)
|
||||
|
||||
if result.get("status") == 401:
|
||||
self._print_error("Authentication required. Use --api-key or set PRAISONAI_ENDPOINTS_API_KEY")
|
||||
self._print_error("Authentication required")
|
||||
return self.EXIT_AUTH_ERROR
|
||||
|
||||
if result.get("status") == 404:
|
||||
self._print_error(f"Endpoint not found: {parsed['recipe']}")
|
||||
self._print_error(f"Endpoint not found: {name}")
|
||||
return self.EXIT_NOT_FOUND
|
||||
|
||||
if result.get("error"):
|
||||
@@ -503,17 +702,171 @@ Client CLI for interacting with recipe endpoints.
|
||||
if parsed["json"]:
|
||||
self._print_json(data)
|
||||
else:
|
||||
if data.get("ok"):
|
||||
self._print_success(f"Endpoint '{parsed['recipe']}' invoked successfully")
|
||||
print(f" Run ID: {data.get('run_id')}")
|
||||
print(f" Status: {data.get('status')}")
|
||||
response = data.get("response", data)
|
||||
self._print_success(f"Endpoint '{name}' invoked successfully")
|
||||
print(f" Response: {response}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def _invoke_mcp(self, name: str, input_data: Dict, config: Dict, parsed: Dict) -> int:
|
||||
"""Invoke an MCP tool."""
|
||||
body = {
|
||||
"tool": name,
|
||||
"arguments": input_data,
|
||||
}
|
||||
|
||||
result = self._make_request(
|
||||
"POST",
|
||||
"/mcp/tools/call",
|
||||
url=parsed["url"],
|
||||
api_key=parsed["api_key"],
|
||||
json_data=body,
|
||||
)
|
||||
|
||||
if result.get("error"):
|
||||
self._print_error(result["error"].get("message", str(result["error"])))
|
||||
return self.EXIT_CONNECTION_ERROR
|
||||
|
||||
data = result.get("data", {})
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json(data)
|
||||
else:
|
||||
self._print_success(f"Tool '{name}' invoked successfully")
|
||||
print(f" Result: {data.get('result', data)}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def _invoke_a2a(self, name: str, input_data: Dict, config: Dict, parsed: Dict) -> int:
|
||||
"""Invoke an A2A agent."""
|
||||
import uuid
|
||||
|
||||
message = input_data.get("message", input_data.get("query", ""))
|
||||
if not message and config.get("message"):
|
||||
message = config["message"]
|
||||
|
||||
body = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": str(uuid.uuid4()),
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [{"type": "text", "text": message}],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result = self._make_request(
|
||||
"POST",
|
||||
"/a2a",
|
||||
url=parsed["url"],
|
||||
api_key=parsed["api_key"],
|
||||
json_data=body,
|
||||
)
|
||||
|
||||
if result.get("error"):
|
||||
self._print_error(result["error"].get("message", str(result["error"])))
|
||||
return self.EXIT_CONNECTION_ERROR
|
||||
|
||||
data = result.get("data", {})
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json(data)
|
||||
else:
|
||||
if "result" in data:
|
||||
self._print_success(f"A2A message sent to '{name}'")
|
||||
print(f" Result: {data['result']}")
|
||||
elif "error" in data:
|
||||
self._print_error(data["error"].get("message", str(data["error"])))
|
||||
return self.EXIT_RUNTIME_ERROR
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def _invoke_a2u(self, name: str, input_data: Dict, config: Dict, parsed: Dict) -> int:
|
||||
"""Subscribe to an A2U event stream."""
|
||||
if parsed["stream"]:
|
||||
return self._stream_a2u(name, parsed)
|
||||
|
||||
body = {
|
||||
"stream": name,
|
||||
"filters": input_data.get("filters", []),
|
||||
}
|
||||
|
||||
result = self._make_request(
|
||||
"POST",
|
||||
"/a2u/subscribe",
|
||||
url=parsed["url"],
|
||||
api_key=parsed["api_key"],
|
||||
json_data=body,
|
||||
)
|
||||
|
||||
if result.get("error"):
|
||||
self._print_error(result["error"].get("message", str(result["error"])))
|
||||
return self.EXIT_CONNECTION_ERROR
|
||||
|
||||
data = result.get("data", {})
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json(data)
|
||||
else:
|
||||
self._print_success(f"Subscribed to A2U stream '{name}'")
|
||||
print(f" Stream URL: {data.get('stream_url')}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def _stream_a2u(self, name: str, parsed: Dict) -> int:
|
||||
"""Stream A2U events."""
|
||||
print(f"Streaming events from '{name}'...")
|
||||
|
||||
for event in self._stream_request(
|
||||
f"/a2u/events/{name}",
|
||||
url=parsed["url"],
|
||||
api_key=parsed["api_key"],
|
||||
):
|
||||
if event.get("error"):
|
||||
self._print_error(event["error"])
|
||||
return self.EXIT_CONNECTION_ERROR
|
||||
|
||||
if parsed["json"]:
|
||||
print(json.dumps(event))
|
||||
else:
|
||||
print(f" [{event.get('event', 'event')}] {event.get('data', '')}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def _handle_invoke_result(self, result: Dict, name: str, parsed: Dict) -> int:
|
||||
"""Handle common invoke result processing."""
|
||||
if result.get("status") == 401:
|
||||
self._print_error("Authentication required. Use --api-key or set PRAISONAI_ENDPOINTS_API_KEY")
|
||||
return self.EXIT_AUTH_ERROR
|
||||
|
||||
if result.get("status") == 404:
|
||||
self._print_error(f"Endpoint not found: {name}")
|
||||
return self.EXIT_NOT_FOUND
|
||||
|
||||
if result.get("error"):
|
||||
self._print_error(result["error"].get("message", str(result["error"])))
|
||||
return self.EXIT_CONNECTION_ERROR
|
||||
|
||||
data = result.get("data", {})
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json(data)
|
||||
else:
|
||||
if data.get("ok", True):
|
||||
self._print_success(f"Endpoint '{name}' invoked successfully")
|
||||
if data.get("run_id"):
|
||||
print(f" Run ID: {data.get('run_id')}")
|
||||
if data.get("status"):
|
||||
print(f" Status: {data.get('status')}")
|
||||
if data.get("output"):
|
||||
print(f" Output: {data.get('output')}")
|
||||
else:
|
||||
self._print_error(f"Invocation failed: {data.get('error')}")
|
||||
return self.EXIT_RUNTIME_ERROR
|
||||
|
||||
return self.EXIT_SUCCESS if data.get("ok") else self.EXIT_RUNTIME_ERROR
|
||||
return self.EXIT_SUCCESS if data.get("ok", True) else self.EXIT_RUNTIME_ERROR
|
||||
|
||||
def _invoke_stream(self, body: Dict, parsed: Dict) -> int:
|
||||
"""Handle streaming invocation."""
|
||||
@@ -551,9 +904,14 @@ Client CLI for interacting with recipe endpoints.
|
||||
"""Check endpoint server health."""
|
||||
spec = {
|
||||
"url": {"default": None},
|
||||
"type": {"default": None},
|
||||
"format": {"default": "table"},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
# Try unified discovery first for richer info
|
||||
discovery = self._try_unified_discovery(parsed["url"])
|
||||
|
||||
result = self._make_request("GET", "/health", url=parsed["url"])
|
||||
|
||||
if result.get("error"):
|
||||
@@ -562,14 +920,97 @@ Client CLI for interacting with recipe endpoints.
|
||||
|
||||
data = result.get("data", {})
|
||||
|
||||
if data.get("status") == "healthy":
|
||||
# Merge discovery info
|
||||
if discovery:
|
||||
data["schema_version"] = discovery.get("schema_version")
|
||||
data["providers"] = [p.get("type") for p in discovery.get("providers", [])]
|
||||
data["endpoint_count"] = len(discovery.get("endpoints", []))
|
||||
|
||||
if parsed["format"] == "json":
|
||||
self._print_json(data)
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
status = data.get("status", "unknown")
|
||||
if status in ("healthy", "ok"):
|
||||
self._print_success("Server healthy")
|
||||
print(f" Service: {data.get('service')}")
|
||||
print(f" Version: {data.get('version')}")
|
||||
if data.get("service"):
|
||||
print(f" Service: {data.get('service')}")
|
||||
if data.get("server_name"):
|
||||
print(f" Server: {data.get('server_name')}")
|
||||
if data.get("version"):
|
||||
print(f" Version: {data.get('version')}")
|
||||
if data.get("schema_version"):
|
||||
print(f" Schema Version: {data.get('schema_version')}")
|
||||
if data.get("providers"):
|
||||
print(f" Providers: {', '.join(data.get('providers'))}")
|
||||
if data.get("endpoint_count"):
|
||||
print(f" Endpoints: {data.get('endpoint_count')}")
|
||||
if data.get("endpoints"):
|
||||
print(f" Endpoints: {', '.join(data.get('endpoints')[:5])}")
|
||||
return self.EXIT_SUCCESS
|
||||
else:
|
||||
self._print_error(f"Server unhealthy: {data}")
|
||||
return self.EXIT_RUNTIME_ERROR
|
||||
|
||||
def cmd_types(self, args: List[str]) -> int:
|
||||
"""List supported provider types."""
|
||||
spec = {
|
||||
"format": {"default": "table"},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
types_info = [
|
||||
{"type": "recipe", "description": "Recipe runner endpoints", "capabilities": ["list", "describe", "invoke", "stream"]},
|
||||
{"type": "agents-api", "description": "Single/multi-agent HTTP API", "capabilities": ["list", "describe", "invoke"]},
|
||||
{"type": "mcp", "description": "MCP server (stdio, http, sse)", "capabilities": ["list-tools", "call-tool"]},
|
||||
{"type": "tools-mcp", "description": "Tools exposed as MCP server", "capabilities": ["list-tools", "call-tool"]},
|
||||
{"type": "a2a", "description": "Agent-to-agent protocol", "capabilities": ["agent-card", "message-send", "stream"]},
|
||||
{"type": "a2u", "description": "Agent-to-user event stream", "capabilities": ["subscribe", "stream"]},
|
||||
]
|
||||
|
||||
if parsed["format"] == "json":
|
||||
self._print_json(types_info)
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
table = Table(title="Supported Provider Types")
|
||||
table.add_column("Type", style="cyan")
|
||||
table.add_column("Description")
|
||||
table.add_column("Capabilities", style="green")
|
||||
|
||||
for t in types_info:
|
||||
table.add_row(
|
||||
t["type"],
|
||||
t["description"],
|
||||
", ".join(t["capabilities"]),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
except ImportError:
|
||||
for t in types_info:
|
||||
print(f"{t['type']}: {t['description']}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def cmd_discovery(self, args: List[str]) -> int:
|
||||
"""Show raw discovery document."""
|
||||
spec = {
|
||||
"url": {"default": None},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
discovery = self._try_unified_discovery(parsed["url"])
|
||||
|
||||
if discovery:
|
||||
self._print_json(discovery)
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
self._print_error("Discovery endpoint not available at this server")
|
||||
return self.EXIT_NOT_FOUND
|
||||
|
||||
|
||||
def handle_endpoints_command(args: List[str]) -> int:
|
||||
|
||||
@@ -53,11 +53,14 @@ class JobsHandler:
|
||||
self,
|
||||
prompt: str,
|
||||
agent_file: Optional[str] = None,
|
||||
recipe_name: Optional[str] = None,
|
||||
recipe_config: Optional[dict] = None,
|
||||
framework: str = "praisonai",
|
||||
timeout: int = 3600,
|
||||
wait: bool = False,
|
||||
poll_interval: int = 5,
|
||||
idempotency_key: Optional[str] = None,
|
||||
idempotency_scope: str = "none",
|
||||
webhook_url: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
output_json: bool = False
|
||||
@@ -68,14 +71,25 @@ class JobsHandler:
|
||||
Args:
|
||||
prompt: The prompt/task for the agent
|
||||
agent_file: Optional path to agents.yaml
|
||||
recipe_name: Optional recipe name (mutually exclusive with agent_file)
|
||||
recipe_config: Optional recipe configuration overrides
|
||||
framework: Framework to use
|
||||
timeout: Job timeout in seconds
|
||||
wait: If True, wait for completion
|
||||
poll_interval: Seconds between status polls
|
||||
idempotency_key: Key for deduplication
|
||||
idempotency_scope: Scope for deduplication (none, session, global)
|
||||
webhook_url: URL for completion webhook
|
||||
session_id: Session ID for conversation continuity
|
||||
output_json: If True, output JSON format
|
||||
|
||||
Returns:
|
||||
Job submission response
|
||||
"""
|
||||
# Validate mutually exclusive options
|
||||
if agent_file and recipe_name:
|
||||
raise ValueError("Cannot specify both --agent-file and --recipe. Choose one.")
|
||||
|
||||
client = self._get_client()
|
||||
|
||||
payload = {
|
||||
@@ -86,12 +100,18 @@ class JobsHandler:
|
||||
|
||||
if agent_file:
|
||||
payload["agent_file"] = agent_file
|
||||
if recipe_name:
|
||||
payload["recipe_name"] = recipe_name
|
||||
if recipe_config:
|
||||
payload["recipe_config"] = recipe_config
|
||||
if webhook_url:
|
||||
payload["webhook_url"] = webhook_url
|
||||
if session_id:
|
||||
payload["session_id"] = session_id
|
||||
if idempotency_key:
|
||||
payload["idempotency_key"] = idempotency_key
|
||||
if idempotency_scope and idempotency_scope != "none":
|
||||
payload["idempotency_scope"] = idempotency_scope
|
||||
|
||||
headers = {}
|
||||
if idempotency_key:
|
||||
@@ -494,12 +514,17 @@ def handle_run_command(args: List[str], verbose: bool = False):
|
||||
submit_parser = subparsers.add_parser("submit", help="Submit a new job")
|
||||
submit_parser.add_argument("prompt", help="The prompt/task for the agent")
|
||||
submit_parser.add_argument("--agent-file", help="Path to agents.yaml")
|
||||
submit_parser.add_argument("--recipe", dest="recipe_name", help="Recipe name to execute (mutually exclusive with --agent-file)")
|
||||
submit_parser.add_argument("--recipe-config", help="Recipe config as JSON string")
|
||||
submit_parser.add_argument("--framework", default="praisonai", help="Framework to use")
|
||||
submit_parser.add_argument("--timeout", type=int, default=3600, help="Timeout in seconds")
|
||||
submit_parser.add_argument("--wait", action="store_true", help="Wait for completion")
|
||||
submit_parser.add_argument("--stream", action="store_true", help="Stream job progress after submission")
|
||||
submit_parser.add_argument("--idempotency-key", help="Idempotency key to prevent duplicates")
|
||||
submit_parser.add_argument("--idempotency-scope", default="none", choices=["none", "session", "global"], help="Idempotency scope")
|
||||
submit_parser.add_argument("--webhook-url", help="Webhook URL for completion callback")
|
||||
submit_parser.add_argument("--session-id", help="Session ID for grouping jobs")
|
||||
submit_parser.add_argument("--metadata", action="append", metavar="KEY=VALUE", help="Custom metadata (can be used multiple times)")
|
||||
submit_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON for scripting")
|
||||
submit_parser.add_argument("--api-url", default="http://127.0.0.1:8005", help="API server URL")
|
||||
|
||||
@@ -552,13 +577,25 @@ def handle_run_command(args: List[str], verbose: bool = False):
|
||||
|
||||
try:
|
||||
if parsed.subcommand == "submit":
|
||||
# Parse recipe config if provided
|
||||
recipe_config = None
|
||||
if getattr(parsed, "recipe_config", None):
|
||||
try:
|
||||
recipe_config = json.loads(parsed.recipe_config)
|
||||
except json.JSONDecodeError:
|
||||
print("Error: --recipe-config must be valid JSON")
|
||||
sys.exit(1)
|
||||
|
||||
handler.submit(
|
||||
prompt=parsed.prompt,
|
||||
agent_file=parsed.agent_file,
|
||||
recipe_name=getattr(parsed, "recipe_name", None),
|
||||
recipe_config=recipe_config,
|
||||
framework=parsed.framework,
|
||||
timeout=parsed.timeout,
|
||||
wait=parsed.wait,
|
||||
idempotency_key=getattr(parsed, "idempotency_key", None),
|
||||
idempotency_scope=getattr(parsed, "idempotency_scope", "none"),
|
||||
webhook_url=getattr(parsed, "webhook_url", None),
|
||||
session_id=getattr(parsed, "session_id", None),
|
||||
output_json=getattr(parsed, "output_json", False)
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
"""
|
||||
Package CLI Feature Handler
|
||||
|
||||
Provides pip-like CLI commands for package management:
|
||||
- install: Install Python packages
|
||||
- uninstall: Uninstall Python packages
|
||||
- list: List installed packages
|
||||
- search: Search packages
|
||||
- index: Manage package indexes
|
||||
|
||||
All commands use the canonical `praisonai` or `praisonai package` prefix.
|
||||
|
||||
Security:
|
||||
- Default to single authoritative index (PyPI)
|
||||
- Warn loudly about dependency confusion when using extra indexes
|
||||
- Require explicit opt-in for extra index fallback
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
|
||||
# Default configuration
|
||||
DEFAULT_INDEX_URL = "https://pypi.org/simple"
|
||||
CONFIG_DIR = Path.home() / ".praison"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
||||
|
||||
|
||||
class PackageConfig:
|
||||
"""Package configuration manager."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize config."""
|
||||
self._config = None
|
||||
|
||||
def _load_config(self) -> Dict[str, Any]:
|
||||
"""Load configuration from file."""
|
||||
if self._config is not None:
|
||||
return self._config
|
||||
|
||||
self._config = {
|
||||
"package": {
|
||||
"index_url": DEFAULT_INDEX_URL,
|
||||
"extra_index_urls": [],
|
||||
"allow_extra_index": False,
|
||||
"trusted_hosts": [],
|
||||
}
|
||||
}
|
||||
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError:
|
||||
try:
|
||||
import tomli as tomllib
|
||||
except ImportError:
|
||||
return self._config
|
||||
|
||||
try:
|
||||
with open(CONFIG_FILE, "rb") as f:
|
||||
file_config = tomllib.load(f)
|
||||
if "package" in file_config:
|
||||
self._config["package"].update(file_config["package"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Environment variable overrides
|
||||
if os.environ.get("PRAISONAI_PACKAGE_INDEX_URL"):
|
||||
self._config["package"]["index_url"] = os.environ["PRAISONAI_PACKAGE_INDEX_URL"]
|
||||
|
||||
if os.environ.get("PRAISONAI_PACKAGE_EXTRA_INDEX_URLS"):
|
||||
urls = os.environ["PRAISONAI_PACKAGE_EXTRA_INDEX_URLS"].split(",")
|
||||
self._config["package"]["extra_index_urls"] = [u.strip() for u in urls if u.strip()]
|
||||
|
||||
return self._config
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get config value."""
|
||||
config = self._load_config()
|
||||
parts = key.split(".")
|
||||
value = config
|
||||
for part in parts:
|
||||
if isinstance(value, dict) and part in value:
|
||||
value = value[part]
|
||||
else:
|
||||
return default
|
||||
return value
|
||||
|
||||
def save_index(self, index_url: str) -> None:
|
||||
"""Save index URL to config."""
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config = {}
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError:
|
||||
try:
|
||||
import tomli as tomllib
|
||||
except ImportError:
|
||||
# Fall back to simple write
|
||||
with open(CONFIG_FILE, "w") as f:
|
||||
f.write(f'[package]\nindex_url = "{index_url}"\n')
|
||||
return
|
||||
|
||||
try:
|
||||
with open(CONFIG_FILE, "rb") as f:
|
||||
config = tomllib.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if "package" not in config:
|
||||
config["package"] = {}
|
||||
config["package"]["index_url"] = index_url
|
||||
|
||||
# Write config (simple TOML format)
|
||||
with open(CONFIG_FILE, "w") as f:
|
||||
for section, values in config.items():
|
||||
f.write(f"[{section}]\n")
|
||||
for key, value in values.items():
|
||||
if isinstance(value, str):
|
||||
f.write(f'{key} = "{value}"\n')
|
||||
elif isinstance(value, bool):
|
||||
f.write(f'{key} = {"true" if value else "false"}\n')
|
||||
elif isinstance(value, list):
|
||||
f.write(f'{key} = {json.dumps(value)}\n')
|
||||
else:
|
||||
f.write(f'{key} = {value}\n')
|
||||
f.write("\n")
|
||||
|
||||
|
||||
class PackageHandler:
|
||||
"""
|
||||
CLI handler for package operations.
|
||||
|
||||
Commands:
|
||||
- install: Install Python packages
|
||||
- uninstall: Uninstall Python packages
|
||||
- list: List installed packages
|
||||
- search: Search packages (best-effort)
|
||||
- index: Manage package indexes
|
||||
"""
|
||||
|
||||
# Stable exit codes
|
||||
EXIT_SUCCESS = 0
|
||||
EXIT_GENERAL_ERROR = 1
|
||||
EXIT_VALIDATION_ERROR = 2
|
||||
EXIT_NETWORK_ERROR = 10
|
||||
EXIT_DEPENDENCY_ERROR = 11
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the handler."""
|
||||
self.config = PackageConfig()
|
||||
|
||||
def handle(self, args: List[str]) -> int:
|
||||
"""
|
||||
Handle package subcommand.
|
||||
|
||||
Args:
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
Exit code
|
||||
"""
|
||||
if not args:
|
||||
self._print_help()
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
command = args[0]
|
||||
remaining = args[1:]
|
||||
|
||||
commands = {
|
||||
"list": self.cmd_list,
|
||||
"search": self.cmd_search,
|
||||
"index": self.cmd_index,
|
||||
"help": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
"--help": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
"-h": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
}
|
||||
|
||||
handler = commands.get(command)
|
||||
if handler:
|
||||
return handler(remaining)
|
||||
|
||||
self._print_error(f"Unknown command: {command}")
|
||||
self._print_help()
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
def _print_help(self):
|
||||
"""Print help message."""
|
||||
print("""
|
||||
PraisonAI Package Commands
|
||||
|
||||
Usage: praisonai package <command> [options]
|
||||
praisonai install <spec...>
|
||||
praisonai uninstall <pkg...>
|
||||
|
||||
Commands:
|
||||
install Install Python packages (shortcut: praisonai install)
|
||||
uninstall Uninstall Python packages (shortcut: praisonai uninstall)
|
||||
list List installed packages
|
||||
search Search for packages
|
||||
index Manage package indexes
|
||||
|
||||
Examples:
|
||||
praisonai install requests
|
||||
praisonai install "requests>=2.28" httpx
|
||||
praisonai uninstall requests
|
||||
praisonai package list
|
||||
praisonai package search langchain
|
||||
praisonai package index show
|
||||
praisonai package index set https://my-index.example.com/simple
|
||||
|
||||
Options for 'install':
|
||||
--index-url URL Use custom index URL
|
||||
--extra-index-url URL Add extra index (requires --allow-extra-index)
|
||||
--allow-extra-index Allow extra index URLs (security risk!)
|
||||
--python PATH Python interpreter to use
|
||||
--upgrade, -U Upgrade packages
|
||||
--no-deps Don't install dependencies
|
||||
--json Output in JSON format
|
||||
|
||||
Options for 'uninstall':
|
||||
--python PATH Python interpreter to use
|
||||
--yes, -y Don't ask for confirmation
|
||||
--json Output in JSON format
|
||||
|
||||
Security Notes:
|
||||
- By default, only the primary index is used (PyPI)
|
||||
- Using --extra-index-url can lead to dependency confusion attacks
|
||||
- Always prefer --index-url over --extra-index-url when possible
|
||||
""")
|
||||
|
||||
def _print_error(self, message: str):
|
||||
"""Print error message."""
|
||||
print(f"Error: {message}", file=sys.stderr)
|
||||
|
||||
def _print_warning(self, message: str):
|
||||
"""Print warning message."""
|
||||
print(f"Warning: {message}", file=sys.stderr)
|
||||
|
||||
def _print_success(self, message: str):
|
||||
"""Print success message."""
|
||||
print(f"✓ {message}")
|
||||
|
||||
def _print_json(self, data: Any):
|
||||
"""Print JSON output."""
|
||||
print(json.dumps(data, indent=2))
|
||||
|
||||
def _parse_args(self, args: List[str], spec: Dict[str, Any]) -> Tuple[Dict[str, Any], List[str]]:
|
||||
"""Parse command arguments, returning parsed args and positional args."""
|
||||
result = {k: v.get("default") for k, v in spec.items()}
|
||||
positional = []
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
|
||||
if arg.startswith("--"):
|
||||
key = arg[2:].replace("-", "_")
|
||||
if key in spec:
|
||||
if spec[key].get("flag"):
|
||||
result[key] = True
|
||||
elif i + 1 < len(args):
|
||||
result[key] = args[i + 1]
|
||||
i += 1
|
||||
else:
|
||||
positional.append(arg)
|
||||
elif arg.startswith("-") and len(arg) == 2:
|
||||
# Short flag
|
||||
found = False
|
||||
for key, val in spec.items():
|
||||
if val.get("short") == arg:
|
||||
if val.get("flag"):
|
||||
result[key] = True
|
||||
elif i + 1 < len(args):
|
||||
result[key] = args[i + 1]
|
||||
i += 1
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
positional.append(arg)
|
||||
else:
|
||||
positional.append(arg)
|
||||
i += 1
|
||||
|
||||
return result, positional
|
||||
|
||||
def _get_python(self, python_path: str = None) -> str:
|
||||
"""Get Python interpreter path."""
|
||||
if python_path:
|
||||
return python_path
|
||||
return sys.executable
|
||||
|
||||
def _run_pip(
|
||||
self,
|
||||
args: List[str],
|
||||
python: str = None,
|
||||
capture: bool = False,
|
||||
) -> Tuple[int, str, str]:
|
||||
"""
|
||||
Run pip command.
|
||||
|
||||
Args:
|
||||
args: pip arguments
|
||||
python: Python interpreter path
|
||||
capture: Capture output instead of streaming
|
||||
|
||||
Returns:
|
||||
Tuple of (return_code, stdout, stderr)
|
||||
"""
|
||||
python = self._get_python(python)
|
||||
cmd = [python, "-m", "pip"] + args
|
||||
|
||||
if capture:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, "PAGER": "cat"},
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
else:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
env={**os.environ, "PAGER": "cat"},
|
||||
)
|
||||
return result.returncode, "", ""
|
||||
|
||||
def cmd_install(self, args: List[str]) -> int:
|
||||
"""Install Python packages."""
|
||||
spec = {
|
||||
"index_url": {"default": None},
|
||||
"extra_index_url": {"default": None},
|
||||
"allow_extra_index": {"flag": True, "default": False},
|
||||
"python": {"default": None},
|
||||
"upgrade": {"flag": True, "short": "-U", "default": False},
|
||||
"no_deps": {"flag": True, "default": False},
|
||||
"json": {"flag": True, "default": False},
|
||||
}
|
||||
parsed, packages = self._parse_args(args, spec)
|
||||
|
||||
if not packages:
|
||||
self._print_error("No packages specified")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
# Build pip command
|
||||
pip_args = ["install"]
|
||||
|
||||
# Index URL
|
||||
index_url = parsed["index_url"] or self.config.get("package.index_url", DEFAULT_INDEX_URL)
|
||||
pip_args.extend(["--index-url", index_url])
|
||||
|
||||
# Extra index URL (with security warning)
|
||||
extra_index = parsed["extra_index_url"]
|
||||
if extra_index:
|
||||
allow_extra = parsed["allow_extra_index"] or self.config.get("package.allow_extra_index", False)
|
||||
if not allow_extra:
|
||||
self._print_error(
|
||||
"Extra index URLs are disabled by default for security.\n"
|
||||
"Using multiple indexes can lead to dependency confusion attacks.\n"
|
||||
"To enable, use --allow-extra-index flag or set package.allow_extra_index=true in config."
|
||||
)
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
self._print_warning(
|
||||
"⚠️ SECURITY WARNING: Using extra index URLs can lead to dependency confusion attacks!\n"
|
||||
" An attacker could publish a malicious package with the same name on PyPI.\n"
|
||||
" Only use this if you trust both indexes and understand the risks."
|
||||
)
|
||||
pip_args.extend(["--extra-index-url", extra_index])
|
||||
|
||||
# Other options
|
||||
if parsed["upgrade"]:
|
||||
pip_args.append("--upgrade")
|
||||
if parsed["no_deps"]:
|
||||
pip_args.append("--no-deps")
|
||||
|
||||
# Add packages
|
||||
pip_args.extend(packages)
|
||||
|
||||
if parsed["json"]:
|
||||
returncode, stdout, stderr = self._run_pip(pip_args, parsed["python"], capture=True)
|
||||
self._print_json({
|
||||
"ok": returncode == 0,
|
||||
"packages": packages,
|
||||
"returncode": returncode,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
})
|
||||
return returncode
|
||||
else:
|
||||
print(f"Installing: {', '.join(packages)}")
|
||||
returncode, _, _ = self._run_pip(pip_args, parsed["python"])
|
||||
if returncode == 0:
|
||||
self._print_success("Installation complete")
|
||||
return returncode
|
||||
|
||||
def cmd_uninstall(self, args: List[str]) -> int:
|
||||
"""Uninstall Python packages."""
|
||||
spec = {
|
||||
"python": {"default": None},
|
||||
"yes": {"flag": True, "short": "-y", "default": False},
|
||||
"json": {"flag": True, "default": False},
|
||||
}
|
||||
parsed, packages = self._parse_args(args, spec)
|
||||
|
||||
if not packages:
|
||||
self._print_error("No packages specified")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
pip_args = ["uninstall"]
|
||||
if parsed["yes"]:
|
||||
pip_args.append("-y")
|
||||
pip_args.extend(packages)
|
||||
|
||||
if parsed["json"]:
|
||||
returncode, stdout, stderr = self._run_pip(pip_args, parsed["python"], capture=True)
|
||||
self._print_json({
|
||||
"ok": returncode == 0,
|
||||
"packages": packages,
|
||||
"returncode": returncode,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
})
|
||||
return returncode
|
||||
else:
|
||||
print(f"Uninstalling: {', '.join(packages)}")
|
||||
returncode, _, _ = self._run_pip(pip_args, parsed["python"])
|
||||
if returncode == 0:
|
||||
self._print_success("Uninstall complete")
|
||||
return returncode
|
||||
|
||||
def cmd_list(self, args: List[str]) -> int:
|
||||
"""List installed packages."""
|
||||
spec = {
|
||||
"python": {"default": None},
|
||||
"json": {"flag": True, "default": False},
|
||||
"outdated": {"flag": True, "default": False},
|
||||
}
|
||||
parsed, _ = self._parse_args(args, spec)
|
||||
|
||||
pip_args = ["list", "--format=json"]
|
||||
if parsed["outdated"]:
|
||||
pip_args.append("--outdated")
|
||||
|
||||
returncode, stdout, stderr = self._run_pip(pip_args, parsed["python"], capture=True)
|
||||
|
||||
if returncode != 0:
|
||||
self._print_error(stderr or "Failed to list packages")
|
||||
return returncode
|
||||
|
||||
try:
|
||||
packages = json.loads(stdout)
|
||||
except json.JSONDecodeError:
|
||||
packages = []
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json({
|
||||
"ok": True,
|
||||
"packages": packages,
|
||||
"count": len(packages),
|
||||
})
|
||||
else:
|
||||
if not packages:
|
||||
print("No packages installed.")
|
||||
else:
|
||||
print(f"{'Package':<30} {'Version':<15}")
|
||||
print("-" * 45)
|
||||
for pkg in packages:
|
||||
name = pkg.get("name", "")
|
||||
version = pkg.get("version", "")
|
||||
print(f"{name:<30} {version:<15}")
|
||||
print(f"\nTotal: {len(packages)} packages")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def cmd_search(self, args: List[str]) -> int:
|
||||
"""Search for packages (best-effort, uses pip search or PyPI API)."""
|
||||
spec = {
|
||||
"json": {"flag": True, "default": False},
|
||||
}
|
||||
parsed, query_parts = self._parse_args(args, spec)
|
||||
|
||||
if not query_parts:
|
||||
self._print_error("Search query required")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
query = " ".join(query_parts)
|
||||
|
||||
# pip search is deprecated, use PyPI JSON API
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
url = f"https://pypi.org/pypi/{query}/json"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
|
||||
info = data.get("info", {})
|
||||
result = {
|
||||
"name": info.get("name"),
|
||||
"version": info.get("version"),
|
||||
"summary": info.get("summary"),
|
||||
"author": info.get("author"),
|
||||
"home_page": info.get("home_page"),
|
||||
}
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json({"ok": True, "results": [result]})
|
||||
else:
|
||||
print(f"Found: {result['name']} ({result['version']})")
|
||||
print(f" {result['summary']}")
|
||||
if result['home_page']:
|
||||
print(f" Homepage: {result['home_page']}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
if parsed["json"]:
|
||||
self._print_json({"ok": True, "results": [], "message": "No exact match found"})
|
||||
else:
|
||||
print(f"No exact match for '{query}'")
|
||||
print("Try: pip search <query> (if available) or browse https://pypi.org")
|
||||
return self.EXIT_SUCCESS
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
if parsed["json"]:
|
||||
self._print_json({"ok": False, "error": str(e)})
|
||||
else:
|
||||
self._print_error(f"Search failed: {e}")
|
||||
return self.EXIT_NETWORK_ERROR
|
||||
|
||||
def cmd_index(self, args: List[str]) -> int:
|
||||
"""Manage package indexes."""
|
||||
if not args:
|
||||
return self._index_show([])
|
||||
|
||||
subcommand = args[0]
|
||||
remaining = args[1:]
|
||||
|
||||
subcommands = {
|
||||
"show": self._index_show,
|
||||
"set": self._index_set,
|
||||
"add": self._index_add,
|
||||
"remove": self._index_remove,
|
||||
}
|
||||
|
||||
handler = subcommands.get(subcommand)
|
||||
if handler:
|
||||
return handler(remaining)
|
||||
|
||||
self._print_error(f"Unknown index subcommand: {subcommand}")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
def _index_show(self, args: List[str]) -> int:
|
||||
"""Show current index configuration."""
|
||||
spec = {
|
||||
"json": {"flag": True, "default": False},
|
||||
}
|
||||
parsed, _ = self._parse_args(args, spec)
|
||||
|
||||
index_url = self.config.get("package.index_url", DEFAULT_INDEX_URL)
|
||||
extra_urls = self.config.get("package.extra_index_urls", [])
|
||||
allow_extra = self.config.get("package.allow_extra_index", False)
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json({
|
||||
"ok": True,
|
||||
"index_url": index_url,
|
||||
"extra_index_urls": extra_urls,
|
||||
"allow_extra_index": allow_extra,
|
||||
})
|
||||
else:
|
||||
print(f"Primary Index: {index_url}")
|
||||
if extra_urls:
|
||||
print(f"Extra Indexes: {', '.join(extra_urls)}")
|
||||
print(f"Allow Extra Index: {'yes' if allow_extra else 'no'}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def _index_set(self, args: List[str]) -> int:
|
||||
"""Set primary index URL."""
|
||||
if not args:
|
||||
self._print_error("Index URL required")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
url = args[0]
|
||||
|
||||
# Validate URL format
|
||||
if not url.startswith(("http://", "https://")):
|
||||
self._print_error("Index URL must start with http:// or https://")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
self.config.save_index(url)
|
||||
self._print_success(f"Primary index set to: {url}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def _index_add(self, args: List[str]) -> int:
|
||||
"""Add extra index URL."""
|
||||
self._print_warning(
|
||||
"Adding extra index URLs is a security risk!\n"
|
||||
"This can lead to dependency confusion attacks.\n"
|
||||
"Consider using --index-url to switch indexes instead."
|
||||
)
|
||||
|
||||
if not args:
|
||||
self._print_error("Index URL required")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
# For now, just print instructions
|
||||
print("\nTo use extra index, run:")
|
||||
print(f" praisonai install <pkg> --extra-index-url {args[0]} --allow-extra-index")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def _index_remove(self, args: List[str]) -> int:
|
||||
"""Remove extra index URL."""
|
||||
print("Extra index URLs are not persisted by default.")
|
||||
print("To reset to PyPI, run:")
|
||||
print(f" praisonai package index set {DEFAULT_INDEX_URL}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
@@ -255,12 +255,62 @@ class RecipeHandler:
|
||||
spec = {
|
||||
"source": {"default": None},
|
||||
"tags": {"default": None},
|
||||
"registry": {"default": None},
|
||||
"token": {"default": None},
|
||||
"json": {"flag": True, "default": False},
|
||||
"offline": {"flag": True, "default": False},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
try:
|
||||
# If registry URL provided, list from that registry
|
||||
if parsed["registry"]:
|
||||
from praisonai.recipe.registry import get_registry
|
||||
import os
|
||||
registry = get_registry(
|
||||
registry=parsed["registry"],
|
||||
token=parsed["token"] or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
|
||||
)
|
||||
result = registry.list_recipes(
|
||||
tags=parsed["tags"].split(",") if parsed["tags"] else None
|
||||
)
|
||||
recipes = result.get("recipes", []) if isinstance(result, dict) else result
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json(recipes)
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
if not recipes:
|
||||
print("No recipes found in registry.")
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
table = Table(title=f"Recipes from {parsed['registry']}")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Version", style="green")
|
||||
table.add_column("Description")
|
||||
table.add_column("Tags", style="yellow")
|
||||
|
||||
for r in recipes:
|
||||
table.add_row(
|
||||
r.get("name", ""),
|
||||
r.get("version", ""),
|
||||
(r.get("description", "")[:50] + "...") if len(r.get("description", "")) > 50 else r.get("description", ""),
|
||||
", ".join(r.get("tags", [])[:3]),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
except ImportError:
|
||||
for r in recipes:
|
||||
print(f"{r.get('name')} ({r.get('version')}): {r.get('description', '')}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
# Default: list local recipes
|
||||
recipes = self.recipe.list_recipes(
|
||||
source_filter=parsed["source"],
|
||||
tags=parsed["tags"].split(",") if parsed["tags"] else None,
|
||||
@@ -309,6 +359,8 @@ class RecipeHandler:
|
||||
"""Search recipes by query."""
|
||||
spec = {
|
||||
"query": {"positional": True, "default": ""},
|
||||
"registry": {"default": None},
|
||||
"token": {"default": None},
|
||||
"json": {"flag": True, "default": False},
|
||||
"offline": {"flag": True, "default": False},
|
||||
}
|
||||
@@ -319,6 +371,32 @@ class RecipeHandler:
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
try:
|
||||
# If registry URL provided, search that registry
|
||||
if parsed["registry"]:
|
||||
from praisonai.recipe.registry import get_registry
|
||||
import os
|
||||
registry = get_registry(
|
||||
registry=parsed["registry"],
|
||||
token=parsed["token"] or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
|
||||
)
|
||||
result = registry.search(parsed["query"])
|
||||
matches = result.get("results", []) if isinstance(result, dict) else result
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json(matches)
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
if not matches:
|
||||
print(f"No recipes found matching '{parsed['query']}' in registry")
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
print(f"Found {len(matches)} recipe(s) matching '{parsed['query']}':")
|
||||
for r in matches:
|
||||
print(f" {r.get('name')}: {r.get('description', '')}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
# Default: search local recipes
|
||||
recipes = self.recipe.list_recipes(offline=parsed["offline"])
|
||||
query = parsed["query"].lower()
|
||||
|
||||
@@ -473,6 +551,7 @@ class RecipeHandler:
|
||||
"session": {"short": "-s", "default": None},
|
||||
"json": {"flag": True, "default": False},
|
||||
"stream": {"flag": True, "default": False},
|
||||
"background": {"flag": True, "default": False},
|
||||
"dry_run": {"flag": True, "default": False},
|
||||
"explain": {"flag": True, "default": False},
|
||||
"verbose": {"short": "-v", "flag": True, "default": False},
|
||||
@@ -531,6 +610,13 @@ class RecipeHandler:
|
||||
}
|
||||
|
||||
try:
|
||||
# Background execution mode
|
||||
if parsed["background"]:
|
||||
return self._run_background(
|
||||
parsed["recipe"], input_data, config,
|
||||
parsed["session"], options, parsed["json"]
|
||||
)
|
||||
|
||||
if parsed["stream"]:
|
||||
return self._run_stream(
|
||||
parsed["recipe"], input_data, config,
|
||||
@@ -578,6 +664,50 @@ class RecipeHandler:
|
||||
self._print_error(str(e))
|
||||
return self.EXIT_RUNTIME_ERROR
|
||||
|
||||
def _run_background(
|
||||
self,
|
||||
recipe_name: str,
|
||||
input_data: Dict[str, Any],
|
||||
config: Dict[str, Any],
|
||||
session_id: Optional[str],
|
||||
options: Dict[str, Any],
|
||||
json_output: bool,
|
||||
) -> int:
|
||||
"""Run recipe as a background task."""
|
||||
try:
|
||||
from praisonai.recipe.operations import run_background
|
||||
|
||||
task = run_background(
|
||||
recipe_name,
|
||||
input=input_data or None,
|
||||
config=config or None,
|
||||
session_id=session_id,
|
||||
timeout_sec=options.get('timeout_sec', 300),
|
||||
)
|
||||
|
||||
if json_output:
|
||||
self._print_json({
|
||||
"ok": True,
|
||||
"task_id": task.task_id,
|
||||
"recipe": task.recipe_name,
|
||||
"session_id": task.session_id,
|
||||
"message": "Task submitted to background"
|
||||
})
|
||||
else:
|
||||
self._print_success(f"Recipe '{recipe_name}' submitted to background")
|
||||
print(f" Task ID: {task.task_id}")
|
||||
print(f" Session: {task.session_id}")
|
||||
print(f"\nCheck status with: praisonai background status {task.task_id}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
except Exception as e:
|
||||
if json_output:
|
||||
self._print_json({"ok": False, "error": str(e)})
|
||||
else:
|
||||
self._print_error(f"Failed to submit background task: {e}")
|
||||
return self.EXIT_RUNTIME_ERROR
|
||||
|
||||
def _run_stream(
|
||||
self,
|
||||
recipe_name: str,
|
||||
@@ -1040,6 +1170,12 @@ OPENAI_API_KEY=your-api-key
|
||||
"recipes": {"default": None},
|
||||
"config": {"default": None},
|
||||
"api_key": {"default": None},
|
||||
"workers": {"default": "1"},
|
||||
"rate_limit": {"default": None},
|
||||
"max_request_size": {"default": None},
|
||||
"enable_metrics": {"flag": True, "default": False},
|
||||
"enable_admin": {"flag": True, "default": False},
|
||||
"trace_exporter": {"default": "none"},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
@@ -1057,6 +1193,7 @@ OPENAI_API_KEY=your-api-key
|
||||
port = int(parsed["port"]) if parsed["port"] != "8765" or "port" not in config else config.get("port", 8765)
|
||||
host = parsed["host"] if parsed["host"] != "127.0.0.1" or "host" not in config else config.get("host", "127.0.0.1")
|
||||
auth = parsed["auth"] if parsed["auth"] != "none" or "auth" not in config else config.get("auth", "none")
|
||||
workers = int(parsed["workers"]) if parsed["workers"] != "1" or "workers" not in config else config.get("workers", 1)
|
||||
|
||||
# Update config with CLI overrides
|
||||
config["auth"] = auth
|
||||
@@ -1064,6 +1201,16 @@ OPENAI_API_KEY=your-api-key
|
||||
config["api_key"] = parsed["api_key"]
|
||||
if parsed["recipes"]:
|
||||
config["recipes"] = parsed["recipes"].split(",")
|
||||
if parsed["rate_limit"]:
|
||||
config["rate_limit"] = int(parsed["rate_limit"])
|
||||
if parsed["max_request_size"]:
|
||||
config["max_request_size"] = int(parsed["max_request_size"])
|
||||
if parsed["enable_metrics"]:
|
||||
config["enable_metrics"] = True
|
||||
if parsed["enable_admin"]:
|
||||
config["enable_admin"] = True
|
||||
if parsed["trace_exporter"] != "none":
|
||||
config["trace_exporter"] = parsed["trace_exporter"]
|
||||
|
||||
# Safety check: require auth for non-localhost
|
||||
if host != "127.0.0.1" and host != "localhost" and auth == "none":
|
||||
@@ -1074,15 +1221,22 @@ OPENAI_API_KEY=your-api-key
|
||||
from praisonai.recipe.serve import serve
|
||||
|
||||
print(f"Starting Recipe Runner on http://{host}:{port}")
|
||||
if workers > 1:
|
||||
print(f"Workers: {workers}")
|
||||
if auth != "none":
|
||||
print(f"Auth: {auth}")
|
||||
print("Press Ctrl+C to stop")
|
||||
print("\nEndpoints:")
|
||||
print(" GET /health - Health check")
|
||||
print(" GET /v1/recipes - List recipes")
|
||||
print(" GET /v1/recipes/{name} - Describe recipe")
|
||||
print(" POST /v1/recipes/run - Run recipe")
|
||||
print(" POST /v1/recipes/stream - Stream recipe")
|
||||
print(" GET /health - Health check")
|
||||
print(" GET /v1/recipes - List recipes")
|
||||
print(" GET /v1/recipes/{name} - Describe recipe")
|
||||
print(" POST /v1/recipes/run - Run recipe")
|
||||
print(" POST /v1/recipes/stream - Stream recipe")
|
||||
print(" GET /openapi.json - OpenAPI spec")
|
||||
if config.get("enable_metrics"):
|
||||
print(" GET /metrics - Prometheus metrics")
|
||||
if config.get("enable_admin"):
|
||||
print(" POST /admin/reload - Hot reload registry")
|
||||
|
||||
# Preload recipes if requested
|
||||
if parsed["preload"]:
|
||||
@@ -1091,7 +1245,7 @@ OPENAI_API_KEY=your-api-key
|
||||
recipes = recipe.list_recipes()
|
||||
print(f" Loaded {len(recipes)} recipes")
|
||||
|
||||
serve(host=host, port=port, reload=parsed["reload"], config=config)
|
||||
serve(host=host, port=port, reload=parsed["reload"], config=config, workers=workers)
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
Recipes CLI - Commands for managing and running Agent-Recipes.
|
||||
|
||||
Provides commands for:
|
||||
- praison recipes list - List available recipes
|
||||
- praison recipes info <name> - Show recipe details
|
||||
- praison recipes run <name> <input> - Run a recipe
|
||||
- praison recipes doctor <name> - Check recipe dependencies
|
||||
- praison recipes explain <name> - Show recipe execution plan
|
||||
- praison recipes init <name> - Initialize recipe in current directory
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import yaml
|
||||
import argparse
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Recipe discovery paths in order of precedence
|
||||
RECIPE_PATHS = [
|
||||
Path.home() / ".praison" / "templates",
|
||||
Path.home() / ".config" / "praison" / "templates",
|
||||
Path.cwd() / ".praison" / "templates",
|
||||
Path("/Users/praison/Agent-Recipes/agent_recipes/templates"),
|
||||
]
|
||||
|
||||
|
||||
def find_recipe_paths() -> List[Path]:
|
||||
"""Find all valid recipe directories."""
|
||||
paths = []
|
||||
for p in RECIPE_PATHS:
|
||||
if p.exists() and p.is_dir():
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
|
||||
def list_recipes() -> List[Dict[str, Any]]:
|
||||
"""List all available recipes."""
|
||||
recipes = []
|
||||
seen = set()
|
||||
|
||||
for base_path in find_recipe_paths():
|
||||
for recipe_dir in base_path.iterdir():
|
||||
if not recipe_dir.is_dir():
|
||||
continue
|
||||
|
||||
template_file = recipe_dir / "TEMPLATE.yaml"
|
||||
if not template_file.exists():
|
||||
continue
|
||||
|
||||
name = recipe_dir.name
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
|
||||
try:
|
||||
with open(template_file) as f:
|
||||
template = yaml.safe_load(f)
|
||||
recipes.append({
|
||||
"name": name,
|
||||
"description": template.get("description", ""),
|
||||
"version": template.get("version", "1.0.0"),
|
||||
"tags": template.get("tags", []),
|
||||
"path": str(recipe_dir),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load recipe {name}: {e}")
|
||||
|
||||
return sorted(recipes, key=lambda x: x["name"])
|
||||
|
||||
|
||||
def get_recipe(name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get recipe by name."""
|
||||
for base_path in find_recipe_paths():
|
||||
recipe_dir = base_path / name
|
||||
template_file = recipe_dir / "TEMPLATE.yaml"
|
||||
|
||||
if template_file.exists():
|
||||
with open(template_file) as f:
|
||||
template = yaml.safe_load(f)
|
||||
template["path"] = str(recipe_dir)
|
||||
return template
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def check_dependencies(recipe: Dict[str, Any]) -> Dict[str, bool]:
|
||||
"""Check if recipe dependencies are satisfied."""
|
||||
results = {}
|
||||
requires = recipe.get("requires", {})
|
||||
|
||||
# Check environment variables
|
||||
for env_var in requires.get("env", []):
|
||||
results[f"env:{env_var}"] = bool(os.environ.get(env_var))
|
||||
|
||||
# Check Python packages
|
||||
for package in requires.get("packages", []):
|
||||
try:
|
||||
__import__(package.replace("-", "_").split("[")[0])
|
||||
results[f"package:{package}"] = True
|
||||
except ImportError:
|
||||
results[f"package:{package}"] = False
|
||||
|
||||
# Check external tools
|
||||
import shutil
|
||||
for tool in requires.get("external", []):
|
||||
results[f"external:{tool}"] = shutil.which(tool) is not None
|
||||
|
||||
# Check recipe tools
|
||||
for tool in requires.get("tools", []):
|
||||
try:
|
||||
from praisonai_tools.recipe_tools import __all__ as available_tools
|
||||
# Check if tool class exists
|
||||
tool_class = tool.replace("_tool", "").title() + "Tool"
|
||||
results[f"tool:{tool}"] = tool_class in available_tools or tool in str(available_tools)
|
||||
except ImportError:
|
||||
results[f"tool:{tool}"] = False
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
"""List available recipes."""
|
||||
recipes = list_recipes()
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(recipes, indent=2))
|
||||
return 0
|
||||
|
||||
if not recipes:
|
||||
print("No recipes found.")
|
||||
return 0
|
||||
|
||||
# Group by first tag if available
|
||||
if args.group:
|
||||
groups = {}
|
||||
for r in recipes:
|
||||
tag = r["tags"][0] if r["tags"] else "other"
|
||||
if tag not in groups:
|
||||
groups[tag] = []
|
||||
groups[tag].append(r)
|
||||
|
||||
for group, group_recipes in sorted(groups.items()):
|
||||
print(f"\n{group.upper()}:")
|
||||
for r in group_recipes:
|
||||
print(f" {r['name']:<35} {r['description'][:50]}")
|
||||
else:
|
||||
print(f"{'Recipe':<35} {'Description':<50} {'Version'}")
|
||||
print("-" * 95)
|
||||
for r in recipes:
|
||||
desc = r["description"][:47] + "..." if len(r["description"]) > 50 else r["description"]
|
||||
print(f"{r['name']:<35} {desc:<50} {r['version']}")
|
||||
|
||||
print(f"\nTotal: {len(recipes)} recipes")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_info(args: argparse.Namespace) -> int:
|
||||
"""Show recipe details."""
|
||||
recipe = get_recipe(args.name)
|
||||
|
||||
if not recipe:
|
||||
print(f"Recipe not found: {args.name}")
|
||||
return 1
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(recipe, indent=2, default=str))
|
||||
return 0
|
||||
|
||||
print(f"Name: {recipe.get('name', args.name)}")
|
||||
print(f"Version: {recipe.get('version', '1.0.0')}")
|
||||
print(f"Description: {recipe.get('description', '')}")
|
||||
print(f"Author: {recipe.get('author', 'unknown')}")
|
||||
print(f"License: {recipe.get('license', 'unknown')}")
|
||||
print(f"Path: {recipe.get('path', '')}")
|
||||
|
||||
if recipe.get("tags"):
|
||||
print(f"Tags: {', '.join(recipe['tags'])}")
|
||||
|
||||
requires = recipe.get("requires", {})
|
||||
if requires:
|
||||
print("\nRequirements:")
|
||||
if requires.get("env"):
|
||||
print(f" Environment: {', '.join(requires['env'])}")
|
||||
if requires.get("packages"):
|
||||
print(f" Packages: {', '.join(requires['packages'])}")
|
||||
if requires.get("external"):
|
||||
print(f" External: {', '.join(requires['external'])}")
|
||||
if requires.get("tools"):
|
||||
print(f" Tools: {', '.join(requires['tools'])}")
|
||||
|
||||
cli = recipe.get("cli", {})
|
||||
if cli:
|
||||
print("\nCLI:")
|
||||
print(f" Command: {cli.get('command', '')}")
|
||||
if cli.get("examples"):
|
||||
print(" Examples:")
|
||||
for ex in cli["examples"]:
|
||||
print(f" {ex}")
|
||||
|
||||
safety = recipe.get("safety", {})
|
||||
if safety:
|
||||
print("\nSafety:")
|
||||
print(f" Dry-run default: {safety.get('dry_run_default', False)}")
|
||||
if safety.get("requires_consent"):
|
||||
print(f" Requires consent: {safety.get('consent_message', 'Yes')}")
|
||||
if safety.get("legal_disclaimer"):
|
||||
print(f" Legal disclaimer: {safety.get('legal_disclaimer')}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_doctor(args: argparse.Namespace) -> int:
|
||||
"""Check recipe dependencies."""
|
||||
recipe = get_recipe(args.name)
|
||||
|
||||
if not recipe:
|
||||
print(f"Recipe not found: {args.name}")
|
||||
return 1
|
||||
|
||||
print(f"Checking dependencies for: {args.name}")
|
||||
print("-" * 50)
|
||||
|
||||
deps = check_dependencies(recipe)
|
||||
all_ok = True
|
||||
|
||||
for dep, status in sorted(deps.items()):
|
||||
icon = "✓" if status else "✗"
|
||||
color = "\033[92m" if status else "\033[91m"
|
||||
reset = "\033[0m"
|
||||
print(f" {color}{icon}{reset} {dep}")
|
||||
if not status:
|
||||
all_ok = False
|
||||
|
||||
print("-" * 50)
|
||||
if all_ok:
|
||||
print("All dependencies satisfied!")
|
||||
return 0
|
||||
else:
|
||||
print("\nMissing dependencies. Install with:")
|
||||
requires = recipe.get("requires", {})
|
||||
|
||||
if requires.get("packages"):
|
||||
missing_pkgs = [p for p in requires["packages"] if not deps.get(f"package:{p}", True)]
|
||||
if missing_pkgs:
|
||||
print(f" pip install {' '.join(missing_pkgs)}")
|
||||
|
||||
if requires.get("external"):
|
||||
missing_ext = [e for e in requires["external"] if not deps.get(f"external:{e}", True)]
|
||||
if missing_ext:
|
||||
print(f" # Install external tools: {', '.join(missing_ext)}")
|
||||
|
||||
if requires.get("env"):
|
||||
missing_env = [e for e in requires["env"] if not deps.get(f"env:{e}", True)]
|
||||
if missing_env:
|
||||
print(f" # Set environment variables: {', '.join(missing_env)}")
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_run(args: argparse.Namespace) -> int:
|
||||
"""Run a recipe."""
|
||||
recipe = get_recipe(args.name)
|
||||
|
||||
if not recipe:
|
||||
print(f"Recipe not found: {args.name}")
|
||||
return 1
|
||||
|
||||
# Check safety requirements
|
||||
safety = recipe.get("safety", {})
|
||||
|
||||
if safety.get("requires_consent") and not args.consent:
|
||||
print(f"This recipe requires consent: {safety.get('consent_message', '')}")
|
||||
print("Use --consent flag to acknowledge.")
|
||||
return 1
|
||||
|
||||
if safety.get("legal_disclaimer"):
|
||||
print(f"LEGAL DISCLAIMER: {safety['legal_disclaimer']}")
|
||||
|
||||
# Check dependencies
|
||||
if not args.skip_checks:
|
||||
deps = check_dependencies(recipe)
|
||||
missing = [k for k, v in deps.items() if not v]
|
||||
if missing:
|
||||
print(f"Missing dependencies: {', '.join(missing)}")
|
||||
print("Run 'praison recipes doctor {args.name}' for details.")
|
||||
if not args.force:
|
||||
return 1
|
||||
|
||||
# Dry run mode
|
||||
if args.dry_run or (safety.get("dry_run_default") and not args.write):
|
||||
print(f"DRY RUN: Would execute recipe '{args.name}'")
|
||||
print(f" Input: {args.input}")
|
||||
print(f" Output: {args.output or 'default'}")
|
||||
print("\nUse --write to execute.")
|
||||
return 0
|
||||
|
||||
# Execute recipe
|
||||
print(f"Running recipe: {args.name}")
|
||||
print(f"Input: {args.input}")
|
||||
|
||||
# TODO: Implement actual recipe execution via praisonaiagents
|
||||
# For now, show what would be executed
|
||||
print("\nRecipe execution would involve:")
|
||||
for tool in recipe.get("requires", {}).get("tools", []):
|
||||
print(f" - Using {tool}")
|
||||
|
||||
print("\nRecipe execution completed (placeholder).")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_explain(args: argparse.Namespace) -> int:
|
||||
"""Explain recipe execution plan."""
|
||||
recipe = get_recipe(args.name)
|
||||
|
||||
if not recipe:
|
||||
print(f"Recipe not found: {args.name}")
|
||||
return 1
|
||||
|
||||
print(f"Execution Plan for: {args.name}")
|
||||
print("=" * 50)
|
||||
|
||||
print("\n1. Dependency Check:")
|
||||
for tool in recipe.get("requires", {}).get("tools", []):
|
||||
print(f" - Load {tool}")
|
||||
|
||||
print("\n2. Input Processing:")
|
||||
inputs = recipe.get("inputs", {})
|
||||
for name, spec in inputs.items():
|
||||
print(f" - {name}: {spec.get('type', 'unknown')}")
|
||||
|
||||
print("\n3. Execution:")
|
||||
print(f" - Run recipe workflow")
|
||||
|
||||
print("\n4. Output:")
|
||||
outputs = recipe.get("outputs", {})
|
||||
for name, spec in outputs.items():
|
||||
print(f" - {name}: {spec.get('type', 'unknown')}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_init(args: argparse.Namespace) -> int:
|
||||
"""Initialize recipe in current directory."""
|
||||
recipe = get_recipe(args.name)
|
||||
|
||||
if not recipe:
|
||||
print(f"Recipe not found: {args.name}")
|
||||
return 1
|
||||
|
||||
target_dir = Path(args.output) if args.output else Path.cwd() / args.name
|
||||
|
||||
if target_dir.exists() and not args.force:
|
||||
print(f"Directory already exists: {target_dir}")
|
||||
print("Use --force to overwrite.")
|
||||
return 1
|
||||
|
||||
import shutil
|
||||
source_dir = Path(recipe["path"])
|
||||
|
||||
if args.dry_run:
|
||||
print(f"DRY RUN: Would copy {source_dir} to {target_dir}")
|
||||
return 0
|
||||
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy recipe files
|
||||
for item in source_dir.iterdir():
|
||||
if item.is_file():
|
||||
shutil.copy2(item, target_dir / item.name)
|
||||
|
||||
print(f"Initialized recipe '{args.name}' in {target_dir}")
|
||||
return 0
|
||||
|
||||
|
||||
def setup_parser(subparsers: argparse._SubParsersAction) -> None:
|
||||
"""Setup recipes subcommand parser."""
|
||||
recipes_parser = subparsers.add_parser(
|
||||
"recipes",
|
||||
help="Manage and run Agent-Recipes",
|
||||
description="Commands for managing and running Agent-Recipes templates.",
|
||||
)
|
||||
|
||||
recipes_subparsers = recipes_parser.add_subparsers(dest="recipes_command")
|
||||
|
||||
# List command
|
||||
list_parser = recipes_subparsers.add_parser("list", help="List available recipes")
|
||||
list_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
list_parser.add_argument("--group", action="store_true", help="Group by category")
|
||||
list_parser.set_defaults(func=cmd_list)
|
||||
|
||||
# Info command
|
||||
info_parser = recipes_subparsers.add_parser("info", help="Show recipe details")
|
||||
info_parser.add_argument("name", help="Recipe name")
|
||||
info_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
info_parser.set_defaults(func=cmd_info)
|
||||
|
||||
# Doctor command
|
||||
doctor_parser = recipes_subparsers.add_parser("doctor", help="Check recipe dependencies")
|
||||
doctor_parser.add_argument("name", help="Recipe name")
|
||||
doctor_parser.set_defaults(func=cmd_doctor)
|
||||
|
||||
# Run command
|
||||
run_parser = recipes_subparsers.add_parser("run", help="Run a recipe")
|
||||
run_parser.add_argument("name", help="Recipe name")
|
||||
run_parser.add_argument("input", nargs="?", help="Input file or value")
|
||||
run_parser.add_argument("--output", "-o", help="Output directory")
|
||||
run_parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
|
||||
run_parser.add_argument("--write", action="store_true", help="Actually execute (for dry-run-default recipes)")
|
||||
run_parser.add_argument("--force", action="store_true", help="Force execution despite missing deps")
|
||||
run_parser.add_argument("--consent", action="store_true", help="Acknowledge consent requirements")
|
||||
run_parser.add_argument("--skip-checks", action="store_true", help="Skip dependency checks")
|
||||
run_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
run_parser.set_defaults(func=cmd_run)
|
||||
|
||||
# Explain command
|
||||
explain_parser = recipes_subparsers.add_parser("explain", help="Explain recipe execution plan")
|
||||
explain_parser.add_argument("name", help="Recipe name")
|
||||
explain_parser.set_defaults(func=cmd_explain)
|
||||
|
||||
# Init command
|
||||
init_parser = recipes_subparsers.add_parser("init", help="Initialize recipe in directory")
|
||||
init_parser.add_argument("name", help="Recipe name")
|
||||
init_parser.add_argument("--output", "-o", help="Target directory")
|
||||
init_parser.add_argument("--force", action="store_true", help="Overwrite existing")
|
||||
init_parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
|
||||
init_parser.set_defaults(func=cmd_init)
|
||||
|
||||
|
||||
def handle_recipes_command(args: argparse.Namespace) -> int:
|
||||
"""Handle recipes subcommand."""
|
||||
if not hasattr(args, "func"):
|
||||
# No subcommand specified, show help
|
||||
print("Usage: praison recipes <command> [options]")
|
||||
print("\nCommands:")
|
||||
print(" list List available recipes")
|
||||
print(" info Show recipe details")
|
||||
print(" doctor Check recipe dependencies")
|
||||
print(" run Run a recipe")
|
||||
print(" explain Explain recipe execution plan")
|
||||
print(" init Initialize recipe in directory")
|
||||
return 0
|
||||
|
||||
return args.func(args)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
Registry CLI Feature Handler
|
||||
|
||||
Provides CLI commands for recipe registry management:
|
||||
- serve: Start local HTTP registry server
|
||||
- status: Check registry status
|
||||
|
||||
All commands use the canonical `praisonai registry` prefix.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class RegistryHandler:
|
||||
"""
|
||||
CLI handler for registry operations.
|
||||
|
||||
Commands:
|
||||
- serve: Start local HTTP registry server
|
||||
- status: Check registry server status
|
||||
"""
|
||||
|
||||
# Stable exit codes
|
||||
EXIT_SUCCESS = 0
|
||||
EXIT_GENERAL_ERROR = 1
|
||||
EXIT_VALIDATION_ERROR = 2
|
||||
EXIT_NETWORK_ERROR = 10
|
||||
EXIT_AUTH_ERROR = 9
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the handler."""
|
||||
pass
|
||||
|
||||
def handle(self, args: List[str]) -> int:
|
||||
"""
|
||||
Handle registry subcommand.
|
||||
|
||||
Args:
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
Exit code
|
||||
"""
|
||||
if not args:
|
||||
self._print_help()
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
command = args[0]
|
||||
remaining = args[1:]
|
||||
|
||||
commands = {
|
||||
"serve": self.cmd_serve,
|
||||
"status": self.cmd_status,
|
||||
"help": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
"--help": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
"-h": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
}
|
||||
|
||||
handler = commands.get(command)
|
||||
if handler:
|
||||
return handler(remaining)
|
||||
|
||||
self._print_error(f"Unknown command: {command}")
|
||||
self._print_help()
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
def _print_help(self):
|
||||
"""Print help message."""
|
||||
print("""
|
||||
PraisonAI Registry Commands
|
||||
|
||||
Usage: praisonai registry <command> [options]
|
||||
|
||||
Commands:
|
||||
serve Start local HTTP registry server
|
||||
status Check registry server status
|
||||
|
||||
Examples:
|
||||
praisonai registry serve
|
||||
praisonai registry serve --port 7777 --token mysecret
|
||||
praisonai registry serve --read-only
|
||||
praisonai registry status --registry http://localhost:7777
|
||||
|
||||
Options for 'serve':
|
||||
--host HOST Host to bind to (default: 127.0.0.1)
|
||||
--port PORT Port to bind to (default: 7777)
|
||||
--dir PATH Registry directory (default: ~/.praison/registry)
|
||||
--token TOKEN Require token for write operations
|
||||
--read-only Disable all write operations
|
||||
--json Output in JSON format
|
||||
|
||||
Options for 'status':
|
||||
--registry URL Registry URL to check (default: http://localhost:7777)
|
||||
--json Output in JSON format
|
||||
""")
|
||||
|
||||
def _print_error(self, message: str):
|
||||
"""Print error message."""
|
||||
print(f"Error: {message}", file=sys.stderr)
|
||||
|
||||
def _print_success(self, message: str):
|
||||
"""Print success message."""
|
||||
print(f"✓ {message}")
|
||||
|
||||
def _print_json(self, data: Any):
|
||||
"""Print JSON output."""
|
||||
import json
|
||||
print(json.dumps(data, indent=2))
|
||||
|
||||
def _parse_args(self, args: List[str], spec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Parse command arguments based on spec."""
|
||||
result = {k: v.get("default") for k, v in spec.items()}
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
|
||||
if arg.startswith("--"):
|
||||
key = arg[2:].replace("-", "_")
|
||||
if key in spec:
|
||||
if spec[key].get("flag"):
|
||||
result[key] = True
|
||||
elif i + 1 < len(args):
|
||||
result[key] = args[i + 1]
|
||||
i += 1
|
||||
elif arg.startswith("-") and len(arg) == 2:
|
||||
# Short flag
|
||||
for key, val in spec.items():
|
||||
if val.get("short") == arg:
|
||||
if val.get("flag"):
|
||||
result[key] = True
|
||||
elif i + 1 < len(args):
|
||||
result[key] = args[i + 1]
|
||||
i += 1
|
||||
break
|
||||
i += 1
|
||||
|
||||
return result
|
||||
|
||||
def cmd_serve(self, args: List[str]) -> int:
|
||||
"""Start local HTTP registry server."""
|
||||
spec = {
|
||||
"host": {"default": "127.0.0.1"},
|
||||
"port": {"default": "7777"},
|
||||
"dir": {"default": None},
|
||||
"token": {"default": None},
|
||||
"read_only": {"flag": True, "default": False},
|
||||
"json": {"flag": True, "default": False},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
try:
|
||||
from praisonai.recipe.server import run_server
|
||||
from praisonai.recipe.registry import DEFAULT_REGISTRY_PATH
|
||||
|
||||
host = parsed["host"]
|
||||
port = int(parsed["port"])
|
||||
registry_path = Path(parsed["dir"]) if parsed["dir"] else DEFAULT_REGISTRY_PATH
|
||||
token = parsed["token"] or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
|
||||
read_only = parsed["read_only"]
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json({
|
||||
"ok": True,
|
||||
"message": "Starting registry server",
|
||||
"host": host,
|
||||
"port": port,
|
||||
"registry_path": str(registry_path),
|
||||
"read_only": read_only,
|
||||
"auth_required": bool(token),
|
||||
})
|
||||
|
||||
run_server(
|
||||
host=host,
|
||||
port=port,
|
||||
registry_path=registry_path,
|
||||
token=token,
|
||||
read_only=read_only,
|
||||
)
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nServer stopped.")
|
||||
return self.EXIT_SUCCESS
|
||||
except Exception as e:
|
||||
self._print_error(str(e))
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
|
||||
def cmd_status(self, args: List[str]) -> int:
|
||||
"""Check registry server status."""
|
||||
spec = {
|
||||
"registry": {"default": "http://localhost:7777"},
|
||||
"json": {"flag": True, "default": False},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
try:
|
||||
from praisonai.recipe.registry import HttpRegistry
|
||||
|
||||
registry = HttpRegistry(parsed["registry"])
|
||||
health = registry.health()
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json(health)
|
||||
else:
|
||||
status = "healthy" if health.get("ok") else "unhealthy"
|
||||
print(f"Registry: {parsed['registry']}")
|
||||
print(f"Status: {status}")
|
||||
if health.get("read_only"):
|
||||
print("Mode: read-only")
|
||||
if health.get("auth_required"):
|
||||
print("Auth: required for writes")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
except Exception as e:
|
||||
if parsed["json"]:
|
||||
self._print_json({
|
||||
"ok": False,
|
||||
"error": str(e),
|
||||
"registry": parsed["registry"],
|
||||
})
|
||||
else:
|
||||
self._print_error(f"Cannot connect to registry: {e}")
|
||||
return self.EXIT_NETWORK_ERROR
|
||||
@@ -0,0 +1,829 @@
|
||||
"""
|
||||
Serve CLI Feature Handler
|
||||
|
||||
Provides CLI commands for launching PraisonAI servers:
|
||||
- praisonai serve agents - Launch agents as HTTP API
|
||||
- praisonai serve recipe - Launch recipe runner
|
||||
- praisonai serve mcp - Launch MCP server
|
||||
- praisonai serve tools - Launch tools as MCP server
|
||||
- praisonai serve a2a - Launch A2A server
|
||||
- praisonai serve a2u - Launch A2U event stream server
|
||||
- praisonai serve unified - Launch unified server with all providers
|
||||
|
||||
All servers include the unified discovery endpoint at /__praisonai__/discovery.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class ServeHandler:
|
||||
"""
|
||||
CLI handler for serve operations.
|
||||
|
||||
Commands:
|
||||
- agents: Launch agents as HTTP API
|
||||
- recipe: Launch recipe runner
|
||||
- mcp: Launch MCP server
|
||||
- tools: Launch tools as MCP server
|
||||
- a2a: Launch A2A server
|
||||
- a2u: Launch A2U event stream server
|
||||
- unified: Launch unified server
|
||||
"""
|
||||
|
||||
EXIT_SUCCESS = 0
|
||||
EXIT_GENERAL_ERROR = 1
|
||||
EXIT_VALIDATION_ERROR = 2
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8765
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the handler."""
|
||||
pass
|
||||
|
||||
def handle(self, args: List[str]) -> int:
|
||||
"""
|
||||
Handle serve subcommand.
|
||||
|
||||
Args:
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
Exit code
|
||||
"""
|
||||
if not args:
|
||||
self._print_help()
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
command = args[0]
|
||||
remaining = args[1:]
|
||||
|
||||
commands = {
|
||||
"agents": self.cmd_agents,
|
||||
"recipe": self.cmd_recipe,
|
||||
"mcp": self.cmd_mcp,
|
||||
"tools": self.cmd_tools,
|
||||
"a2a": self.cmd_a2a,
|
||||
"a2u": self.cmd_a2u,
|
||||
"unified": self.cmd_unified,
|
||||
"help": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
"--help": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
"-h": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
}
|
||||
|
||||
if command in commands:
|
||||
return commands[command](remaining)
|
||||
else:
|
||||
self._print_error(f"Unknown command: {command}")
|
||||
self._print_help()
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
|
||||
def _print_help(self):
|
||||
"""Print help message."""
|
||||
help_text = """
|
||||
[bold cyan]PraisonAI Serve[/bold cyan]
|
||||
|
||||
Launch PraisonAI servers with unified discovery support.
|
||||
|
||||
[bold]Usage:[/bold]
|
||||
praisonai serve <command> [options]
|
||||
|
||||
[bold]Commands:[/bold]
|
||||
agents Launch agents as HTTP API
|
||||
recipe Launch recipe runner server
|
||||
mcp Launch MCP server (HTTP mode)
|
||||
tools Launch tools as MCP server
|
||||
a2a Launch A2A protocol server
|
||||
a2u Launch A2U event stream server
|
||||
unified Launch unified server with all providers
|
||||
|
||||
[bold]Common Options:[/bold]
|
||||
--host <host> Server host (default: 127.0.0.1)
|
||||
--port <port> Server port (default: 8765)
|
||||
--reload Enable hot reload
|
||||
--api-key <key> API key for authentication
|
||||
|
||||
[bold]Agents Options:[/bold]
|
||||
--file <path> Agents YAML file (default: agents.yaml)
|
||||
--path <path> API endpoint path (default: /agents)
|
||||
|
||||
[bold]Recipe Options:[/bold]
|
||||
--config <path> Config file path (serve.yaml)
|
||||
--preload Preload all recipes
|
||||
|
||||
[bold]MCP Options:[/bold]
|
||||
--transport Transport type: http, sse (default: http)
|
||||
|
||||
[bold]Examples:[/bold]
|
||||
praisonai serve agents --file agents.yaml --port 8000
|
||||
praisonai serve recipe --port 8765
|
||||
praisonai serve mcp --transport http --port 8080
|
||||
praisonai serve tools --port 8081
|
||||
praisonai serve a2a --port 8082
|
||||
praisonai serve unified --port 8765
|
||||
|
||||
[bold]Discovery:[/bold]
|
||||
All servers expose /__praisonai__/discovery for unified endpoint discovery.
|
||||
Use `praisonai endpoints` to interact with any server.
|
||||
"""
|
||||
self._print_rich(help_text)
|
||||
|
||||
def _print_rich(self, text: str):
|
||||
"""Print with rich formatting if available."""
|
||||
try:
|
||||
from rich import print as rprint
|
||||
rprint(text)
|
||||
except ImportError:
|
||||
import re
|
||||
plain = re.sub(r'\[/?[^\]]+\]', '', text)
|
||||
print(plain)
|
||||
|
||||
def _print_error(self, message: str):
|
||||
"""Print error message."""
|
||||
try:
|
||||
from rich import print as rprint
|
||||
rprint(f"[red]Error: {message}[/red]")
|
||||
except ImportError:
|
||||
print(f"Error: {message}", file=sys.stderr)
|
||||
|
||||
def _print_success(self, message: str):
|
||||
"""Print success message."""
|
||||
try:
|
||||
from rich import print as rprint
|
||||
rprint(f"[green]✓ {message}[/green]")
|
||||
except ImportError:
|
||||
print(f"✓ {message}")
|
||||
|
||||
def _parse_args(self, args: List[str], spec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Parse command arguments based on spec."""
|
||||
result = {k: v.get("default") for k, v in spec.items()}
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
|
||||
if arg.startswith("--"):
|
||||
key = arg[2:].replace("-", "_")
|
||||
if key in spec:
|
||||
if spec[key].get("flag"):
|
||||
result[key] = True
|
||||
elif i + 1 < len(args):
|
||||
value = args[i + 1]
|
||||
# Type conversion
|
||||
if spec[key].get("type") == "int":
|
||||
value = int(value)
|
||||
result[key] = value
|
||||
i += 1
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return result
|
||||
|
||||
def cmd_agents(self, args: List[str]) -> int:
|
||||
"""Launch agents as HTTP API."""
|
||||
spec = {
|
||||
"file": {"default": "agents.yaml"},
|
||||
"host": {"default": self.DEFAULT_HOST},
|
||||
"port": {"default": self.DEFAULT_PORT, "type": "int"},
|
||||
"path": {"default": "/agents"},
|
||||
"reload": {"flag": True, "default": False},
|
||||
"api_key": {"default": None},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
# Check if agents file exists
|
||||
if not os.path.exists(parsed["file"]):
|
||||
self._print_error(f"Agents file not found: {parsed['file']}")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
try:
|
||||
self._print_success(f"Starting agents server on {parsed['host']}:{parsed['port']}")
|
||||
print(f" Agents file: {parsed['file']}")
|
||||
print(f" Endpoint: {parsed['path']}")
|
||||
print(" Discovery: /__praisonai__/discovery")
|
||||
|
||||
# Create and run server
|
||||
app = self._create_agents_app(parsed)
|
||||
self._run_server(app, parsed["host"], parsed["port"], parsed["reload"])
|
||||
|
||||
except ImportError as e:
|
||||
self._print_error(f"Missing dependency: {e}")
|
||||
print("Install with: pip install praisonai[serve]")
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
except Exception as e:
|
||||
self._print_error(str(e))
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def _create_agents_app(self, config: Dict[str, Any]) -> Any:
|
||||
"""Create FastAPI app for agents."""
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from praisonai.endpoints.discovery import (
|
||||
create_discovery_document,
|
||||
EndpointInfo,
|
||||
ProviderInfo,
|
||||
)
|
||||
from praisonai.endpoints.server import add_discovery_routes
|
||||
|
||||
# Load agents from YAML
|
||||
import yaml
|
||||
with open(config["file"]) as f:
|
||||
_ = yaml.safe_load(f) # Validate YAML
|
||||
|
||||
# Create discovery document
|
||||
discovery = create_discovery_document(server_name="praisonai-agents")
|
||||
discovery.add_provider(ProviderInfo(
|
||||
type="agents-api",
|
||||
name="Agents API",
|
||||
description="Agent HTTP API endpoints",
|
||||
capabilities=["invoke", "health"],
|
||||
))
|
||||
|
||||
# Create app
|
||||
app = FastAPI(
|
||||
title="PraisonAI Agents API",
|
||||
description="HTTP API for PraisonAI Agents",
|
||||
)
|
||||
|
||||
# Add discovery routes
|
||||
add_discovery_routes(app, discovery)
|
||||
|
||||
# Request model
|
||||
class AgentQuery(BaseModel):
|
||||
query: str
|
||||
|
||||
# Create endpoint for agents
|
||||
path = config["path"]
|
||||
|
||||
@app.post(path)
|
||||
async def invoke_agents(request: Request, query_data: AgentQuery = None):
|
||||
"""Invoke agents with a query."""
|
||||
if query_data is None:
|
||||
try:
|
||||
body = await request.json()
|
||||
_ = body.get("query", "") # Extract query
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid request")
|
||||
|
||||
try:
|
||||
# Lazy load and run agents
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
|
||||
generator = AgentsGenerator(
|
||||
agent_file=config["file"],
|
||||
framework="praisonai",
|
||||
)
|
||||
result = generator.generate_crew_and_kickoff()
|
||||
|
||||
return {"response": result}
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{"error": str(e)},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Add endpoint to discovery
|
||||
discovery.add_endpoint(EndpointInfo(
|
||||
name=path.lstrip("/"),
|
||||
description="Invoke agents workflow",
|
||||
provider_type="agents-api",
|
||||
input_schema={"type": "object", "properties": {"query": {"type": "string"}}},
|
||||
streaming=["none"],
|
||||
))
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": "PraisonAI Agents API",
|
||||
"endpoints": [path],
|
||||
"discovery": "/__praisonai__/discovery",
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
def cmd_recipe(self, args: List[str]) -> int:
|
||||
"""Launch recipe runner server."""
|
||||
spec = {
|
||||
"host": {"default": self.DEFAULT_HOST},
|
||||
"port": {"default": self.DEFAULT_PORT, "type": "int"},
|
||||
"config": {"default": None},
|
||||
"reload": {"flag": True, "default": False},
|
||||
"api_key": {"default": None},
|
||||
"preload": {"flag": True, "default": False},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
try:
|
||||
self._print_success(f"Starting recipe server on {parsed['host']}:{parsed['port']}")
|
||||
print(" Discovery: /__praisonai__/discovery")
|
||||
|
||||
from praisonai.recipe.serve import serve, load_config
|
||||
|
||||
# Load config
|
||||
config = load_config(parsed["config"]) if parsed["config"] else {}
|
||||
if parsed["api_key"]:
|
||||
config["api_key"] = parsed["api_key"]
|
||||
|
||||
serve(
|
||||
host=parsed["host"],
|
||||
port=parsed["port"],
|
||||
reload=parsed["reload"],
|
||||
config=config,
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
self._print_error(f"Missing dependency: {e}")
|
||||
print("Install with: pip install praisonai[serve]")
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
except Exception as e:
|
||||
self._print_error(str(e))
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def cmd_mcp(self, args: List[str]) -> int:
|
||||
"""Launch MCP server (DEPRECATED - use 'praisonai mcp serve' instead)."""
|
||||
import sys
|
||||
|
||||
# Print deprecation warning
|
||||
print("\n[yellow]⚠ DEPRECATION WARNING:[/yellow]", file=sys.stderr)
|
||||
print("[yellow]'praisonai serve mcp' is deprecated and will be removed in a future version.[/yellow]", file=sys.stderr)
|
||||
print("[yellow]Please use 'praisonai mcp serve' instead.[/yellow]\n", file=sys.stderr)
|
||||
|
||||
# Redirect to new MCP server CLI
|
||||
try:
|
||||
from praisonai.mcp_server.cli import handle_mcp_command
|
||||
|
||||
# Convert args to new format
|
||||
new_args = ["serve"]
|
||||
spec = {
|
||||
"host": {"default": self.DEFAULT_HOST},
|
||||
"port": {"default": 8080, "type": "int"},
|
||||
"transport": {"default": "http-stream"},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
new_args.extend(["--host", parsed["host"]])
|
||||
new_args.extend(["--port", str(parsed["port"])])
|
||||
new_args.extend(["--transport", parsed["transport"]])
|
||||
|
||||
return handle_mcp_command(new_args)
|
||||
|
||||
except ImportError as e:
|
||||
self._print_error(f"Missing dependency: {e}")
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
except Exception as e:
|
||||
self._print_error(str(e))
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
|
||||
def _create_mcp_app(self, config: Dict[str, Any]) -> Any:
|
||||
"""Create FastAPI app for MCP server."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
from praisonai.endpoints.discovery import (
|
||||
create_discovery_document,
|
||||
ProviderInfo,
|
||||
)
|
||||
from praisonai.endpoints.server import add_discovery_routes
|
||||
|
||||
# Create discovery document
|
||||
discovery = create_discovery_document(server_name="praisonai-mcp")
|
||||
discovery.add_provider(ProviderInfo(
|
||||
type="mcp",
|
||||
name="MCP Server",
|
||||
description=f"MCP server ({config['transport']} transport)",
|
||||
capabilities=["list-tools", "call-tool"],
|
||||
))
|
||||
|
||||
app = FastAPI(
|
||||
title="PraisonAI MCP Server",
|
||||
description="MCP protocol server",
|
||||
)
|
||||
|
||||
add_discovery_routes(app, discovery)
|
||||
|
||||
# MCP tools endpoint
|
||||
@app.get("/mcp/tools")
|
||||
async def list_tools():
|
||||
"""List available MCP tools."""
|
||||
# TODO: Load tools from config or registry
|
||||
return {"tools": []}
|
||||
|
||||
@app.post("/mcp/tools/call")
|
||||
async def call_tool(request_data: dict):
|
||||
"""Call an MCP tool."""
|
||||
tool_name = request_data.get("tool")
|
||||
_ = request_data.get("arguments", {}) # Arguments for tool
|
||||
|
||||
# TODO: Execute tool
|
||||
return {"result": None, "tool": tool_name}
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": "PraisonAI MCP Server",
|
||||
"transport": config["transport"],
|
||||
"discovery": "/__praisonai__/discovery",
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
def cmd_tools(self, args: List[str]) -> int:
|
||||
"""Launch tools as MCP server (DEPRECATED - use 'praisonai mcp serve' instead)."""
|
||||
import sys
|
||||
|
||||
# Print deprecation warning
|
||||
print("\n[yellow]⚠ DEPRECATION WARNING:[/yellow]", file=sys.stderr)
|
||||
print("[yellow]'praisonai serve tools' is deprecated and will be removed in a future version.[/yellow]", file=sys.stderr)
|
||||
print("[yellow]Please use 'praisonai mcp serve' instead.[/yellow]\n", file=sys.stderr)
|
||||
|
||||
# Redirect to new MCP server CLI
|
||||
try:
|
||||
from praisonai.mcp_server.cli import handle_mcp_command
|
||||
|
||||
# Convert args to new format
|
||||
new_args = ["serve"]
|
||||
spec = {
|
||||
"host": {"default": self.DEFAULT_HOST},
|
||||
"port": {"default": 8080, "type": "int"},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
new_args.extend(["--host", parsed["host"]])
|
||||
new_args.extend(["--port", str(parsed["port"])])
|
||||
new_args.extend(["--transport", "http-stream"])
|
||||
|
||||
return handle_mcp_command(new_args)
|
||||
|
||||
except ImportError as e:
|
||||
self._print_error(f"Missing dependency: {e}")
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
except Exception as e:
|
||||
self._print_error(str(e))
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
|
||||
def _create_tools_app(self, config: Dict[str, Any]) -> Any:
|
||||
"""Create FastAPI app for tools MCP server."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
from praisonai.endpoints.discovery import (
|
||||
create_discovery_document,
|
||||
ProviderInfo,
|
||||
)
|
||||
from praisonai.endpoints.server import add_discovery_routes
|
||||
|
||||
discovery = create_discovery_document(server_name="praisonai-tools-mcp")
|
||||
discovery.add_provider(ProviderInfo(
|
||||
type="tools-mcp",
|
||||
name="Tools MCP Server",
|
||||
description="Python tools exposed as MCP server",
|
||||
capabilities=["list-tools", "call-tool"],
|
||||
))
|
||||
|
||||
app = FastAPI(
|
||||
title="PraisonAI Tools MCP Server",
|
||||
description="Tools exposed as MCP server",
|
||||
)
|
||||
|
||||
add_discovery_routes(app, discovery)
|
||||
|
||||
@app.get("/tools")
|
||||
async def list_tools():
|
||||
"""List available tools."""
|
||||
return {"tools": []}
|
||||
|
||||
@app.post("/tools/call")
|
||||
async def call_tool(request_data: dict):
|
||||
"""Call a tool."""
|
||||
return {"result": None}
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": "PraisonAI Tools MCP Server",
|
||||
"discovery": "/__praisonai__/discovery",
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
def cmd_a2a(self, args: List[str]) -> int:
|
||||
"""Launch A2A protocol server."""
|
||||
spec = {
|
||||
"host": {"default": self.DEFAULT_HOST},
|
||||
"port": {"default": 8082, "type": "int"},
|
||||
"file": {"default": "agents.yaml"},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
try:
|
||||
self._print_success(f"Starting A2A server on {parsed['host']}:{parsed['port']}")
|
||||
print(" Agent Card: /.well-known/agent.json")
|
||||
print(" Discovery: /__praisonai__/discovery")
|
||||
|
||||
app = self._create_a2a_app(parsed)
|
||||
self._run_server(app, parsed["host"], parsed["port"], False)
|
||||
|
||||
except ImportError as e:
|
||||
self._print_error(f"Missing dependency: {e}")
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
except Exception as e:
|
||||
self._print_error(str(e))
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def _create_a2a_app(self, config: Dict[str, Any]) -> Any:
|
||||
"""Create FastAPI app for A2A server."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
from praisonai.endpoints.discovery import (
|
||||
create_discovery_document,
|
||||
EndpointInfo,
|
||||
ProviderInfo,
|
||||
)
|
||||
from praisonai.endpoints.server import add_discovery_routes
|
||||
|
||||
discovery = create_discovery_document(server_name="praisonai-a2a")
|
||||
discovery.add_provider(ProviderInfo(
|
||||
type="a2a",
|
||||
name="A2A Protocol",
|
||||
description="Agent-to-Agent communication",
|
||||
capabilities=["agent-card", "message-send"],
|
||||
))
|
||||
|
||||
app = FastAPI(
|
||||
title="PraisonAI A2A Server",
|
||||
description="A2A protocol server",
|
||||
)
|
||||
|
||||
add_discovery_routes(app, discovery)
|
||||
|
||||
# Agent card
|
||||
@app.get("/.well-known/agent.json")
|
||||
async def agent_card():
|
||||
"""Return agent card for A2A discovery."""
|
||||
return {
|
||||
"name": "PraisonAI Agent",
|
||||
"description": "PraisonAI Agent via A2A protocol",
|
||||
"version": "1.0.0",
|
||||
"url": f"http://{config.get('host', 'localhost')}:{config.get('port', 8082)}/a2a",
|
||||
"capabilities": {
|
||||
"streaming": True,
|
||||
"pushNotifications": False,
|
||||
},
|
||||
"skills": [],
|
||||
}
|
||||
|
||||
@app.get("/status")
|
||||
async def status():
|
||||
"""Return server status."""
|
||||
return {"status": "ok", "name": "PraisonAI A2A", "version": "1.0.0"}
|
||||
|
||||
@app.post("/a2a")
|
||||
async def a2a_endpoint(request_data: dict):
|
||||
"""Handle A2A JSON-RPC requests."""
|
||||
method = request_data.get("method")
|
||||
params = request_data.get("params", {})
|
||||
request_id = request_data.get("id")
|
||||
|
||||
if method == "message/send":
|
||||
# Handle message
|
||||
message = params.get("message", {})
|
||||
parts = message.get("parts", [])
|
||||
text = ""
|
||||
for part in parts:
|
||||
if part.get("type") == "text":
|
||||
text += part.get("text", "")
|
||||
|
||||
# TODO: Process with agent
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"parts": [{"type": "text", "text": f"Received: {text}"}],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {"code": -32601, "message": f"Method not found: {method}"},
|
||||
}
|
||||
|
||||
discovery.add_endpoint(EndpointInfo(
|
||||
name="a2a",
|
||||
description="A2A protocol endpoint",
|
||||
provider_type="a2a",
|
||||
streaming=["sse"],
|
||||
))
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": "PraisonAI A2A Server",
|
||||
"agent_card": "/.well-known/agent.json",
|
||||
"discovery": "/__praisonai__/discovery",
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
def cmd_a2u(self, args: List[str]) -> int:
|
||||
"""Launch A2U event stream server."""
|
||||
spec = {
|
||||
"host": {"default": self.DEFAULT_HOST},
|
||||
"port": {"default": 8083, "type": "int"},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
try:
|
||||
self._print_success(f"Starting A2U server on {parsed['host']}:{parsed['port']}")
|
||||
print(" Events: /a2u/events/<stream>")
|
||||
print(" Discovery: /__praisonai__/discovery")
|
||||
|
||||
app = self._create_a2u_app(parsed)
|
||||
self._run_server(app, parsed["host"], parsed["port"], False)
|
||||
|
||||
except ImportError as e:
|
||||
self._print_error(f"Missing dependency: {e}")
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
except Exception as e:
|
||||
self._print_error(str(e))
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def _create_a2u_app(self, config: Dict[str, Any]) -> Any:
|
||||
"""Create FastAPI app for A2U server."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
from praisonai.endpoints.discovery import (
|
||||
create_discovery_document,
|
||||
EndpointInfo,
|
||||
ProviderInfo,
|
||||
)
|
||||
from praisonai.endpoints.server import add_discovery_routes
|
||||
from praisonai.endpoints.a2u_server import create_a2u_routes
|
||||
|
||||
discovery = create_discovery_document(server_name="praisonai-a2u")
|
||||
discovery.add_provider(ProviderInfo(
|
||||
type="a2u",
|
||||
name="A2U Event Stream",
|
||||
description="Agent-to-User event streaming",
|
||||
capabilities=["subscribe", "stream"],
|
||||
))
|
||||
|
||||
app = FastAPI(
|
||||
title="PraisonAI A2U Server",
|
||||
description="A2U event stream server",
|
||||
)
|
||||
|
||||
add_discovery_routes(app, discovery)
|
||||
create_a2u_routes(app)
|
||||
|
||||
discovery.add_endpoint(EndpointInfo(
|
||||
name="events",
|
||||
description="Agent event stream",
|
||||
provider_type="a2u",
|
||||
streaming=["sse"],
|
||||
))
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": "PraisonAI A2U Server",
|
||||
"events": "/a2u/events/<stream>",
|
||||
"discovery": "/__praisonai__/discovery",
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
def cmd_unified(self, args: List[str]) -> int:
|
||||
"""Launch unified server with all providers."""
|
||||
spec = {
|
||||
"host": {"default": self.DEFAULT_HOST},
|
||||
"port": {"default": self.DEFAULT_PORT, "type": "int"},
|
||||
"file": {"default": "agents.yaml"},
|
||||
"reload": {"flag": True, "default": False},
|
||||
"api_key": {"default": None},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
try:
|
||||
self._print_success(f"Starting unified server on {parsed['host']}:{parsed['port']}")
|
||||
print(" Providers: agents-api, recipe, mcp, a2a, a2u")
|
||||
print(" Discovery: /__praisonai__/discovery")
|
||||
|
||||
app = self._create_unified_app(parsed)
|
||||
self._run_server(app, parsed["host"], parsed["port"], parsed["reload"])
|
||||
|
||||
except ImportError as e:
|
||||
self._print_error(f"Missing dependency: {e}")
|
||||
print("Install with: pip install praisonai[serve]")
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
except Exception as e:
|
||||
self._print_error(str(e))
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def _create_unified_app(self, config: Dict[str, Any]) -> Any:
|
||||
"""Create unified FastAPI app with all providers."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
from praisonai.endpoints.discovery import (
|
||||
create_discovery_document,
|
||||
EndpointInfo,
|
||||
ProviderInfo,
|
||||
)
|
||||
from praisonai.endpoints.server import add_discovery_routes
|
||||
from praisonai.endpoints.a2u_server import create_a2u_routes
|
||||
|
||||
discovery = create_discovery_document(server_name="praisonai-unified")
|
||||
|
||||
# Add all providers
|
||||
discovery.add_provider(ProviderInfo(
|
||||
type="agents-api",
|
||||
name="Agents API",
|
||||
capabilities=["invoke", "health"],
|
||||
))
|
||||
discovery.add_provider(ProviderInfo(
|
||||
type="recipe",
|
||||
name="Recipe Runner",
|
||||
capabilities=["list", "describe", "invoke", "stream"],
|
||||
))
|
||||
discovery.add_provider(ProviderInfo(
|
||||
type="mcp",
|
||||
name="MCP Server",
|
||||
capabilities=["list-tools", "call-tool"],
|
||||
))
|
||||
discovery.add_provider(ProviderInfo(
|
||||
type="a2a",
|
||||
name="A2A Protocol",
|
||||
capabilities=["agent-card", "message-send"],
|
||||
))
|
||||
discovery.add_provider(ProviderInfo(
|
||||
type="a2u",
|
||||
name="A2U Event Stream",
|
||||
capabilities=["subscribe", "stream"],
|
||||
))
|
||||
|
||||
app = FastAPI(
|
||||
title="PraisonAI Unified Server",
|
||||
description="Unified server with all PraisonAI providers",
|
||||
)
|
||||
|
||||
add_discovery_routes(app, discovery)
|
||||
create_a2u_routes(app)
|
||||
|
||||
# Add sample endpoints
|
||||
discovery.add_endpoint(EndpointInfo(
|
||||
name="agents",
|
||||
description="Agents workflow endpoint",
|
||||
provider_type="agents-api",
|
||||
))
|
||||
discovery.add_endpoint(EndpointInfo(
|
||||
name="events",
|
||||
description="Agent event stream",
|
||||
provider_type="a2u",
|
||||
streaming=["sse"],
|
||||
))
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": "PraisonAI Unified Server",
|
||||
"providers": ["agents-api", "recipe", "mcp", "a2a", "a2u"],
|
||||
"discovery": "/__praisonai__/discovery",
|
||||
}
|
||||
|
||||
@app.get("/.well-known/agent.json")
|
||||
async def agent_card():
|
||||
return {
|
||||
"name": "PraisonAI Unified",
|
||||
"description": "PraisonAI Unified Server",
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
def _run_server(self, app: Any, host: str, port: int, reload: bool) -> None:
|
||||
"""Run the server with uvicorn."""
|
||||
import uvicorn
|
||||
uvicorn.run(app, host=host, port=port, reload=reload)
|
||||
|
||||
|
||||
def handle_serve_command(args: List[str]) -> int:
|
||||
"""Entry point for serve command."""
|
||||
handler = ServeHandler()
|
||||
return handler.handle(args)
|
||||
@@ -698,7 +698,7 @@ class PraisonAI:
|
||||
return default_args
|
||||
|
||||
# Define special commands
|
||||
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor']
|
||||
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor', 'registry', 'package', 'install', 'uninstall']
|
||||
|
||||
parser = argparse.ArgumentParser(prog="praisonai", description="praisonAI command-line interface")
|
||||
parser.add_argument("--framework", choices=["crewai", "autogen", "praisonai"], help="Specify the framework")
|
||||
@@ -1193,11 +1193,17 @@ class PraisonAI:
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif args.command == 'endpoints':
|
||||
# Endpoints command - client CLI for recipe endpoints
|
||||
# Endpoints command - unified client CLI for all server types
|
||||
from .features.endpoints import handle_endpoints_command
|
||||
exit_code = handle_endpoints_command(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif args.command == 'serve':
|
||||
# Serve command - launch PraisonAI servers
|
||||
from .features.serve import handle_serve_command
|
||||
exit_code = handle_serve_command(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif args.command == 'agents':
|
||||
# Agents command - run multiple agents with custom definitions
|
||||
if not PRAISONAI_AVAILABLE:
|
||||
@@ -1377,6 +1383,34 @@ class PraisonAI:
|
||||
handler = DoctorHandler()
|
||||
exit_code = handler.run(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif args.command == 'registry':
|
||||
# Registry command - manage recipe registry server
|
||||
from .features.registry import RegistryHandler
|
||||
handler = RegistryHandler()
|
||||
exit_code = handler.handle(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif args.command == 'package':
|
||||
# Package command - pip-like package management
|
||||
from .features.package import PackageHandler
|
||||
handler = PackageHandler()
|
||||
exit_code = handler.handle(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif args.command == 'install':
|
||||
# Install command - shortcut for package install
|
||||
from .features.package import PackageHandler
|
||||
handler = PackageHandler()
|
||||
exit_code = handler.cmd_install(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif args.command == 'uninstall':
|
||||
# Uninstall command - shortcut for package uninstall
|
||||
from .features.package import PackageHandler
|
||||
handler = PackageHandler()
|
||||
exit_code = handler.cmd_uninstall(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# Only check framework availability for agent-related operations
|
||||
if not args.command and (args.init or args.auto or args.framework):
|
||||
@@ -2904,8 +2938,32 @@ class PraisonAI:
|
||||
else:
|
||||
print(f"[red]MCP config not found: {config_name}[/red]")
|
||||
|
||||
elif action == 'serve':
|
||||
# Start MCP server
|
||||
from praisonai.mcp_server.cli import handle_mcp_command as mcp_serve_handler
|
||||
exit_code = mcp_serve_handler(['serve'] + action_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif action == 'list-tools':
|
||||
# List MCP tools
|
||||
from praisonai.mcp_server.cli import handle_mcp_command as mcp_serve_handler
|
||||
exit_code = mcp_serve_handler(['list-tools'] + action_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif action == 'config-generate':
|
||||
# Generate client config
|
||||
from praisonai.mcp_server.cli import handle_mcp_command as mcp_serve_handler
|
||||
exit_code = mcp_serve_handler(['config-generate'] + action_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif action == 'help' or action == '--help':
|
||||
print("[bold]MCP Commands:[/bold]")
|
||||
print("\n[bold cyan]Server Commands:[/bold cyan]")
|
||||
print(" praisonai mcp serve - Start MCP server (STDIO)")
|
||||
print(" praisonai mcp serve --transport http-stream - Start HTTP Stream server")
|
||||
print(" praisonai mcp list-tools - List available MCP tools")
|
||||
print(" praisonai mcp config-generate --client claude-desktop - Generate client config")
|
||||
print("\n[bold cyan]Config Management:[/bold cyan]")
|
||||
print(" praisonai mcp list - List all MCP configs")
|
||||
print(" praisonai mcp show <name> - Show specific config")
|
||||
print(" praisonai mcp create <name> <cmd> [args...] - Create a new config")
|
||||
@@ -2914,7 +2972,9 @@ class PraisonAI:
|
||||
print(" praisonai mcp disable <name> - Disable a config")
|
||||
print("\n[bold]Config Location:[/bold]")
|
||||
print(" .praison/mcp/*.json, ~/.praison/mcp/*.json")
|
||||
print("\n[bold]Example:[/bold]")
|
||||
print("\n[bold]Examples:[/bold]")
|
||||
print(" praisonai mcp serve --transport stdio")
|
||||
print(" praisonai mcp serve --transport http-stream --port 8080")
|
||||
print(" praisonai mcp create filesystem npx -y @modelcontextprotocol/server-filesystem .")
|
||||
else:
|
||||
print(f"[red]Unknown mcp action: {action}[/red]")
|
||||
|
||||
@@ -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==2.7.3 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==2.7.7 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Unified Endpoints Module
|
||||
|
||||
Provides unified discovery, provider adapters, and server utilities
|
||||
for all PraisonAI serve features.
|
||||
|
||||
Provider Types:
|
||||
- recipe: Recipe runner endpoints
|
||||
- agents-api: Single/multi-agent HTTP API
|
||||
- mcp: MCP server (stdio, http, sse)
|
||||
- tools-mcp: Tools exposed as MCP server
|
||||
- a2a: Agent-to-agent protocol
|
||||
- a2u: Agent-to-user event stream
|
||||
|
||||
Architecture:
|
||||
- Discovery schema is versioned and consistent across all providers
|
||||
- Provider adapters abstract protocol differences
|
||||
- CLI client uses unified discovery for list/describe/invoke/health
|
||||
"""
|
||||
|
||||
# Lazy loading for all exports
|
||||
_lazy_imports = {
|
||||
"DiscoveryDocument": ".discovery",
|
||||
"EndpointInfo": ".discovery",
|
||||
"ProviderInfo": ".discovery",
|
||||
"SCHEMA_VERSION": ".discovery",
|
||||
"create_discovery_document": ".discovery",
|
||||
"BaseProvider": ".providers.base",
|
||||
"RecipeProvider": ".providers.recipe",
|
||||
"AgentsAPIProvider": ".providers.agents_api",
|
||||
"MCPProvider": ".providers.mcp",
|
||||
"ToolsMCPProvider": ".providers.tools_mcp",
|
||||
"A2AProvider": ".providers.a2a",
|
||||
"A2UProvider": ".providers.a2u",
|
||||
"ProviderRegistry": ".registry",
|
||||
"get_provider": ".registry",
|
||||
"register_provider": ".registry",
|
||||
"list_provider_types": ".registry",
|
||||
"get_provider_class": ".registry",
|
||||
"create_unified_app": ".server",
|
||||
"add_discovery_routes": ".server",
|
||||
}
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name in _lazy_imports:
|
||||
module_path = _lazy_imports[name]
|
||||
import importlib
|
||||
module = importlib.import_module(module_path, package=__name__)
|
||||
return getattr(module, name)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
def __dir__():
|
||||
return list(_lazy_imports.keys())
|
||||
@@ -0,0 +1,410 @@
|
||||
"""
|
||||
A2U (Agent-to-User) Event Stream Server
|
||||
|
||||
Provides SSE-based event streaming for agent-to-user communication.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class A2UEvent:
|
||||
"""An event in the A2U stream."""
|
||||
event_type: str
|
||||
data: Dict[str, Any]
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
event_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
||||
|
||||
def to_sse(self) -> str:
|
||||
"""Convert to SSE format."""
|
||||
return f"event: {self.event_type}\ndata: {json.dumps(self.data)}\nid: {self.event_id}\n\n"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"event_type": self.event_type,
|
||||
"data": self.data,
|
||||
"timestamp": self.timestamp,
|
||||
"event_id": self.event_id,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class A2USubscription:
|
||||
"""A subscription to an A2U event stream."""
|
||||
subscription_id: str
|
||||
stream_name: str
|
||||
filters: List[str] = field(default_factory=list)
|
||||
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
def matches_event(self, event: A2UEvent) -> bool:
|
||||
"""Check if event matches subscription filters."""
|
||||
if not self.filters:
|
||||
return True
|
||||
return event.event_type in self.filters
|
||||
|
||||
|
||||
class A2UEventBus:
|
||||
"""
|
||||
Event bus for A2U event distribution.
|
||||
|
||||
Manages subscriptions and broadcasts events to subscribers.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the event bus."""
|
||||
self._subscriptions: Dict[str, A2USubscription] = {}
|
||||
self._queues: Dict[str, asyncio.Queue] = {}
|
||||
self._streams: Dict[str, Set[str]] = {} # stream_name -> subscription_ids
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
stream_name: str = "events",
|
||||
filters: Optional[List[str]] = None,
|
||||
) -> A2USubscription:
|
||||
"""
|
||||
Subscribe to an event stream.
|
||||
|
||||
Args:
|
||||
stream_name: Name of the stream to subscribe to
|
||||
filters: Optional list of event types to filter
|
||||
|
||||
Returns:
|
||||
A2USubscription object
|
||||
"""
|
||||
subscription_id = f"sub-{uuid.uuid4().hex[:12]}"
|
||||
subscription = A2USubscription(
|
||||
subscription_id=subscription_id,
|
||||
stream_name=stream_name,
|
||||
filters=filters or [],
|
||||
)
|
||||
|
||||
self._subscriptions[subscription_id] = subscription
|
||||
self._queues[subscription_id] = asyncio.Queue()
|
||||
|
||||
if stream_name not in self._streams:
|
||||
self._streams[stream_name] = set()
|
||||
self._streams[stream_name].add(subscription_id)
|
||||
|
||||
logger.debug(f"Created subscription {subscription_id} for stream {stream_name}")
|
||||
return subscription
|
||||
|
||||
def unsubscribe(self, subscription_id: str) -> bool:
|
||||
"""
|
||||
Unsubscribe from an event stream.
|
||||
|
||||
Args:
|
||||
subscription_id: ID of the subscription to remove
|
||||
|
||||
Returns:
|
||||
True if unsubscribed, False if not found
|
||||
"""
|
||||
if subscription_id not in self._subscriptions:
|
||||
return False
|
||||
|
||||
subscription = self._subscriptions[subscription_id]
|
||||
|
||||
# Remove from stream set
|
||||
if subscription.stream_name in self._streams:
|
||||
self._streams[subscription.stream_name].discard(subscription_id)
|
||||
|
||||
# Clean up
|
||||
del self._subscriptions[subscription_id]
|
||||
del self._queues[subscription_id]
|
||||
|
||||
logger.debug(f"Removed subscription {subscription_id}")
|
||||
return True
|
||||
|
||||
async def publish(self, event: A2UEvent, stream_name: str = "events") -> int:
|
||||
"""
|
||||
Publish an event to a stream.
|
||||
|
||||
Args:
|
||||
event: Event to publish
|
||||
stream_name: Name of the stream
|
||||
|
||||
Returns:
|
||||
Number of subscribers that received the event
|
||||
"""
|
||||
if stream_name not in self._streams:
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
for sub_id in self._streams[stream_name]:
|
||||
subscription = self._subscriptions.get(sub_id)
|
||||
if subscription and subscription.matches_event(event):
|
||||
await self._queues[sub_id].put(event)
|
||||
count += 1
|
||||
|
||||
logger.debug(f"Published event {event.event_type} to {count} subscribers")
|
||||
return count
|
||||
|
||||
def publish_sync(self, event: A2UEvent, stream_name: str = "events") -> int:
|
||||
"""
|
||||
Synchronously publish an event (creates event loop if needed).
|
||||
|
||||
Args:
|
||||
event: Event to publish
|
||||
stream_name: Name of the stream
|
||||
|
||||
Returns:
|
||||
Number of subscribers that received the event
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Schedule in running loop
|
||||
asyncio.ensure_future(self.publish(event, stream_name))
|
||||
return len(self._streams.get(stream_name, set()))
|
||||
else:
|
||||
return loop.run_until_complete(self.publish(event, stream_name))
|
||||
except RuntimeError:
|
||||
# No event loop, create one
|
||||
return asyncio.run(self.publish(event, stream_name))
|
||||
|
||||
async def get_events(
|
||||
self,
|
||||
subscription_id: str,
|
||||
timeout: float = 30.0,
|
||||
):
|
||||
"""
|
||||
Async generator for subscription events.
|
||||
|
||||
Args:
|
||||
subscription_id: ID of the subscription
|
||||
timeout: Timeout for waiting for events
|
||||
|
||||
Yields:
|
||||
A2UEvent objects
|
||||
"""
|
||||
if subscription_id not in self._queues:
|
||||
return
|
||||
|
||||
queue = self._queues[subscription_id]
|
||||
|
||||
while True:
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=timeout)
|
||||
yield event
|
||||
except asyncio.TimeoutError:
|
||||
# Send keepalive
|
||||
yield A2UEvent(event_type="keepalive", data={})
|
||||
|
||||
|
||||
# Global event bus instance
|
||||
_event_bus: Optional[A2UEventBus] = None
|
||||
|
||||
|
||||
def get_event_bus() -> A2UEventBus:
|
||||
"""Get or create the global event bus."""
|
||||
global _event_bus
|
||||
if _event_bus is None:
|
||||
_event_bus = A2UEventBus()
|
||||
return _event_bus
|
||||
|
||||
|
||||
def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None:
|
||||
"""
|
||||
Add A2U routes to a FastAPI/Starlette application.
|
||||
|
||||
Args:
|
||||
app: FastAPI or Starlette application
|
||||
event_bus: Optional event bus (uses global if not provided)
|
||||
"""
|
||||
bus = event_bus or get_event_bus()
|
||||
|
||||
try:
|
||||
from starlette.responses import JSONResponse, StreamingResponse
|
||||
from starlette.routing import Route
|
||||
except ImportError:
|
||||
try:
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
except ImportError:
|
||||
raise ImportError("Starlette or FastAPI required for A2U routes")
|
||||
|
||||
async def a2u_info(request):
|
||||
"""GET /a2u/info - Get A2U server info."""
|
||||
return JSONResponse({
|
||||
"name": "A2U Event Stream",
|
||||
"version": "1.0.0",
|
||||
"streams": list(bus._streams.keys()) or ["events"],
|
||||
"event_types": [
|
||||
"agent.started",
|
||||
"agent.thinking",
|
||||
"agent.tool_call",
|
||||
"agent.response",
|
||||
"agent.completed",
|
||||
"agent.error",
|
||||
],
|
||||
})
|
||||
|
||||
async def a2u_subscribe(request):
|
||||
"""POST /a2u/subscribe - Subscribe to an event stream."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
stream_name = body.get("stream", "events")
|
||||
filters = body.get("filters", [])
|
||||
|
||||
subscription = bus.subscribe(stream_name, filters)
|
||||
|
||||
base_url = str(request.url).rsplit("/", 1)[0]
|
||||
|
||||
return JSONResponse({
|
||||
"subscription_id": subscription.subscription_id,
|
||||
"stream_name": subscription.stream_name,
|
||||
"stream_url": f"{base_url}/events/{subscription.subscription_id}",
|
||||
"created_at": subscription.created_at,
|
||||
})
|
||||
|
||||
async def a2u_unsubscribe(request):
|
||||
"""POST /a2u/unsubscribe - Unsubscribe from an event stream."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
|
||||
subscription_id = body.get("subscription_id")
|
||||
if not subscription_id:
|
||||
return JSONResponse({"error": "subscription_id required"}, status_code=400)
|
||||
|
||||
if bus.unsubscribe(subscription_id):
|
||||
return JSONResponse({"status": "unsubscribed"})
|
||||
else:
|
||||
return JSONResponse({"error": "Subscription not found"}, status_code=404)
|
||||
|
||||
async def a2u_events_stream(request):
|
||||
"""GET /a2u/events/{stream_name} - Stream events via SSE."""
|
||||
stream_name = request.path_params.get("stream_name", "events")
|
||||
|
||||
# Create subscription for this stream
|
||||
subscription = bus.subscribe(stream_name)
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
async for event in bus.get_events(subscription.subscription_id):
|
||||
yield event.to_sse()
|
||||
finally:
|
||||
bus.unsubscribe(subscription.subscription_id)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
async def a2u_events_subscription(request):
|
||||
"""GET /a2u/events/sub/{subscription_id} - Stream events for subscription."""
|
||||
subscription_id = request.path_params.get("subscription_id")
|
||||
|
||||
if subscription_id not in bus._subscriptions:
|
||||
return JSONResponse({"error": "Subscription not found"}, status_code=404)
|
||||
|
||||
async def event_generator():
|
||||
async for event in bus.get_events(subscription_id):
|
||||
yield event.to_sse()
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
async def a2u_health(request):
|
||||
"""GET /a2u/health - A2U health check."""
|
||||
return JSONResponse({
|
||||
"status": "healthy",
|
||||
"active_subscriptions": len(bus._subscriptions),
|
||||
"active_streams": len(bus._streams),
|
||||
})
|
||||
|
||||
# Add routes based on app type
|
||||
if hasattr(app, 'add_api_route'):
|
||||
# FastAPI
|
||||
app.add_api_route("/a2u/info", a2u_info, methods=["GET"])
|
||||
app.add_api_route("/a2u/subscribe", a2u_subscribe, methods=["POST"])
|
||||
app.add_api_route("/a2u/unsubscribe", a2u_unsubscribe, methods=["POST"])
|
||||
app.add_api_route("/a2u/events/{stream_name}", a2u_events_stream, methods=["GET"])
|
||||
app.add_api_route("/a2u/events/sub/{subscription_id}", a2u_events_subscription, methods=["GET"])
|
||||
app.add_api_route("/a2u/health", a2u_health, methods=["GET"])
|
||||
elif hasattr(app, 'routes'):
|
||||
# Starlette
|
||||
app.routes.extend([
|
||||
Route("/a2u/info", a2u_info, methods=["GET"]),
|
||||
Route("/a2u/subscribe", a2u_subscribe, methods=["POST"]),
|
||||
Route("/a2u/unsubscribe", a2u_unsubscribe, methods=["POST"]),
|
||||
Route("/a2u/events/{stream_name}", a2u_events_stream, methods=["GET"]),
|
||||
Route("/a2u/events/sub/{subscription_id}", a2u_events_subscription, methods=["GET"]),
|
||||
Route("/a2u/health", a2u_health, methods=["GET"]),
|
||||
])
|
||||
|
||||
|
||||
# Helper functions for publishing events
|
||||
def emit_agent_event(
|
||||
event_type: str,
|
||||
data: Dict[str, Any],
|
||||
agent_id: Optional[str] = None,
|
||||
stream_name: str = "events",
|
||||
) -> None:
|
||||
"""
|
||||
Emit an agent event to the A2U stream.
|
||||
|
||||
Args:
|
||||
event_type: Type of event (e.g., "agent.started", "agent.response")
|
||||
data: Event data
|
||||
agent_id: Optional agent ID to include
|
||||
stream_name: Stream to publish to
|
||||
"""
|
||||
if agent_id:
|
||||
data["agent_id"] = agent_id
|
||||
|
||||
event = A2UEvent(event_type=event_type, data=data)
|
||||
get_event_bus().publish_sync(event, stream_name)
|
||||
|
||||
|
||||
def emit_agent_started(agent_id: str, agent_name: str, **kwargs) -> None:
|
||||
"""Emit agent.started event."""
|
||||
emit_agent_event("agent.started", {"agent_name": agent_name, **kwargs}, agent_id)
|
||||
|
||||
|
||||
def emit_agent_thinking(agent_id: str, message: str = "", **kwargs) -> None:
|
||||
"""Emit agent.thinking event."""
|
||||
emit_agent_event("agent.thinking", {"message": message, **kwargs}, agent_id)
|
||||
|
||||
|
||||
def emit_agent_tool_call(agent_id: str, tool_name: str, arguments: Dict = None, **kwargs) -> None:
|
||||
"""Emit agent.tool_call event."""
|
||||
emit_agent_event("agent.tool_call", {"tool_name": tool_name, "arguments": arguments or {}, **kwargs}, agent_id)
|
||||
|
||||
|
||||
def emit_agent_response(agent_id: str, response: str, **kwargs) -> None:
|
||||
"""Emit agent.response event."""
|
||||
emit_agent_event("agent.response", {"response": response, **kwargs}, agent_id)
|
||||
|
||||
|
||||
def emit_agent_completed(agent_id: str, result: Any = None, **kwargs) -> None:
|
||||
"""Emit agent.completed event."""
|
||||
emit_agent_event("agent.completed", {"result": result, **kwargs}, agent_id)
|
||||
|
||||
|
||||
def emit_agent_error(agent_id: str, error: str, **kwargs) -> None:
|
||||
"""Emit agent.error event."""
|
||||
emit_agent_event("agent.error", {"error": error, **kwargs}, agent_id)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Unified Discovery Schema
|
||||
|
||||
Provides versioned discovery document format for all PraisonAI serve features.
|
||||
This schema is consistent across all provider types.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# Schema version - increment on breaking changes
|
||||
SCHEMA_VERSION = "1.0.0"
|
||||
|
||||
|
||||
class ProviderType(str, Enum):
|
||||
"""Supported provider types."""
|
||||
RECIPE = "recipe"
|
||||
AGENTS_API = "agents-api"
|
||||
MCP = "mcp"
|
||||
TOOLS_MCP = "tools-mcp"
|
||||
A2A = "a2a"
|
||||
A2U = "a2u"
|
||||
|
||||
|
||||
class StreamingMode(str, Enum):
|
||||
"""Supported streaming modes."""
|
||||
NONE = "none"
|
||||
SSE = "sse"
|
||||
WEBSOCKET = "websocket"
|
||||
MCP_STREAM = "mcp-stream"
|
||||
|
||||
|
||||
class AuthMode(str, Enum):
|
||||
"""Supported authentication modes."""
|
||||
NONE = "none"
|
||||
API_KEY = "api-key"
|
||||
BEARER = "bearer"
|
||||
JWT = "jwt"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EndpointInfo:
|
||||
"""Information about a single endpoint."""
|
||||
name: str
|
||||
description: str = ""
|
||||
provider_type: str = "recipe"
|
||||
tags: List[str] = field(default_factory=list)
|
||||
version: str = "1.0.0"
|
||||
|
||||
# Schema information
|
||||
input_schema: Optional[Dict[str, Any]] = None
|
||||
output_schema: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Capabilities
|
||||
streaming: List[str] = field(default_factory=lambda: ["none"])
|
||||
auth_modes: List[str] = field(default_factory=lambda: ["none"])
|
||||
|
||||
# Metadata
|
||||
examples: List[Dict[str, Any]] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderInfo:
|
||||
"""Information about a provider."""
|
||||
type: str
|
||||
name: str
|
||||
version: str = "1.0.0"
|
||||
description: str = ""
|
||||
capabilities: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveryDocument:
|
||||
"""
|
||||
Unified discovery document for PraisonAI servers.
|
||||
|
||||
This document is served at /__praisonai__/discovery and provides
|
||||
a consistent interface for discovering endpoints across all provider types.
|
||||
"""
|
||||
schema_version: str = SCHEMA_VERSION
|
||||
server_name: str = "praisonai"
|
||||
server_version: str = "1.0.0"
|
||||
|
||||
# Provider information
|
||||
providers: List[ProviderInfo] = field(default_factory=list)
|
||||
|
||||
# Endpoint information
|
||||
endpoints: List[EndpointInfo] = field(default_factory=list)
|
||||
|
||||
# Server capabilities
|
||||
auth_modes: List[str] = field(default_factory=lambda: ["none"])
|
||||
streaming_modes: List[str] = field(default_factory=lambda: ["none", "sse"])
|
||||
|
||||
# Metadata
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"server_name": self.server_name,
|
||||
"server_version": self.server_version,
|
||||
"providers": [p.to_dict() for p in self.providers],
|
||||
"endpoints": [e.to_dict() for e in self.endpoints],
|
||||
"auth_modes": self.auth_modes,
|
||||
"streaming_modes": self.streaming_modes,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
def add_provider(self, provider: ProviderInfo) -> None:
|
||||
"""Add a provider to the discovery document."""
|
||||
self.providers.append(provider)
|
||||
|
||||
def add_endpoint(self, endpoint: EndpointInfo) -> None:
|
||||
"""Add an endpoint to the discovery document."""
|
||||
self.endpoints.append(endpoint)
|
||||
|
||||
def get_endpoints_by_type(self, provider_type: str) -> List[EndpointInfo]:
|
||||
"""Get endpoints filtered by provider type."""
|
||||
return [e for e in self.endpoints if e.provider_type == provider_type]
|
||||
|
||||
def get_endpoint_by_name(self, name: str) -> Optional[EndpointInfo]:
|
||||
"""Get an endpoint by name."""
|
||||
for e in self.endpoints:
|
||||
if e.name == name:
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
def create_discovery_document(
|
||||
server_name: str = "praisonai",
|
||||
server_version: Optional[str] = None,
|
||||
) -> DiscoveryDocument:
|
||||
"""
|
||||
Create a new discovery document.
|
||||
|
||||
Args:
|
||||
server_name: Name of the server
|
||||
server_version: Version of the server (auto-detected if not provided)
|
||||
|
||||
Returns:
|
||||
DiscoveryDocument instance
|
||||
"""
|
||||
if server_version is None:
|
||||
try:
|
||||
from praisonai.version import __version__
|
||||
server_version = __version__
|
||||
except ImportError:
|
||||
server_version = "unknown"
|
||||
|
||||
return DiscoveryDocument(
|
||||
server_name=server_name,
|
||||
server_version=server_version,
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Provider Adapters
|
||||
|
||||
Each provider adapter implements the BaseProvider interface
|
||||
and handles protocol-specific details for discovery and invocation.
|
||||
"""
|
||||
|
||||
# Lazy loading for all exports
|
||||
_lazy_imports = {
|
||||
"BaseProvider": ".base",
|
||||
"RecipeProvider": ".recipe",
|
||||
"AgentsAPIProvider": ".agents_api",
|
||||
"MCPProvider": ".mcp",
|
||||
"ToolsMCPProvider": ".tools_mcp",
|
||||
"A2AProvider": ".a2a",
|
||||
"A2UProvider": ".a2u",
|
||||
}
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name in _lazy_imports:
|
||||
module_path = _lazy_imports[name]
|
||||
import importlib
|
||||
module = importlib.import_module(module_path, package=__name__)
|
||||
return getattr(module, name)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
def __dir__():
|
||||
return list(_lazy_imports.keys())
|
||||
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
A2A Provider
|
||||
|
||||
Provider adapter for Agent-to-Agent protocol endpoints.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
from .base import BaseProvider, InvokeResult, HealthResult
|
||||
from ..discovery import EndpointInfo, ProviderInfo
|
||||
|
||||
|
||||
class A2AProvider(BaseProvider):
|
||||
"""
|
||||
Provider adapter for A2A (Agent-to-Agent) protocol endpoints.
|
||||
|
||||
A2A enables agent-to-agent communication following the A2A protocol spec.
|
||||
Supports discovery via /.well-known/agent.json and message passing.
|
||||
"""
|
||||
|
||||
provider_type = "a2a"
|
||||
|
||||
def get_provider_info(self) -> ProviderInfo:
|
||||
"""Get provider information."""
|
||||
return ProviderInfo(
|
||||
type=self.provider_type,
|
||||
name="A2A Protocol",
|
||||
description="Agent-to-Agent communication protocol",
|
||||
capabilities=["agent-card", "message-send", "task-management"],
|
||||
)
|
||||
|
||||
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
|
||||
"""List available A2A agents."""
|
||||
# Try unified discovery first
|
||||
result = self._make_request("GET", "/__praisonai__/discovery")
|
||||
|
||||
if not result.get("error") and result.get("data"):
|
||||
data = result.get("data", {})
|
||||
endpoints = []
|
||||
for ep in data.get("endpoints", []):
|
||||
if ep.get("provider_type") == self.provider_type:
|
||||
endpoints.append(EndpointInfo(
|
||||
name=ep.get("name", ""),
|
||||
description=ep.get("description", ""),
|
||||
provider_type=self.provider_type,
|
||||
tags=ep.get("tags", []),
|
||||
streaming=ep.get("streaming", ["sse"]),
|
||||
auth_modes=ep.get("auth_modes", ["none"]),
|
||||
))
|
||||
return endpoints
|
||||
|
||||
# Fallback: try agent card
|
||||
result = self._make_request("GET", "/.well-known/agent.json")
|
||||
|
||||
if result.get("error"):
|
||||
return []
|
||||
|
||||
agent_card = result.get("data", {})
|
||||
|
||||
return [EndpointInfo(
|
||||
name=agent_card.get("name", "a2a-agent"),
|
||||
description=agent_card.get("description", "A2A Agent"),
|
||||
provider_type=self.provider_type,
|
||||
version=agent_card.get("version", "1.0.0"),
|
||||
streaming=["sse"] if agent_card.get("capabilities", {}).get("streaming") else ["none"],
|
||||
auth_modes=["none"],
|
||||
metadata={"agent_card": agent_card},
|
||||
)]
|
||||
|
||||
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
|
||||
"""Get detailed information about an A2A agent."""
|
||||
# Get agent card
|
||||
result = self._make_request("GET", "/.well-known/agent.json")
|
||||
|
||||
if result.get("error"):
|
||||
return None
|
||||
|
||||
agent_card = result.get("data", {})
|
||||
|
||||
return EndpointInfo(
|
||||
name=agent_card.get("name", name),
|
||||
description=agent_card.get("description", ""),
|
||||
provider_type=self.provider_type,
|
||||
version=agent_card.get("version", "1.0.0"),
|
||||
streaming=["sse"] if agent_card.get("capabilities", {}).get("streaming") else ["none"],
|
||||
auth_modes=["none"],
|
||||
metadata={
|
||||
"agent_card": agent_card,
|
||||
"skills": agent_card.get("skills", []),
|
||||
"capabilities": agent_card.get("capabilities", {}),
|
||||
},
|
||||
)
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
name: str,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
stream: bool = False,
|
||||
) -> InvokeResult:
|
||||
"""Send a message to an A2A agent."""
|
||||
import uuid
|
||||
|
||||
# Build A2A message
|
||||
message = input_data.get("message", "") if input_data else ""
|
||||
if not message and config:
|
||||
message = config.get("message", config.get("query", ""))
|
||||
|
||||
body = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": str(uuid.uuid4()),
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [{"type": "text", "text": message}],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result = self._make_request("POST", "/a2a", json_data=body)
|
||||
|
||||
if result.get("error"):
|
||||
return InvokeResult(
|
||||
ok=False,
|
||||
status="error",
|
||||
error=result["error"].get("message", str(result["error"])),
|
||||
)
|
||||
|
||||
data = result.get("data", {})
|
||||
|
||||
# Handle JSON-RPC response
|
||||
if "result" in data:
|
||||
return InvokeResult(
|
||||
ok=True,
|
||||
status="success",
|
||||
data=data["result"],
|
||||
)
|
||||
elif "error" in data:
|
||||
return InvokeResult(
|
||||
ok=False,
|
||||
status="error",
|
||||
error=data["error"].get("message", str(data["error"])),
|
||||
)
|
||||
|
||||
return InvokeResult(
|
||||
ok=True,
|
||||
status="success",
|
||||
data=data,
|
||||
)
|
||||
|
||||
def invoke_stream(
|
||||
self,
|
||||
name: str,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
"""Send a streaming message to an A2A agent."""
|
||||
import json
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
message = input_data.get("message", "") if input_data else ""
|
||||
if not message and config:
|
||||
message = config.get("message", config.get("query", ""))
|
||||
|
||||
body = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/stream",
|
||||
"id": str(uuid.uuid4()),
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [{"type": "text", "text": message}],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
full_url = f"{self.base_url}/a2a"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
}
|
||||
if self.api_key:
|
||||
headers["X-API-Key"] = self.api_key
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(full_url, data=data, headers=headers, method="POST")
|
||||
|
||||
with urllib.request.urlopen(req, timeout=300) as response:
|
||||
buffer = ""
|
||||
for line in response:
|
||||
line = line.decode("utf-8")
|
||||
buffer += line
|
||||
|
||||
if buffer.endswith("\n\n"):
|
||||
event_type = "message"
|
||||
event_data = ""
|
||||
|
||||
for part in buffer.strip().split("\n"):
|
||||
if part.startswith("event:"):
|
||||
event_type = part[6:].strip()
|
||||
elif part.startswith("data:"):
|
||||
event_data = part[5:].strip()
|
||||
|
||||
if event_data:
|
||||
try:
|
||||
yield {"event": event_type, "data": json.loads(event_data)}
|
||||
except json.JSONDecodeError:
|
||||
yield {"event": event_type, "data": event_data}
|
||||
|
||||
buffer = ""
|
||||
|
||||
except Exception as e:
|
||||
yield {"event": "error", "data": {"error": str(e)}}
|
||||
|
||||
def health(self) -> HealthResult:
|
||||
"""Check A2A server health."""
|
||||
# Try status endpoint first
|
||||
result = self._make_request("GET", "/status")
|
||||
|
||||
if not result.get("error"):
|
||||
data = result.get("data", {})
|
||||
return HealthResult(
|
||||
healthy=data.get("status") == "ok",
|
||||
status=data.get("status", "unknown"),
|
||||
server_name=data.get("name", "A2A Agent"),
|
||||
server_version=data.get("version"),
|
||||
provider_type=self.provider_type,
|
||||
)
|
||||
|
||||
# Fallback: try agent card
|
||||
result = self._make_request("GET", "/.well-known/agent.json")
|
||||
|
||||
if result.get("error"):
|
||||
return HealthResult(
|
||||
healthy=False,
|
||||
status="unhealthy",
|
||||
provider_type=self.provider_type,
|
||||
metadata={"error": result["error"].get("message", str(result["error"]))},
|
||||
)
|
||||
|
||||
agent_card = result.get("data", {})
|
||||
return HealthResult(
|
||||
healthy=True,
|
||||
status="healthy",
|
||||
server_name=agent_card.get("name", "A2A Agent"),
|
||||
server_version=agent_card.get("version"),
|
||||
provider_type=self.provider_type,
|
||||
)
|
||||
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
A2U Provider
|
||||
|
||||
Provider adapter for Agent-to-User event stream endpoints.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
from .base import BaseProvider, InvokeResult, HealthResult
|
||||
from ..discovery import EndpointInfo, ProviderInfo
|
||||
|
||||
|
||||
class A2UProvider(BaseProvider):
|
||||
"""
|
||||
Provider adapter for A2U (Agent-to-User) event stream endpoints.
|
||||
|
||||
A2U provides a protocol surface for streaming agent events to users,
|
||||
typically via SSE or WebSocket. This enables real-time UI updates.
|
||||
"""
|
||||
|
||||
provider_type = "a2u"
|
||||
|
||||
def get_provider_info(self) -> ProviderInfo:
|
||||
"""Get provider information."""
|
||||
return ProviderInfo(
|
||||
type=self.provider_type,
|
||||
name="A2U Event Stream",
|
||||
description="Agent-to-User event streaming protocol",
|
||||
capabilities=["event-stream", "subscribe", "unsubscribe"],
|
||||
)
|
||||
|
||||
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
|
||||
"""List available A2U event streams."""
|
||||
# Try unified discovery first
|
||||
result = self._make_request("GET", "/__praisonai__/discovery")
|
||||
|
||||
if not result.get("error") and result.get("data"):
|
||||
data = result.get("data", {})
|
||||
endpoints = []
|
||||
for ep in data.get("endpoints", []):
|
||||
if ep.get("provider_type") == self.provider_type:
|
||||
endpoints.append(EndpointInfo(
|
||||
name=ep.get("name", ""),
|
||||
description=ep.get("description", ""),
|
||||
provider_type=self.provider_type,
|
||||
tags=ep.get("tags", []),
|
||||
streaming=ep.get("streaming", ["sse"]),
|
||||
auth_modes=ep.get("auth_modes", ["none"]),
|
||||
))
|
||||
return endpoints
|
||||
|
||||
# Fallback: check for events endpoint
|
||||
result = self._make_request("GET", "/a2u/info")
|
||||
|
||||
if result.get("error"):
|
||||
# Return default event stream endpoint
|
||||
return [EndpointInfo(
|
||||
name="events",
|
||||
description="Agent event stream",
|
||||
provider_type=self.provider_type,
|
||||
streaming=["sse"],
|
||||
auth_modes=["none"],
|
||||
)]
|
||||
|
||||
data = result.get("data", {})
|
||||
streams = data.get("streams", [{"name": "events", "description": "Agent event stream"}])
|
||||
|
||||
endpoints = []
|
||||
for stream in streams:
|
||||
endpoints.append(EndpointInfo(
|
||||
name=stream.get("name", "events"),
|
||||
description=stream.get("description", "Event stream"),
|
||||
provider_type=self.provider_type,
|
||||
streaming=["sse", "websocket"],
|
||||
auth_modes=["none"],
|
||||
))
|
||||
|
||||
return endpoints
|
||||
|
||||
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
|
||||
"""Get detailed information about an A2U event stream."""
|
||||
endpoints = self.list_endpoints()
|
||||
for ep in endpoints:
|
||||
if ep.name == name:
|
||||
return ep
|
||||
|
||||
# Return default info
|
||||
return EndpointInfo(
|
||||
name=name,
|
||||
description=f"Event stream: {name}",
|
||||
provider_type=self.provider_type,
|
||||
streaming=["sse"],
|
||||
auth_modes=["none"],
|
||||
metadata={
|
||||
"event_types": [
|
||||
"agent.started",
|
||||
"agent.thinking",
|
||||
"agent.tool_call",
|
||||
"agent.response",
|
||||
"agent.completed",
|
||||
"agent.error",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
name: str,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
stream: bool = False,
|
||||
) -> InvokeResult:
|
||||
"""Subscribe to an A2U event stream (returns subscription info)."""
|
||||
body = {
|
||||
"stream": name,
|
||||
"filters": input_data.get("filters", []) if input_data else [],
|
||||
}
|
||||
|
||||
result = self._make_request("POST", "/a2u/subscribe", json_data=body)
|
||||
|
||||
if result.get("error"):
|
||||
return InvokeResult(
|
||||
ok=False,
|
||||
status="error",
|
||||
error=result["error"].get("message", str(result["error"])),
|
||||
)
|
||||
|
||||
data = result.get("data", {})
|
||||
return InvokeResult(
|
||||
ok=True,
|
||||
status="subscribed",
|
||||
data={
|
||||
"subscription_id": data.get("subscription_id"),
|
||||
"stream_url": data.get("stream_url", f"{self.base_url}/a2u/events/{name}"),
|
||||
},
|
||||
)
|
||||
|
||||
def invoke_stream(
|
||||
self,
|
||||
name: str,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
"""Stream events from an A2U endpoint."""
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
stream_path = f"/a2u/events/{name}"
|
||||
full_url = f"{self.base_url}{stream_path}"
|
||||
|
||||
headers = {"Accept": "text/event-stream"}
|
||||
if self.api_key:
|
||||
headers["X-API-Key"] = self.api_key
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(full_url, headers=headers, method="GET")
|
||||
|
||||
with urllib.request.urlopen(req, timeout=300) as response:
|
||||
buffer = ""
|
||||
for line in response:
|
||||
line = line.decode("utf-8")
|
||||
buffer += line
|
||||
|
||||
if buffer.endswith("\n\n"):
|
||||
event_type = "event"
|
||||
event_data = ""
|
||||
|
||||
for part in buffer.strip().split("\n"):
|
||||
if part.startswith("event:"):
|
||||
event_type = part[6:].strip()
|
||||
elif part.startswith("data:"):
|
||||
event_data = part[5:].strip()
|
||||
|
||||
if event_data:
|
||||
try:
|
||||
yield {"event": event_type, "data": json.loads(event_data)}
|
||||
except json.JSONDecodeError:
|
||||
yield {"event": event_type, "data": event_data}
|
||||
|
||||
buffer = ""
|
||||
|
||||
except Exception as e:
|
||||
yield {"event": "error", "data": {"error": str(e)}}
|
||||
|
||||
def health(self) -> HealthResult:
|
||||
"""Check A2U server health."""
|
||||
result = self._make_request("GET", "/health")
|
||||
|
||||
if result.get("error"):
|
||||
# Try a2u-specific health
|
||||
result = self._make_request("GET", "/a2u/health")
|
||||
|
||||
if result.get("error"):
|
||||
return HealthResult(
|
||||
healthy=False,
|
||||
status="unhealthy",
|
||||
provider_type=self.provider_type,
|
||||
metadata={"error": result["error"].get("message", str(result["error"]))},
|
||||
)
|
||||
|
||||
data = result.get("data", {})
|
||||
return HealthResult(
|
||||
healthy=True,
|
||||
status="healthy",
|
||||
server_name=data.get("server_name", "A2U Server"),
|
||||
server_version=data.get("version"),
|
||||
provider_type=self.provider_type,
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Agents API Provider
|
||||
|
||||
Provider adapter for agents-as-API endpoints (single agent or multi-agent router).
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import BaseProvider, InvokeResult, HealthResult
|
||||
from ..discovery import EndpointInfo, ProviderInfo
|
||||
|
||||
|
||||
class AgentsAPIProvider(BaseProvider):
|
||||
"""
|
||||
Provider adapter for agents-as-API endpoints.
|
||||
|
||||
Connects to FastAPI-based agent servers launched via Agent.launch() or Agents.launch()
|
||||
and provides unified discovery and invocation interface.
|
||||
"""
|
||||
|
||||
provider_type = "agents-api"
|
||||
|
||||
def get_provider_info(self) -> ProviderInfo:
|
||||
"""Get provider information."""
|
||||
return ProviderInfo(
|
||||
type=self.provider_type,
|
||||
name="Agents API",
|
||||
description="Agent HTTP API endpoints",
|
||||
capabilities=["list", "describe", "invoke", "health"],
|
||||
)
|
||||
|
||||
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
|
||||
"""List available agent endpoints."""
|
||||
# Try unified discovery first
|
||||
result = self._make_request("GET", "/__praisonai__/discovery")
|
||||
|
||||
if not result.get("error") and result.get("data"):
|
||||
data = result.get("data", {})
|
||||
endpoints = []
|
||||
for ep in data.get("endpoints", []):
|
||||
if ep.get("provider_type") == self.provider_type:
|
||||
endpoints.append(EndpointInfo(
|
||||
name=ep.get("name", ""),
|
||||
description=ep.get("description", ""),
|
||||
provider_type=self.provider_type,
|
||||
tags=ep.get("tags", []),
|
||||
version=ep.get("version", "1.0.0"),
|
||||
streaming=ep.get("streaming", ["none"]),
|
||||
auth_modes=ep.get("auth_modes", ["none"]),
|
||||
))
|
||||
return endpoints
|
||||
|
||||
# Fallback: try root endpoint for endpoint list
|
||||
result = self._make_request("GET", "/")
|
||||
|
||||
if result.get("error"):
|
||||
return []
|
||||
|
||||
data = result.get("data", {})
|
||||
endpoint_paths = data.get("endpoints", [])
|
||||
|
||||
endpoints = []
|
||||
for path in endpoint_paths:
|
||||
endpoints.append(EndpointInfo(
|
||||
name=path.lstrip("/"),
|
||||
description=f"Agent endpoint at {path}",
|
||||
provider_type=self.provider_type,
|
||||
streaming=["none"],
|
||||
auth_modes=["none"],
|
||||
))
|
||||
|
||||
return endpoints
|
||||
|
||||
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
|
||||
"""Get detailed information about an agent endpoint."""
|
||||
# Try unified discovery first
|
||||
result = self._make_request("GET", "/__praisonai__/discovery")
|
||||
|
||||
if not result.get("error") and result.get("data"):
|
||||
data = result.get("data", {})
|
||||
for ep in data.get("endpoints", []):
|
||||
if ep.get("name") == name:
|
||||
return EndpointInfo(
|
||||
name=ep.get("name", name),
|
||||
description=ep.get("description", ""),
|
||||
provider_type=self.provider_type,
|
||||
tags=ep.get("tags", []),
|
||||
version=ep.get("version", "1.0.0"),
|
||||
input_schema=ep.get("input_schema"),
|
||||
output_schema=ep.get("output_schema"),
|
||||
streaming=ep.get("streaming", ["none"]),
|
||||
auth_modes=ep.get("auth_modes", ["none"]),
|
||||
metadata=ep.get("metadata", {}),
|
||||
)
|
||||
|
||||
# Fallback: return basic info
|
||||
return EndpointInfo(
|
||||
name=name,
|
||||
description=f"Agent endpoint: {name}",
|
||||
provider_type=self.provider_type,
|
||||
input_schema={"type": "object", "properties": {"query": {"type": "string"}}},
|
||||
streaming=["none"],
|
||||
auth_modes=["none"],
|
||||
)
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
name: str,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
stream: bool = False,
|
||||
) -> InvokeResult:
|
||||
"""Invoke an agent endpoint."""
|
||||
# Normalize endpoint path
|
||||
path = name if name.startswith("/") else f"/{name}"
|
||||
|
||||
# Build request body
|
||||
body = input_data or {}
|
||||
if "query" not in body and config and "query" in config:
|
||||
body["query"] = config["query"]
|
||||
|
||||
result = self._make_request("POST", path, json_data=body)
|
||||
|
||||
if result.get("status") == 401:
|
||||
return InvokeResult(
|
||||
ok=False,
|
||||
status="auth_error",
|
||||
error="Authentication required",
|
||||
)
|
||||
|
||||
if result.get("status") == 404:
|
||||
return InvokeResult(
|
||||
ok=False,
|
||||
status="not_found",
|
||||
error=f"Endpoint not found: {name}",
|
||||
)
|
||||
|
||||
if result.get("error"):
|
||||
return InvokeResult(
|
||||
ok=False,
|
||||
status="error",
|
||||
error=result["error"].get("message", str(result["error"])),
|
||||
)
|
||||
|
||||
data = result.get("data", {})
|
||||
return InvokeResult(
|
||||
ok=True,
|
||||
status="success",
|
||||
data=data.get("response", data),
|
||||
)
|
||||
|
||||
def health(self) -> HealthResult:
|
||||
"""Check agent server health."""
|
||||
result = self._make_request("GET", "/health")
|
||||
|
||||
if result.get("error"):
|
||||
return HealthResult(
|
||||
healthy=False,
|
||||
status="unhealthy",
|
||||
provider_type=self.provider_type,
|
||||
metadata={"error": result["error"].get("message", str(result["error"]))},
|
||||
)
|
||||
|
||||
data = result.get("data", {})
|
||||
return HealthResult(
|
||||
healthy=data.get("status") == "ok",
|
||||
status=data.get("status", "unknown"),
|
||||
server_name="PraisonAI Agents API",
|
||||
provider_type=self.provider_type,
|
||||
metadata={"endpoints": data.get("endpoints", [])},
|
||||
)
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Base Provider Interface
|
||||
|
||||
Abstract base class for all provider adapters.
|
||||
Each provider implements discovery, invocation, and health check methods.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
from ..discovery import EndpointInfo, ProviderInfo
|
||||
|
||||
|
||||
@dataclass
|
||||
class InvokeResult:
|
||||
"""Result from invoking an endpoint."""
|
||||
ok: bool
|
||||
status: str = "success"
|
||||
data: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"ok": self.ok,
|
||||
"status": self.status,
|
||||
"data": self.data,
|
||||
"error": self.error,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthResult:
|
||||
"""Result from health check."""
|
||||
healthy: bool
|
||||
status: str = "healthy"
|
||||
server_name: Optional[str] = None
|
||||
server_version: Optional[str] = None
|
||||
provider_type: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"healthy": self.healthy,
|
||||
"status": self.status,
|
||||
"server_name": self.server_name,
|
||||
"server_version": self.server_version,
|
||||
"provider_type": self.provider_type,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
class BaseProvider(ABC):
|
||||
"""
|
||||
Abstract base class for provider adapters.
|
||||
|
||||
Each provider type (recipe, agents-api, mcp, etc.) implements this interface
|
||||
to provide consistent discovery and invocation behavior.
|
||||
"""
|
||||
|
||||
# Provider type identifier
|
||||
provider_type: str = "base"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "http://localhost:8765",
|
||||
api_key: Optional[str] = None,
|
||||
timeout: float = 30.0,
|
||||
):
|
||||
"""
|
||||
Initialize the provider.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of the server
|
||||
api_key: Optional API key for authentication
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
|
||||
@abstractmethod
|
||||
def get_provider_info(self) -> ProviderInfo:
|
||||
"""
|
||||
Get provider information.
|
||||
|
||||
Returns:
|
||||
ProviderInfo with provider details
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
|
||||
"""
|
||||
List available endpoints.
|
||||
|
||||
Args:
|
||||
tags: Optional list of tags to filter by
|
||||
|
||||
Returns:
|
||||
List of EndpointInfo objects
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
|
||||
"""
|
||||
Get detailed information about an endpoint.
|
||||
|
||||
Args:
|
||||
name: Endpoint name
|
||||
|
||||
Returns:
|
||||
EndpointInfo or None if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def invoke(
|
||||
self,
|
||||
name: str,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
stream: bool = False,
|
||||
) -> InvokeResult:
|
||||
"""
|
||||
Invoke an endpoint.
|
||||
|
||||
Args:
|
||||
name: Endpoint name
|
||||
input_data: Input data for the endpoint
|
||||
config: Optional configuration overrides
|
||||
stream: Whether to stream the response
|
||||
|
||||
Returns:
|
||||
InvokeResult with response data
|
||||
"""
|
||||
pass
|
||||
|
||||
def invoke_stream(
|
||||
self,
|
||||
name: str,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
"""
|
||||
Invoke an endpoint with streaming.
|
||||
|
||||
Args:
|
||||
name: Endpoint name
|
||||
input_data: Input data for the endpoint
|
||||
config: Optional configuration overrides
|
||||
|
||||
Yields:
|
||||
Stream events as dictionaries
|
||||
"""
|
||||
# Default implementation - subclasses can override
|
||||
result = self.invoke(name, input_data, config, stream=True)
|
||||
if result.ok:
|
||||
yield {"event": "complete", "data": result.data}
|
||||
else:
|
||||
yield {"event": "error", "data": {"error": result.error}}
|
||||
|
||||
@abstractmethod
|
||||
def health(self) -> HealthResult:
|
||||
"""
|
||||
Check server health.
|
||||
|
||||
Returns:
|
||||
HealthResult with health status
|
||||
"""
|
||||
pass
|
||||
|
||||
def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
json_data: Optional[Dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Make HTTP request to server.
|
||||
|
||||
Args:
|
||||
method: HTTP method
|
||||
path: URL path
|
||||
json_data: Optional JSON body
|
||||
|
||||
Returns:
|
||||
Response data dictionary
|
||||
"""
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
full_url = f"{self.base_url}{path}"
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_key:
|
||||
headers["X-API-Key"] = self.api_key
|
||||
|
||||
data = None
|
||||
if json_data:
|
||||
data = json.dumps(json_data).encode("utf-8")
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
full_url,
|
||||
data=data,
|
||||
headers=headers,
|
||||
method=method,
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
return {"status": response.status, "data": json.loads(body) if body else {}}
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8") if e.fp else ""
|
||||
try:
|
||||
error_data = json.loads(body) if body else {}
|
||||
except json.JSONDecodeError:
|
||||
error_data = {"message": body}
|
||||
return {"status": e.code, "error": error_data}
|
||||
except urllib.error.URLError as e:
|
||||
return {"status": 0, "error": {"message": f"Connection error: {e.reason}"}}
|
||||
except Exception as e:
|
||||
return {"status": 0, "error": {"message": str(e)}}
|
||||
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
MCP Provider
|
||||
|
||||
Provider adapter for MCP server endpoints (stdio, http, sse).
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
from .base import BaseProvider, InvokeResult, HealthResult
|
||||
from ..discovery import EndpointInfo, ProviderInfo
|
||||
|
||||
|
||||
class MCPProvider(BaseProvider):
|
||||
"""
|
||||
Provider adapter for MCP server endpoints.
|
||||
|
||||
Supports multiple MCP transport types:
|
||||
- stdio: Standard input/output (requires subprocess)
|
||||
- http: HTTP-based MCP server
|
||||
- sse: Server-Sent Events MCP gateway
|
||||
"""
|
||||
|
||||
provider_type = "mcp"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "http://localhost:8765",
|
||||
api_key: Optional[str] = None,
|
||||
timeout: float = 30.0,
|
||||
transport: str = "http",
|
||||
command: Optional[str] = None,
|
||||
args: Optional[List[str]] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize MCP provider.
|
||||
|
||||
Args:
|
||||
base_url: Base URL for HTTP/SSE transport
|
||||
api_key: Optional API key
|
||||
timeout: Request timeout
|
||||
transport: Transport type (stdio, http, sse)
|
||||
command: Command for stdio transport
|
||||
args: Command arguments for stdio transport
|
||||
env: Environment variables for stdio transport
|
||||
"""
|
||||
super().__init__(base_url, api_key, timeout)
|
||||
self.transport = transport
|
||||
self.command = command
|
||||
self.args = args or []
|
||||
self.env = env or {}
|
||||
|
||||
def get_provider_info(self) -> ProviderInfo:
|
||||
"""Get provider information."""
|
||||
return ProviderInfo(
|
||||
type=self.provider_type,
|
||||
name="MCP Server",
|
||||
description=f"MCP server ({self.transport} transport)",
|
||||
capabilities=["list-tools", "call-tool", "list-resources"],
|
||||
)
|
||||
|
||||
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
|
||||
"""List available MCP tools as endpoints."""
|
||||
if self.transport == "stdio":
|
||||
return self._list_stdio_tools(tags)
|
||||
else:
|
||||
return self._list_http_tools(tags)
|
||||
|
||||
def _list_stdio_tools(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
|
||||
"""List tools from stdio MCP server."""
|
||||
try:
|
||||
from praisonaiagents.mcp import MCP
|
||||
|
||||
mcp = MCP(command=self.command, args=self.args, env=self.env)
|
||||
tools = mcp.get_tools()
|
||||
|
||||
endpoints = []
|
||||
for tool in tools:
|
||||
tool_name = getattr(tool, 'name', str(tool))
|
||||
tool_desc = getattr(tool, 'description', '')
|
||||
|
||||
endpoints.append(EndpointInfo(
|
||||
name=tool_name,
|
||||
description=tool_desc,
|
||||
provider_type=self.provider_type,
|
||||
streaming=["mcp-stream"],
|
||||
auth_modes=["none"],
|
||||
metadata={"transport": "stdio"},
|
||||
))
|
||||
|
||||
return endpoints
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _list_http_tools(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
|
||||
"""List tools from HTTP MCP server."""
|
||||
# Try unified discovery first
|
||||
result = self._make_request("GET", "/__praisonai__/discovery")
|
||||
|
||||
if not result.get("error") and result.get("data"):
|
||||
data = result.get("data", {})
|
||||
endpoints = []
|
||||
for ep in data.get("endpoints", []):
|
||||
if ep.get("provider_type") == self.provider_type:
|
||||
endpoints.append(EndpointInfo(
|
||||
name=ep.get("name", ""),
|
||||
description=ep.get("description", ""),
|
||||
provider_type=self.provider_type,
|
||||
tags=ep.get("tags", []),
|
||||
streaming=ep.get("streaming", ["mcp-stream"]),
|
||||
auth_modes=ep.get("auth_modes", ["none"]),
|
||||
))
|
||||
return endpoints
|
||||
|
||||
# Fallback: try MCP tools endpoint
|
||||
result = self._make_request("GET", "/mcp/tools")
|
||||
|
||||
if result.get("error"):
|
||||
return []
|
||||
|
||||
tools = result.get("data", {}).get("tools", [])
|
||||
endpoints = []
|
||||
|
||||
for tool in tools:
|
||||
endpoints.append(EndpointInfo(
|
||||
name=tool.get("name", ""),
|
||||
description=tool.get("description", ""),
|
||||
provider_type=self.provider_type,
|
||||
input_schema=tool.get("inputSchema"),
|
||||
streaming=["mcp-stream"],
|
||||
auth_modes=["none"],
|
||||
))
|
||||
|
||||
return endpoints
|
||||
|
||||
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
|
||||
"""Get detailed information about an MCP tool."""
|
||||
endpoints = self.list_endpoints()
|
||||
for ep in endpoints:
|
||||
if ep.name == name:
|
||||
return ep
|
||||
return None
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
name: str,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
stream: bool = False,
|
||||
) -> InvokeResult:
|
||||
"""Invoke an MCP tool."""
|
||||
if self.transport == "stdio":
|
||||
return self._invoke_stdio(name, input_data)
|
||||
else:
|
||||
return self._invoke_http(name, input_data)
|
||||
|
||||
def _invoke_stdio(self, name: str, input_data: Optional[Dict[str, Any]] = None) -> InvokeResult:
|
||||
"""Invoke tool via stdio MCP."""
|
||||
try:
|
||||
from praisonaiagents.mcp import MCP
|
||||
|
||||
mcp = MCP(command=self.command, args=self.args, env=self.env)
|
||||
tools = mcp.get_tools()
|
||||
|
||||
for tool in tools:
|
||||
tool_name = getattr(tool, 'name', str(tool))
|
||||
if tool_name == name:
|
||||
if callable(tool):
|
||||
result = tool(**(input_data or {}))
|
||||
return InvokeResult(
|
||||
ok=True,
|
||||
status="success",
|
||||
data=result,
|
||||
)
|
||||
|
||||
return InvokeResult(
|
||||
ok=False,
|
||||
status="not_found",
|
||||
error=f"Tool not found: {name}",
|
||||
)
|
||||
except Exception as e:
|
||||
return InvokeResult(
|
||||
ok=False,
|
||||
status="error",
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def _invoke_http(self, name: str, input_data: Optional[Dict[str, Any]] = None) -> InvokeResult:
|
||||
"""Invoke tool via HTTP MCP."""
|
||||
body = {
|
||||
"tool": name,
|
||||
"arguments": input_data or {},
|
||||
}
|
||||
|
||||
result = self._make_request("POST", "/mcp/tools/call", json_data=body)
|
||||
|
||||
if result.get("error"):
|
||||
return InvokeResult(
|
||||
ok=False,
|
||||
status="error",
|
||||
error=result["error"].get("message", str(result["error"])),
|
||||
)
|
||||
|
||||
data = result.get("data", {})
|
||||
return InvokeResult(
|
||||
ok=True,
|
||||
status="success",
|
||||
data=data.get("result", data),
|
||||
)
|
||||
|
||||
def invoke_stream(
|
||||
self,
|
||||
name: str,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
"""Invoke MCP tool with streaming."""
|
||||
# MCP streaming is tool-specific; for now, wrap sync call
|
||||
result = self.invoke(name, input_data, config)
|
||||
if result.ok:
|
||||
yield {"event": "result", "data": result.data}
|
||||
else:
|
||||
yield {"event": "error", "data": {"error": result.error}}
|
||||
|
||||
def health(self) -> HealthResult:
|
||||
"""Check MCP server health."""
|
||||
if self.transport == "stdio":
|
||||
# For stdio, check if command exists
|
||||
import shutil
|
||||
if self.command and shutil.which(self.command):
|
||||
return HealthResult(
|
||||
healthy=True,
|
||||
status="available",
|
||||
server_name=self.command,
|
||||
provider_type=self.provider_type,
|
||||
metadata={"transport": "stdio"},
|
||||
)
|
||||
return HealthResult(
|
||||
healthy=False,
|
||||
status="unavailable",
|
||||
provider_type=self.provider_type,
|
||||
metadata={"error": f"Command not found: {self.command}"},
|
||||
)
|
||||
|
||||
# HTTP health check
|
||||
result = self._make_request("GET", "/health")
|
||||
|
||||
if result.get("error"):
|
||||
return HealthResult(
|
||||
healthy=False,
|
||||
status="unhealthy",
|
||||
provider_type=self.provider_type,
|
||||
metadata={"error": result["error"].get("message", str(result["error"]))},
|
||||
)
|
||||
|
||||
data = result.get("data", {})
|
||||
return HealthResult(
|
||||
healthy=True,
|
||||
status="healthy",
|
||||
server_name=data.get("server_name", "MCP Server"),
|
||||
server_version=data.get("version"),
|
||||
provider_type=self.provider_type,
|
||||
)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Recipe Provider
|
||||
|
||||
Provider adapter for recipe runner endpoints.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
from .base import BaseProvider, InvokeResult, HealthResult
|
||||
from ..discovery import EndpointInfo, ProviderInfo
|
||||
|
||||
|
||||
class RecipeProvider(BaseProvider):
|
||||
"""
|
||||
Provider adapter for recipe runner endpoints.
|
||||
|
||||
Connects to recipe runner server (Starlette-based) and provides
|
||||
unified discovery and invocation interface.
|
||||
"""
|
||||
|
||||
provider_type = "recipe"
|
||||
|
||||
def get_provider_info(self) -> ProviderInfo:
|
||||
"""Get provider information."""
|
||||
return ProviderInfo(
|
||||
type=self.provider_type,
|
||||
name="Recipe Runner",
|
||||
description="Recipe execution endpoints",
|
||||
capabilities=["list", "describe", "invoke", "stream", "validate"],
|
||||
)
|
||||
|
||||
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
|
||||
"""List available recipe endpoints."""
|
||||
path = "/v1/recipes"
|
||||
if tags:
|
||||
path += f"?tags={','.join(tags)}"
|
||||
|
||||
result = self._make_request("GET", path)
|
||||
|
||||
if result.get("error"):
|
||||
return []
|
||||
|
||||
recipes = result.get("data", {}).get("recipes", [])
|
||||
endpoints = []
|
||||
|
||||
for r in recipes:
|
||||
endpoints.append(EndpointInfo(
|
||||
name=r.get("name", ""),
|
||||
description=r.get("description", ""),
|
||||
provider_type=self.provider_type,
|
||||
tags=r.get("tags", []),
|
||||
version=r.get("version", "1.0.0"),
|
||||
streaming=["none", "sse"],
|
||||
auth_modes=["none", "api-key"],
|
||||
))
|
||||
|
||||
return endpoints
|
||||
|
||||
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
|
||||
"""Get detailed information about a recipe endpoint."""
|
||||
result = self._make_request("GET", f"/v1/recipes/{name}")
|
||||
|
||||
if result.get("status") == 404 or result.get("error"):
|
||||
return None
|
||||
|
||||
data = result.get("data", {})
|
||||
|
||||
# Get schema
|
||||
schema_result = self._make_request("GET", f"/v1/recipes/{name}/schema")
|
||||
schema_data = schema_result.get("data", {}) if not schema_result.get("error") else {}
|
||||
|
||||
return EndpointInfo(
|
||||
name=data.get("name", name),
|
||||
description=data.get("description", ""),
|
||||
provider_type=self.provider_type,
|
||||
tags=data.get("tags", []),
|
||||
version=data.get("version", "1.0.0"),
|
||||
input_schema=schema_data.get("input_schema"),
|
||||
output_schema=schema_data.get("output_schema"),
|
||||
streaming=["none", "sse"],
|
||||
auth_modes=["none", "api-key"],
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
name: str,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
stream: bool = False,
|
||||
) -> InvokeResult:
|
||||
"""Invoke a recipe endpoint."""
|
||||
body = {
|
||||
"recipe": name,
|
||||
"input": input_data or {},
|
||||
"config": config or {},
|
||||
}
|
||||
|
||||
result = self._make_request("POST", "/v1/recipes/run", json_data=body)
|
||||
|
||||
if result.get("status") == 401:
|
||||
return InvokeResult(
|
||||
ok=False,
|
||||
status="auth_error",
|
||||
error="Authentication required",
|
||||
)
|
||||
|
||||
if result.get("status") == 404:
|
||||
return InvokeResult(
|
||||
ok=False,
|
||||
status="not_found",
|
||||
error=f"Recipe not found: {name}",
|
||||
)
|
||||
|
||||
if result.get("error"):
|
||||
return InvokeResult(
|
||||
ok=False,
|
||||
status="error",
|
||||
error=result["error"].get("message", str(result["error"])),
|
||||
)
|
||||
|
||||
data = result.get("data", {})
|
||||
return InvokeResult(
|
||||
ok=data.get("ok", True),
|
||||
status=data.get("status", "success"),
|
||||
data=data.get("output"),
|
||||
metadata={"run_id": data.get("run_id")},
|
||||
)
|
||||
|
||||
def invoke_stream(
|
||||
self,
|
||||
name: str,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
"""Invoke a recipe endpoint with streaming."""
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
body = {
|
||||
"recipe": name,
|
||||
"input": input_data or {},
|
||||
"config": config or {},
|
||||
}
|
||||
|
||||
full_url = f"{self.base_url}/v1/recipes/stream"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
}
|
||||
if self.api_key:
|
||||
headers["X-API-Key"] = self.api_key
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(full_url, data=data, headers=headers, method="POST")
|
||||
|
||||
with urllib.request.urlopen(req, timeout=300) as response:
|
||||
buffer = ""
|
||||
for line in response:
|
||||
line = line.decode("utf-8")
|
||||
buffer += line
|
||||
|
||||
if buffer.endswith("\n\n"):
|
||||
event_type = "message"
|
||||
event_data = ""
|
||||
|
||||
for part in buffer.strip().split("\n"):
|
||||
if part.startswith("event:"):
|
||||
event_type = part[6:].strip()
|
||||
elif part.startswith("data:"):
|
||||
event_data = part[5:].strip()
|
||||
|
||||
if event_data:
|
||||
try:
|
||||
yield {"event": event_type, "data": json.loads(event_data)}
|
||||
except json.JSONDecodeError:
|
||||
yield {"event": event_type, "data": event_data}
|
||||
|
||||
buffer = ""
|
||||
|
||||
except Exception as e:
|
||||
yield {"event": "error", "data": {"error": str(e)}}
|
||||
|
||||
def health(self) -> HealthResult:
|
||||
"""Check recipe server health."""
|
||||
result = self._make_request("GET", "/health")
|
||||
|
||||
if result.get("error"):
|
||||
return HealthResult(
|
||||
healthy=False,
|
||||
status="unhealthy",
|
||||
provider_type=self.provider_type,
|
||||
metadata={"error": result["error"].get("message", str(result["error"]))},
|
||||
)
|
||||
|
||||
data = result.get("data", {})
|
||||
return HealthResult(
|
||||
healthy=data.get("status") == "healthy",
|
||||
status=data.get("status", "unknown"),
|
||||
server_name=data.get("service"),
|
||||
server_version=data.get("version"),
|
||||
provider_type=self.provider_type,
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Tools MCP Provider
|
||||
|
||||
Provider adapter for tools exposed as MCP server.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
from .base import BaseProvider, InvokeResult, HealthResult
|
||||
from ..discovery import EndpointInfo, ProviderInfo
|
||||
|
||||
|
||||
class ToolsMCPProvider(BaseProvider):
|
||||
"""
|
||||
Provider adapter for tools exposed as MCP server.
|
||||
|
||||
This provider connects to a ToolsMCPServer that exposes Python functions
|
||||
as MCP tools.
|
||||
"""
|
||||
|
||||
provider_type = "tools-mcp"
|
||||
|
||||
def get_provider_info(self) -> ProviderInfo:
|
||||
"""Get provider information."""
|
||||
return ProviderInfo(
|
||||
type=self.provider_type,
|
||||
name="Tools MCP Server",
|
||||
description="Python tools exposed as MCP server",
|
||||
capabilities=["list-tools", "call-tool"],
|
||||
)
|
||||
|
||||
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
|
||||
"""List available tools as endpoints."""
|
||||
# Try unified discovery first
|
||||
result = self._make_request("GET", "/__praisonai__/discovery")
|
||||
|
||||
if not result.get("error") and result.get("data"):
|
||||
data = result.get("data", {})
|
||||
endpoints = []
|
||||
for ep in data.get("endpoints", []):
|
||||
if ep.get("provider_type") == self.provider_type:
|
||||
endpoints.append(EndpointInfo(
|
||||
name=ep.get("name", ""),
|
||||
description=ep.get("description", ""),
|
||||
provider_type=self.provider_type,
|
||||
tags=ep.get("tags", []),
|
||||
input_schema=ep.get("input_schema"),
|
||||
streaming=ep.get("streaming", ["none"]),
|
||||
auth_modes=ep.get("auth_modes", ["none"]),
|
||||
))
|
||||
return endpoints
|
||||
|
||||
# Fallback: try tools endpoint
|
||||
result = self._make_request("GET", "/tools")
|
||||
|
||||
if result.get("error"):
|
||||
return []
|
||||
|
||||
tools = result.get("data", {}).get("tools", [])
|
||||
endpoints = []
|
||||
|
||||
for tool in tools:
|
||||
endpoints.append(EndpointInfo(
|
||||
name=tool.get("name", ""),
|
||||
description=tool.get("description", ""),
|
||||
provider_type=self.provider_type,
|
||||
input_schema=tool.get("input_schema"),
|
||||
streaming=["none"],
|
||||
auth_modes=["none"],
|
||||
))
|
||||
|
||||
return endpoints
|
||||
|
||||
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
|
||||
"""Get detailed information about a tool."""
|
||||
endpoints = self.list_endpoints()
|
||||
for ep in endpoints:
|
||||
if ep.name == name:
|
||||
return ep
|
||||
return None
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
name: str,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
stream: bool = False,
|
||||
) -> InvokeResult:
|
||||
"""Invoke a tool."""
|
||||
body = {
|
||||
"tool": name,
|
||||
"arguments": input_data or {},
|
||||
}
|
||||
|
||||
result = self._make_request("POST", "/tools/call", json_data=body)
|
||||
|
||||
if result.get("status") == 404:
|
||||
return InvokeResult(
|
||||
ok=False,
|
||||
status="not_found",
|
||||
error=f"Tool not found: {name}",
|
||||
)
|
||||
|
||||
if result.get("error"):
|
||||
return InvokeResult(
|
||||
ok=False,
|
||||
status="error",
|
||||
error=result["error"].get("message", str(result["error"])),
|
||||
)
|
||||
|
||||
data = result.get("data", {})
|
||||
return InvokeResult(
|
||||
ok=True,
|
||||
status="success",
|
||||
data=data.get("result", data),
|
||||
)
|
||||
|
||||
def invoke_stream(
|
||||
self,
|
||||
name: str,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
"""Invoke tool with streaming (wraps sync call)."""
|
||||
result = self.invoke(name, input_data, config)
|
||||
if result.ok:
|
||||
yield {"event": "result", "data": result.data}
|
||||
else:
|
||||
yield {"event": "error", "data": {"error": result.error}}
|
||||
|
||||
def health(self) -> HealthResult:
|
||||
"""Check tools server health."""
|
||||
result = self._make_request("GET", "/health")
|
||||
|
||||
if result.get("error"):
|
||||
return HealthResult(
|
||||
healthy=False,
|
||||
status="unhealthy",
|
||||
provider_type=self.provider_type,
|
||||
metadata={"error": result["error"].get("message", str(result["error"]))},
|
||||
)
|
||||
|
||||
data = result.get("data", {})
|
||||
return HealthResult(
|
||||
healthy=True,
|
||||
status="healthy",
|
||||
server_name=data.get("server_name", "Tools MCP Server"),
|
||||
server_version=data.get("version"),
|
||||
provider_type=self.provider_type,
|
||||
)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Provider Registry
|
||||
|
||||
Central registry for provider adapters.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Type
|
||||
|
||||
from .providers.base import BaseProvider
|
||||
|
||||
|
||||
# Global provider registry
|
||||
_providers: Dict[str, Type[BaseProvider]] = {}
|
||||
|
||||
|
||||
def register_provider(provider_type: str, provider_class: Type[BaseProvider]) -> None:
|
||||
"""
|
||||
Register a provider class.
|
||||
|
||||
Args:
|
||||
provider_type: Provider type identifier
|
||||
provider_class: Provider class to register
|
||||
"""
|
||||
_providers[provider_type] = provider_class
|
||||
|
||||
|
||||
def get_provider(
|
||||
provider_type: str,
|
||||
base_url: str = "http://localhost:8765",
|
||||
api_key: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[BaseProvider]:
|
||||
"""
|
||||
Get a provider instance by type.
|
||||
|
||||
Args:
|
||||
provider_type: Provider type identifier
|
||||
base_url: Base URL for the provider
|
||||
api_key: Optional API key
|
||||
**kwargs: Additional provider-specific arguments
|
||||
|
||||
Returns:
|
||||
Provider instance or None if not found
|
||||
"""
|
||||
# Lazy register built-in providers
|
||||
_ensure_providers_registered()
|
||||
|
||||
if provider_type not in _providers:
|
||||
return None
|
||||
|
||||
return _providers[provider_type](base_url=base_url, api_key=api_key, **kwargs)
|
||||
|
||||
|
||||
def list_provider_types() -> List[str]:
|
||||
"""
|
||||
List all registered provider types.
|
||||
|
||||
Returns:
|
||||
List of provider type identifiers
|
||||
"""
|
||||
_ensure_providers_registered()
|
||||
return list(_providers.keys())
|
||||
|
||||
|
||||
def get_provider_class(provider_type: str) -> Optional[Type[BaseProvider]]:
|
||||
"""
|
||||
Get a provider class by type.
|
||||
|
||||
Args:
|
||||
provider_type: Provider type identifier
|
||||
|
||||
Returns:
|
||||
Provider class or None if not found
|
||||
"""
|
||||
_ensure_providers_registered()
|
||||
return _providers.get(provider_type)
|
||||
|
||||
|
||||
def _ensure_providers_registered() -> None:
|
||||
"""Ensure built-in providers are registered."""
|
||||
if _providers:
|
||||
return
|
||||
|
||||
# Lazy import and register built-in providers
|
||||
from .providers.recipe import RecipeProvider
|
||||
from .providers.agents_api import AgentsAPIProvider
|
||||
from .providers.mcp import MCPProvider
|
||||
from .providers.tools_mcp import ToolsMCPProvider
|
||||
from .providers.a2a import A2AProvider
|
||||
from .providers.a2u import A2UProvider
|
||||
|
||||
register_provider("recipe", RecipeProvider)
|
||||
register_provider("agents-api", AgentsAPIProvider)
|
||||
register_provider("mcp", MCPProvider)
|
||||
register_provider("tools-mcp", ToolsMCPProvider)
|
||||
register_provider("a2a", A2AProvider)
|
||||
register_provider("a2u", A2UProvider)
|
||||
|
||||
|
||||
class ProviderRegistry:
|
||||
"""
|
||||
Provider registry class for managing provider instances.
|
||||
|
||||
This class provides a more object-oriented interface to the provider registry.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the registry."""
|
||||
_ensure_providers_registered()
|
||||
|
||||
def get(
|
||||
self,
|
||||
provider_type: str,
|
||||
base_url: str = "http://localhost:8765",
|
||||
api_key: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[BaseProvider]:
|
||||
"""Get a provider instance."""
|
||||
return get_provider(provider_type, base_url, api_key, **kwargs)
|
||||
|
||||
def register(self, provider_type: str, provider_class: Type[BaseProvider]) -> None:
|
||||
"""Register a provider class."""
|
||||
register_provider(provider_type, provider_class)
|
||||
|
||||
def list_types(self) -> List[str]:
|
||||
"""List all provider types."""
|
||||
return list_provider_types()
|
||||
|
||||
def get_class(self, provider_type: str) -> Optional[Type[BaseProvider]]:
|
||||
"""Get a provider class."""
|
||||
return get_provider_class(provider_type)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Unified Server Utilities
|
||||
|
||||
Provides utilities for adding discovery endpoints to any server.
|
||||
"""
|
||||
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from .discovery import (
|
||||
DiscoveryDocument,
|
||||
EndpointInfo,
|
||||
ProviderInfo,
|
||||
SCHEMA_VERSION,
|
||||
create_discovery_document,
|
||||
)
|
||||
|
||||
|
||||
def add_discovery_routes(
|
||||
app: Any,
|
||||
discovery: DiscoveryDocument,
|
||||
path: str = "/__praisonai__/discovery",
|
||||
) -> None:
|
||||
"""
|
||||
Add unified discovery routes to a Starlette/FastAPI application.
|
||||
|
||||
Args:
|
||||
app: Starlette or FastAPI application
|
||||
discovery: DiscoveryDocument to serve
|
||||
path: Path for discovery endpoint
|
||||
"""
|
||||
try:
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
except ImportError:
|
||||
# Try FastAPI
|
||||
try:
|
||||
from fastapi.responses import JSONResponse
|
||||
except ImportError:
|
||||
raise ImportError("Starlette or FastAPI required for discovery routes")
|
||||
|
||||
async def discovery_handler(request):
|
||||
"""Return discovery document."""
|
||||
return JSONResponse(discovery.to_dict())
|
||||
|
||||
async def health_handler(request):
|
||||
"""Return health status with discovery info."""
|
||||
return JSONResponse({
|
||||
"status": "healthy",
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"server_name": discovery.server_name,
|
||||
"server_version": discovery.server_version,
|
||||
"providers": [p.type for p in discovery.providers],
|
||||
"endpoint_count": len(discovery.endpoints),
|
||||
})
|
||||
|
||||
# Add routes based on app type
|
||||
if hasattr(app, 'add_api_route'):
|
||||
# FastAPI
|
||||
app.add_api_route(path, discovery_handler, methods=["GET"])
|
||||
if not _has_route(app, "/health"):
|
||||
app.add_api_route("/health", health_handler, methods=["GET"])
|
||||
elif hasattr(app, 'routes'):
|
||||
# Starlette
|
||||
app.routes.append(Route(path, discovery_handler, methods=["GET"]))
|
||||
if not _has_route(app, "/health"):
|
||||
app.routes.append(Route("/health", health_handler, methods=["GET"]))
|
||||
|
||||
|
||||
def _has_route(app: Any, path: str) -> bool:
|
||||
"""Check if app already has a route at path."""
|
||||
if hasattr(app, 'routes'):
|
||||
for route in app.routes:
|
||||
if hasattr(route, 'path') and route.path == path:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def create_unified_app(
|
||||
providers: Optional[List[str]] = None,
|
||||
server_name: str = "praisonai",
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8765,
|
||||
cors_origins: Optional[List[str]] = None,
|
||||
api_key: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Create a unified server application with discovery support.
|
||||
|
||||
Args:
|
||||
providers: List of provider types to enable
|
||||
server_name: Server name for discovery
|
||||
host: Server host
|
||||
port: Server port
|
||||
cors_origins: CORS allowed origins
|
||||
api_key: Optional API key for authentication
|
||||
|
||||
Returns:
|
||||
FastAPI application
|
||||
"""
|
||||
try:
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
except ImportError:
|
||||
raise ImportError("FastAPI required. Install with: pip install fastapi")
|
||||
|
||||
# Create discovery document
|
||||
discovery = create_discovery_document(server_name=server_name)
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title=f"{server_name} Unified API",
|
||||
description="Unified PraisonAI endpoints server",
|
||||
version=discovery.server_version,
|
||||
)
|
||||
|
||||
# Add CORS if configured
|
||||
if cors_origins:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Add discovery routes
|
||||
add_discovery_routes(app, discovery)
|
||||
|
||||
# Store discovery document for later modification
|
||||
app.state.discovery = discovery
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def register_endpoint_to_discovery(
|
||||
app: Any,
|
||||
endpoint: EndpointInfo,
|
||||
) -> None:
|
||||
"""
|
||||
Register an endpoint to the app's discovery document.
|
||||
|
||||
Args:
|
||||
app: FastAPI/Starlette app with discovery
|
||||
endpoint: EndpointInfo to register
|
||||
"""
|
||||
if hasattr(app, 'state') and hasattr(app.state, 'discovery'):
|
||||
app.state.discovery.add_endpoint(endpoint)
|
||||
|
||||
|
||||
def register_provider_to_discovery(
|
||||
app: Any,
|
||||
provider: ProviderInfo,
|
||||
) -> None:
|
||||
"""
|
||||
Register a provider to the app's discovery document.
|
||||
|
||||
Args:
|
||||
app: FastAPI/Starlette app with discovery
|
||||
provider: ProviderInfo to register
|
||||
"""
|
||||
if hasattr(app, 'state') and hasattr(app.state, 'discovery'):
|
||||
app.state.discovery.add_provider(provider)
|
||||
@@ -39,12 +39,18 @@ class JobExecutor:
|
||||
self.default_timeout = default_timeout
|
||||
self.cleanup_interval = cleanup_interval
|
||||
|
||||
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||
self._semaphore: Optional[asyncio.Semaphore] = None
|
||||
self._running_tasks: Dict[str, asyncio.Task] = {}
|
||||
self._cleanup_task: Optional[asyncio.Task] = None
|
||||
self._shutdown = False
|
||||
self._progress_callbacks: Dict[str, Callable] = {}
|
||||
|
||||
def _get_semaphore(self) -> asyncio.Semaphore:
|
||||
"""Lazily create semaphore to avoid event loop issues."""
|
||||
if self._semaphore is None:
|
||||
self._semaphore = asyncio.Semaphore(self.max_concurrent)
|
||||
return self._semaphore
|
||||
|
||||
async def start(self):
|
||||
"""Start the executor and cleanup loop."""
|
||||
self._shutdown = False
|
||||
@@ -147,7 +153,7 @@ class JobExecutor:
|
||||
|
||||
async def _execute_job(self, job: Job):
|
||||
"""Execute a job."""
|
||||
async with self._semaphore:
|
||||
async with self._get_semaphore():
|
||||
try:
|
||||
# Mark as running
|
||||
job.start()
|
||||
@@ -203,7 +209,12 @@ class JobExecutor:
|
||||
Run the agent for a job.
|
||||
|
||||
This is the core execution logic that runs the PraisonAI agent.
|
||||
Supports both direct agent execution and recipe-based execution.
|
||||
"""
|
||||
# Check if this is a recipe job
|
||||
if job.recipe_name:
|
||||
return await self._run_recipe(job)
|
||||
|
||||
# Imports are done lazily in _run_praisonai_agents and _run_legacy_praisonai
|
||||
|
||||
# Determine agent configuration
|
||||
@@ -241,6 +252,50 @@ class JobExecutor:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _run_recipe(self, job: Job) -> Any:
|
||||
"""Run a recipe-based job."""
|
||||
try:
|
||||
from praisonai.recipe.bridge import resolve, execute_resolved_recipe
|
||||
except ImportError:
|
||||
raise RuntimeError("Recipe execution requires praisonai.recipe module")
|
||||
|
||||
# Update progress
|
||||
job.update_progress(percentage=10.0, step=f"Resolving recipe: {job.recipe_name}")
|
||||
await self.store.save(job)
|
||||
await self._notify_progress(job)
|
||||
|
||||
# Resolve the recipe
|
||||
resolved = resolve(
|
||||
job.recipe_name,
|
||||
input_data=job.prompt,
|
||||
config=job.recipe_config or {},
|
||||
session_id=job.session_id,
|
||||
options={'timeout_sec': job.timeout},
|
||||
)
|
||||
|
||||
# Update job with recipe info
|
||||
job.agent_id = f"recipe:{resolved.name}"
|
||||
job.run_id = resolved.run_id
|
||||
|
||||
# Update progress
|
||||
job.update_progress(percentage=20.0, step=f"Executing recipe: {resolved.name}")
|
||||
await self.store.save(job)
|
||||
await self._notify_progress(job)
|
||||
|
||||
# Execute the recipe
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: execute_resolved_recipe(resolved)
|
||||
)
|
||||
|
||||
# Update progress
|
||||
job.update_progress(percentage=90.0, step="Finalizing")
|
||||
await self.store.save(job)
|
||||
await self._notify_progress(job)
|
||||
|
||||
return result
|
||||
|
||||
async def _run_praisonai_agents(self, job: Job, agent_file: str) -> Any:
|
||||
"""Run using praisonaiagents framework."""
|
||||
try:
|
||||
|
||||
@@ -25,12 +25,15 @@ class JobSubmitRequest(BaseModel):
|
||||
prompt: str = Field(..., description="The prompt or task for the agent")
|
||||
agent_file: Optional[str] = Field(None, description="Path to agents.yaml file")
|
||||
agent_yaml: Optional[str] = Field(None, description="Inline agent YAML configuration")
|
||||
recipe_name: Optional[str] = Field(None, description="Recipe name to execute (mutually exclusive with agent_file)")
|
||||
recipe_config: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Recipe configuration overrides")
|
||||
framework: Optional[str] = Field("praisonai", description="Framework to use (praisonai, crewai, autogen)")
|
||||
config: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Additional configuration")
|
||||
webhook_url: Optional[str] = Field(None, description="URL to POST results when complete")
|
||||
timeout: Optional[int] = Field(3600, description="Timeout in seconds (default: 1 hour)")
|
||||
session_id: Optional[str] = Field(None, description="Session ID for conversation continuity")
|
||||
idempotency_key: Optional[str] = Field(None, description="Idempotency key to prevent duplicate submissions")
|
||||
idempotency_scope: Optional[str] = Field("none", description="Idempotency scope: none, session, global")
|
||||
|
||||
|
||||
class JobSubmitResponse(BaseModel):
|
||||
@@ -96,12 +99,15 @@ class Job(BaseModel):
|
||||
prompt: str = Field(...)
|
||||
agent_file: Optional[str] = Field(None)
|
||||
agent_yaml: Optional[str] = Field(None)
|
||||
recipe_name: Optional[str] = Field(None)
|
||||
recipe_config: Dict[str, Any] = Field(default_factory=dict)
|
||||
framework: str = Field("praisonai")
|
||||
config: Dict[str, Any] = Field(default_factory=dict)
|
||||
webhook_url: Optional[str] = Field(None)
|
||||
timeout: int = Field(3600)
|
||||
session_id: Optional[str] = Field(None)
|
||||
idempotency_key: Optional[str] = Field(None)
|
||||
idempotency_scope: str = Field("none")
|
||||
|
||||
# Progress tracking
|
||||
progress_percentage: float = Field(0.0)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
PraisonAI MCP Server Module
|
||||
|
||||
Exposes PraisonAI capabilities as an MCP server that any MCP client can connect to.
|
||||
|
||||
Supports:
|
||||
- STDIO transport (default, for Claude Desktop, Cursor, etc.)
|
||||
- HTTP Stream transport (MCP 2025-03-26 spec)
|
||||
|
||||
Usage:
|
||||
# STDIO mode (for Claude Desktop config)
|
||||
praisonai mcp serve --transport stdio
|
||||
|
||||
# HTTP Stream mode
|
||||
praisonai mcp serve --transport http-stream --port 8080
|
||||
|
||||
# Programmatic usage
|
||||
from praisonai.mcp_server import MCPServer
|
||||
|
||||
server = MCPServer()
|
||||
server.run(transport="stdio")
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"MCPServer",
|
||||
"MCPToolRegistry",
|
||||
"MCPResourceRegistry",
|
||||
"MCPPromptRegistry",
|
||||
"register_tool",
|
||||
"register_resource",
|
||||
"register_prompt",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy load server components."""
|
||||
if name == "MCPServer":
|
||||
from .server import MCPServer
|
||||
return MCPServer
|
||||
elif name == "MCPToolRegistry":
|
||||
from .registry import MCPToolRegistry
|
||||
return MCPToolRegistry
|
||||
elif name == "MCPResourceRegistry":
|
||||
from .registry import MCPResourceRegistry
|
||||
return MCPResourceRegistry
|
||||
elif name == "MCPPromptRegistry":
|
||||
from .registry import MCPPromptRegistry
|
||||
return MCPPromptRegistry
|
||||
elif name == "register_tool":
|
||||
from .registry import register_tool
|
||||
return register_tool
|
||||
elif name == "register_resource":
|
||||
from .registry import register_resource
|
||||
return register_resource
|
||||
elif name == "register_prompt":
|
||||
from .registry import register_prompt
|
||||
return register_prompt
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
MCP Adapters
|
||||
|
||||
Adapters that convert PraisonAI capabilities, CLI features, and agent operations
|
||||
into MCP tools, resources, and prompts.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"register_all",
|
||||
"register_all_tools",
|
||||
"register_capability_tools",
|
||||
"register_extended_capability_tools",
|
||||
"register_agent_tools",
|
||||
"register_memory_tools",
|
||||
"register_knowledge_tools",
|
||||
"register_cli_tools",
|
||||
"register_mcp_resources",
|
||||
"register_mcp_prompts",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy load adapters."""
|
||||
if name == "register_all":
|
||||
from .capabilities import register_all_tools
|
||||
from .extended_capabilities import register_extended_capability_tools
|
||||
from .cli_tools import register_cli_tools
|
||||
from .resources import register_mcp_resources
|
||||
from .prompts import register_mcp_prompts
|
||||
|
||||
def _register_all():
|
||||
register_all_tools()
|
||||
register_extended_capability_tools()
|
||||
register_cli_tools()
|
||||
register_mcp_resources()
|
||||
register_mcp_prompts()
|
||||
|
||||
return _register_all
|
||||
elif name == "register_all_tools":
|
||||
from .capabilities import register_all_tools
|
||||
return register_all_tools
|
||||
elif name == "register_capability_tools":
|
||||
from .capabilities import register_capability_tools
|
||||
return register_capability_tools
|
||||
elif name == "register_extended_capability_tools":
|
||||
from .extended_capabilities import register_extended_capability_tools
|
||||
return register_extended_capability_tools
|
||||
elif name == "register_agent_tools":
|
||||
from .agents import register_agent_tools
|
||||
return register_agent_tools
|
||||
elif name == "register_memory_tools":
|
||||
from .memory import register_memory_tools
|
||||
return register_memory_tools
|
||||
elif name == "register_knowledge_tools":
|
||||
from .knowledge import register_knowledge_tools
|
||||
return register_knowledge_tools
|
||||
elif name == "register_cli_tools":
|
||||
from .cli_tools import register_cli_tools
|
||||
return register_cli_tools
|
||||
elif name == "register_mcp_resources":
|
||||
from .resources import register_mcp_resources
|
||||
return register_mcp_resources
|
||||
elif name == "register_mcp_prompts":
|
||||
from .prompts import register_mcp_prompts
|
||||
return register_mcp_prompts
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Agents Adapter
|
||||
|
||||
Maps PraisonAI agent operations to MCP tools.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from ..registry import register_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_agent_tools() -> None:
|
||||
"""Register agent-related MCP tools."""
|
||||
|
||||
@register_tool("praisonai.agent.chat")
|
||||
def agent_chat(
|
||||
message: str,
|
||||
model: str = "gpt-4o-mini",
|
||||
instructions: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Chat with a PraisonAI agent."""
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
instructions=instructions or "You are a helpful assistant.",
|
||||
llm=model,
|
||||
)
|
||||
result = agent.chat(message)
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: praisonaiagents not installed"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.agent.run")
|
||||
def agent_run(
|
||||
task: str,
|
||||
model: str = "gpt-4o-mini",
|
||||
instructions: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Run a task with a PraisonAI agent."""
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
instructions=instructions or "You are a helpful assistant.",
|
||||
llm=model,
|
||||
)
|
||||
result = agent.start(task)
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: praisonaiagents not installed"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.workflow.run")
|
||||
def workflow_run(
|
||||
yaml_content: str,
|
||||
) -> str:
|
||||
"""Run a workflow from YAML definition."""
|
||||
try:
|
||||
import yaml
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
|
||||
# Parse YAML to validate
|
||||
yaml.safe_load(yaml_content)
|
||||
|
||||
# Write to temp file and run
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
||||
f.write(yaml_content)
|
||||
temp_path = f.name
|
||||
|
||||
generator = AgentsGenerator(
|
||||
agent_file=temp_path,
|
||||
framework="praisonai",
|
||||
)
|
||||
result = generator.generate_crew_and_kickoff()
|
||||
return str(result)
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.workflow.run_file")
|
||||
def workflow_run_file(
|
||||
file_path: str,
|
||||
) -> str:
|
||||
"""Run a workflow from YAML file path."""
|
||||
try:
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
|
||||
generator = AgentsGenerator(
|
||||
agent_file=file_path,
|
||||
framework="praisonai",
|
||||
)
|
||||
result = generator.generate_crew_and_kickoff()
|
||||
return str(result)
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.research.run")
|
||||
def research_run(
|
||||
topic: str,
|
||||
depth: str = "medium",
|
||||
) -> str:
|
||||
"""Run deep research on a topic."""
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
instructions = f"""You are a research agent. Conduct thorough research on the given topic.
|
||||
Research depth: {depth}
|
||||
Provide comprehensive findings with sources where possible."""
|
||||
|
||||
agent = Agent(
|
||||
instructions=instructions,
|
||||
llm="gpt-4o",
|
||||
)
|
||||
result = agent.start(f"Research the following topic thoroughly: {topic}")
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: praisonaiagents not installed"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
logger.info("Registered agent MCP tools")
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Capabilities Adapter
|
||||
|
||||
Maps PraisonAI capabilities (audio, images, embeddings, etc.) to MCP tools.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from ..registry import register_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_capability_tools() -> None:
|
||||
"""Register all capability-based MCP tools."""
|
||||
|
||||
# Audio tools
|
||||
@register_tool("praisonai.audio.transcribe")
|
||||
def audio_transcribe(
|
||||
file_path: str,
|
||||
model: str = "whisper-1",
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Transcribe audio file to text."""
|
||||
from praisonai.capabilities import transcribe
|
||||
result = transcribe(file=file_path, model=model, language=language)
|
||||
return result.text if hasattr(result, "text") else str(result)
|
||||
|
||||
@register_tool("praisonai.audio.speech")
|
||||
def audio_speech(
|
||||
text: str,
|
||||
model: str = "tts-1",
|
||||
voice: str = "alloy",
|
||||
) -> str:
|
||||
"""Convert text to speech."""
|
||||
from praisonai.capabilities import speech
|
||||
result = speech(input=text, model=model, voice=voice)
|
||||
return f"Audio generated: {result}"
|
||||
|
||||
# Image tools
|
||||
@register_tool("praisonai.images.generate")
|
||||
def images_generate(
|
||||
prompt: str,
|
||||
model: str = "dall-e-3",
|
||||
size: str = "1024x1024",
|
||||
n: int = 1,
|
||||
) -> str:
|
||||
"""Generate images from text prompt."""
|
||||
from praisonai.capabilities import image_generate
|
||||
result = image_generate(prompt=prompt, model=model, size=size, n=n)
|
||||
if hasattr(result, "data"):
|
||||
urls = [img.url for img in result.data if hasattr(img, "url")]
|
||||
return f"Generated {len(urls)} image(s): {urls}"
|
||||
return str(result)
|
||||
|
||||
# Embedding tools
|
||||
@register_tool("praisonai.embed.create")
|
||||
def embed_create(
|
||||
text: str,
|
||||
model: str = "text-embedding-3-small",
|
||||
) -> str:
|
||||
"""Create text embeddings."""
|
||||
from praisonai.capabilities import embed
|
||||
result = embed(input=text, model=model)
|
||||
if hasattr(result, "data") and result.data:
|
||||
return f"Embedding created with {len(result.data[0].embedding)} dimensions"
|
||||
return str(result)
|
||||
|
||||
# Moderation tools
|
||||
@register_tool("praisonai.moderate.check")
|
||||
def moderate_check(text: str) -> str:
|
||||
"""Check content for policy violations."""
|
||||
from praisonai.capabilities import moderate
|
||||
result = moderate(input=text)
|
||||
if hasattr(result, "results") and result.results:
|
||||
flagged = result.results[0].flagged
|
||||
return f"Content flagged: {flagged}"
|
||||
return str(result)
|
||||
|
||||
# Chat completion
|
||||
@register_tool("praisonai.chat.completion")
|
||||
def chat_completion(
|
||||
message: str,
|
||||
model: str = "gpt-4o-mini",
|
||||
system_prompt: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Generate chat completion."""
|
||||
from praisonai.capabilities import chat_completion
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append({"role": "user", "content": message})
|
||||
result = chat_completion(model=model, messages=messages)
|
||||
if hasattr(result, "choices") and result.choices:
|
||||
return result.choices[0].message.content
|
||||
return str(result)
|
||||
|
||||
# Rerank
|
||||
@register_tool("praisonai.rerank")
|
||||
def rerank_documents(
|
||||
query: str,
|
||||
documents: List[str],
|
||||
model: str = "rerank-english-v2.0",
|
||||
top_n: int = 5,
|
||||
) -> str:
|
||||
"""Rerank documents by relevance to query."""
|
||||
from praisonai.capabilities import rerank
|
||||
result = rerank(query=query, documents=documents, model=model, top_n=top_n)
|
||||
return str(result)
|
||||
|
||||
# RAG
|
||||
@register_tool("praisonai.rag.query")
|
||||
def rag_query(
|
||||
query: str,
|
||||
collection: str = "default",
|
||||
) -> str:
|
||||
"""Query RAG knowledge base."""
|
||||
from praisonai.capabilities import rag_query
|
||||
result = rag_query(query=query, collection=collection)
|
||||
return str(result)
|
||||
|
||||
# Search
|
||||
@register_tool("praisonai.search")
|
||||
def search_web(query: str) -> str:
|
||||
"""Search the web."""
|
||||
from praisonai.capabilities import search
|
||||
result = search(query=query)
|
||||
return str(result)
|
||||
|
||||
# Guardrails
|
||||
@register_tool("praisonai.guardrails.check")
|
||||
def guardrails_check(
|
||||
text: str,
|
||||
guardrail_name: str = "default",
|
||||
) -> str:
|
||||
"""Apply guardrail to text."""
|
||||
from praisonai.capabilities import apply_guardrail
|
||||
result = apply_guardrail(text=text, guardrail_name=guardrail_name)
|
||||
return str(result)
|
||||
|
||||
logger.info("Registered capability MCP tools")
|
||||
|
||||
|
||||
def register_all_tools() -> None:
|
||||
"""Register all MCP tools from all adapters."""
|
||||
register_capability_tools()
|
||||
|
||||
# Import and register other adapters
|
||||
try:
|
||||
from .agents import register_agent_tools
|
||||
register_agent_tools()
|
||||
except ImportError:
|
||||
logger.debug("Agent tools adapter not available")
|
||||
|
||||
try:
|
||||
from .memory import register_memory_tools
|
||||
register_memory_tools()
|
||||
except ImportError:
|
||||
logger.debug("Memory tools adapter not available")
|
||||
|
||||
try:
|
||||
from .knowledge import register_knowledge_tools
|
||||
register_knowledge_tools()
|
||||
except ImportError:
|
||||
logger.debug("Knowledge tools adapter not available")
|
||||
|
||||
logger.info("All MCP tools registered")
|
||||
@@ -0,0 +1,568 @@
|
||||
"""
|
||||
CLI Tools Adapter
|
||||
|
||||
Maps PraisonAI CLI features to MCP tools:
|
||||
- Workflow management
|
||||
- Rules management
|
||||
- Hooks management
|
||||
- Session management
|
||||
- Todo management
|
||||
- Docs management
|
||||
- Schedule management
|
||||
- Profile management
|
||||
- Deploy management
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from ..registry import register_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_cli_tools() -> None:
|
||||
"""Register CLI-based MCP tools."""
|
||||
|
||||
# Workflow tools
|
||||
@register_tool("praisonai.workflow.list")
|
||||
def workflow_list() -> str:
|
||||
"""List available workflows."""
|
||||
try:
|
||||
import glob
|
||||
workflows = []
|
||||
for pattern in ["*.yaml", "*.yml", "agents*.yaml", "workflow*.yaml"]:
|
||||
workflows.extend(glob.glob(pattern))
|
||||
if not workflows:
|
||||
return "No workflows found in current directory"
|
||||
return f"Available workflows: {workflows}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.workflow.validate")
|
||||
def workflow_validate(file_path: str) -> str:
|
||||
"""Validate a workflow YAML file."""
|
||||
try:
|
||||
import yaml
|
||||
with open(file_path, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
required = ["framework", "topic"]
|
||||
missing = [k for k in required if k not in config]
|
||||
if missing:
|
||||
return f"Invalid workflow: missing {missing}"
|
||||
|
||||
return f"Workflow valid: {file_path}"
|
||||
except yaml.YAMLError as e:
|
||||
return f"YAML error: {e}"
|
||||
except FileNotFoundError:
|
||||
return f"File not found: {file_path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.workflow.show")
|
||||
def workflow_show(file_path: str) -> str:
|
||||
"""Show workflow configuration."""
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
content = f.read()
|
||||
return content
|
||||
except FileNotFoundError:
|
||||
return f"File not found: {file_path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.workflow.auto")
|
||||
def workflow_auto(topic: str, pattern: str = "sequential") -> str:
|
||||
"""Auto-generate a workflow for a topic."""
|
||||
try:
|
||||
from praisonai.auto import AutoGenerator
|
||||
generator = AutoGenerator(topic=topic)
|
||||
result = generator.generate(pattern=pattern)
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: AutoGenerator not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Rules tools
|
||||
@register_tool("praisonai.rules.list")
|
||||
def rules_list() -> str:
|
||||
"""List active rules."""
|
||||
try:
|
||||
import os
|
||||
rules_dir = os.path.expanduser("~/.praison/rules")
|
||||
if not os.path.exists(rules_dir):
|
||||
return "No rules directory found"
|
||||
rules = os.listdir(rules_dir)
|
||||
return f"Active rules: {rules}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.rules.show")
|
||||
def rules_show(rule_name: str) -> str:
|
||||
"""Show a specific rule."""
|
||||
try:
|
||||
import os
|
||||
rule_path = os.path.expanduser(f"~/.praison/rules/{rule_name}")
|
||||
if not os.path.exists(rule_path):
|
||||
return f"Rule not found: {rule_name}"
|
||||
with open(rule_path, 'r') as f:
|
||||
content = f.read()
|
||||
return content
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.rules.create")
|
||||
def rules_create(rule_name: str, content: str) -> str:
|
||||
"""Create a new rule."""
|
||||
try:
|
||||
import os
|
||||
rules_dir = os.path.expanduser("~/.praison/rules")
|
||||
os.makedirs(rules_dir, exist_ok=True)
|
||||
rule_path = os.path.join(rules_dir, rule_name)
|
||||
with open(rule_path, 'w') as f:
|
||||
f.write(content)
|
||||
return f"Rule created: {rule_name}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.rules.delete")
|
||||
def rules_delete(rule_name: str) -> str:
|
||||
"""Delete a rule."""
|
||||
try:
|
||||
import os
|
||||
rule_path = os.path.expanduser(f"~/.praison/rules/{rule_name}")
|
||||
if not os.path.exists(rule_path):
|
||||
return f"Rule not found: {rule_name}"
|
||||
os.remove(rule_path)
|
||||
return f"Rule deleted: {rule_name}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Hooks tools
|
||||
@register_tool("praisonai.hooks.list")
|
||||
def hooks_list() -> str:
|
||||
"""List registered hooks."""
|
||||
try:
|
||||
from praisonaiagents.hooks import get_hook_manager
|
||||
manager = get_hook_manager()
|
||||
hooks = manager.list_hooks()
|
||||
return str(hooks)
|
||||
except ImportError:
|
||||
return "Error: Hooks not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.hooks.stats")
|
||||
def hooks_stats() -> str:
|
||||
"""Get hook execution statistics."""
|
||||
try:
|
||||
from praisonaiagents.hooks import get_hook_manager
|
||||
manager = get_hook_manager()
|
||||
stats = manager.get_stats()
|
||||
return str(stats)
|
||||
except ImportError:
|
||||
return "Error: Hooks not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Session tools
|
||||
@register_tool("praisonai.session.list")
|
||||
def session_list() -> str:
|
||||
"""List active sessions."""
|
||||
try:
|
||||
from praisonaiagents.session import SessionManager
|
||||
manager = SessionManager()
|
||||
sessions = manager.list_sessions()
|
||||
return str(sessions)
|
||||
except ImportError:
|
||||
return "Error: Session management not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.session.info")
|
||||
def session_info(session_id: str) -> str:
|
||||
"""Get session information."""
|
||||
try:
|
||||
from praisonaiagents.session import SessionManager
|
||||
manager = SessionManager()
|
||||
info = manager.get_session(session_id)
|
||||
return str(info)
|
||||
except ImportError:
|
||||
return "Error: Session management not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.session.delete")
|
||||
def session_delete(session_id: str) -> str:
|
||||
"""Delete a session."""
|
||||
try:
|
||||
from praisonaiagents.session import SessionManager
|
||||
manager = SessionManager()
|
||||
manager.delete_session(session_id)
|
||||
return f"Session deleted: {session_id}"
|
||||
except ImportError:
|
||||
return "Error: Session management not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Todo tools
|
||||
@register_tool("praisonai.todo.list")
|
||||
def todo_list() -> str:
|
||||
"""List todo items."""
|
||||
try:
|
||||
import os
|
||||
import json
|
||||
todo_path = os.path.expanduser("~/.praison/todo.json")
|
||||
if not os.path.exists(todo_path):
|
||||
return "No todos found"
|
||||
with open(todo_path, 'r') as f:
|
||||
todos = json.load(f)
|
||||
return str(todos)
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.todo.add")
|
||||
def todo_add(content: str, priority: str = "medium") -> str:
|
||||
"""Add a todo item."""
|
||||
try:
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
todo_path = os.path.expanduser("~/.praison/todo.json")
|
||||
os.makedirs(os.path.dirname(todo_path), exist_ok=True)
|
||||
|
||||
todos = []
|
||||
if os.path.exists(todo_path):
|
||||
with open(todo_path, 'r') as f:
|
||||
todos = json.load(f)
|
||||
|
||||
todo = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"content": content,
|
||||
"priority": priority,
|
||||
"status": "pending",
|
||||
}
|
||||
todos.append(todo)
|
||||
|
||||
with open(todo_path, 'w') as f:
|
||||
json.dump(todos, f, indent=2)
|
||||
|
||||
return f"Todo added: {todo['id']}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.todo.complete")
|
||||
def todo_complete(todo_id: str) -> str:
|
||||
"""Mark a todo as complete."""
|
||||
try:
|
||||
import os
|
||||
import json
|
||||
todo_path = os.path.expanduser("~/.praison/todo.json")
|
||||
if not os.path.exists(todo_path):
|
||||
return "No todos found"
|
||||
|
||||
with open(todo_path, 'r') as f:
|
||||
todos = json.load(f)
|
||||
|
||||
for todo in todos:
|
||||
if todo.get("id") == todo_id:
|
||||
todo["status"] = "completed"
|
||||
break
|
||||
else:
|
||||
return f"Todo not found: {todo_id}"
|
||||
|
||||
with open(todo_path, 'w') as f:
|
||||
json.dump(todos, f, indent=2)
|
||||
|
||||
return f"Todo completed: {todo_id}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.todo.delete")
|
||||
def todo_delete(todo_id: str) -> str:
|
||||
"""Delete a todo item."""
|
||||
try:
|
||||
import os
|
||||
import json
|
||||
todo_path = os.path.expanduser("~/.praison/todo.json")
|
||||
if not os.path.exists(todo_path):
|
||||
return "No todos found"
|
||||
|
||||
with open(todo_path, 'r') as f:
|
||||
todos = json.load(f)
|
||||
|
||||
todos = [t for t in todos if t.get("id") != todo_id]
|
||||
|
||||
with open(todo_path, 'w') as f:
|
||||
json.dump(todos, f, indent=2)
|
||||
|
||||
return f"Todo deleted: {todo_id}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Tools discovery
|
||||
@register_tool("praisonai.tools.list")
|
||||
def tools_list() -> str:
|
||||
"""List available tools."""
|
||||
try:
|
||||
from praisonaiagents.tools import get_available_tools
|
||||
tools = get_available_tools()
|
||||
return str(tools)
|
||||
except ImportError:
|
||||
return "Error: Tools module not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.tools.info")
|
||||
def tools_info(tool_name: str) -> str:
|
||||
"""Get information about a tool."""
|
||||
try:
|
||||
from praisonaiagents.tools import get_tool_info
|
||||
info = get_tool_info(tool_name)
|
||||
return str(info)
|
||||
except ImportError:
|
||||
return "Error: Tools module not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.tools.search")
|
||||
def tools_search(query: str) -> str:
|
||||
"""Search for tools by name or description."""
|
||||
try:
|
||||
from praisonaiagents.tools import search_tools
|
||||
results = search_tools(query)
|
||||
return str(results)
|
||||
except ImportError:
|
||||
return "Error: Tools module not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Schedule tools
|
||||
@register_tool("praisonai.schedule.list")
|
||||
def schedule_list() -> str:
|
||||
"""List scheduled tasks."""
|
||||
try:
|
||||
from praisonai.agent_scheduler import AgentScheduler
|
||||
scheduler = AgentScheduler()
|
||||
tasks = scheduler.list_tasks()
|
||||
return str(tasks)
|
||||
except ImportError:
|
||||
return "Error: Scheduler not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.schedule.add")
|
||||
def schedule_add(
|
||||
task_name: str,
|
||||
cron: str,
|
||||
workflow_path: str,
|
||||
) -> str:
|
||||
"""Add a scheduled task."""
|
||||
try:
|
||||
from praisonai.agent_scheduler import AgentScheduler
|
||||
scheduler = AgentScheduler()
|
||||
scheduler.add_task(task_name, cron, workflow_path)
|
||||
return f"Task scheduled: {task_name}"
|
||||
except ImportError:
|
||||
return "Error: Scheduler not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.schedule.remove")
|
||||
def schedule_remove(task_name: str) -> str:
|
||||
"""Remove a scheduled task."""
|
||||
try:
|
||||
from praisonai.agent_scheduler import AgentScheduler
|
||||
scheduler = AgentScheduler()
|
||||
scheduler.remove_task(task_name)
|
||||
return f"Task removed: {task_name}"
|
||||
except ImportError:
|
||||
return "Error: Scheduler not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Deploy tools
|
||||
@register_tool("praisonai.deploy.validate")
|
||||
def deploy_validate(config_path: str = "deploy.yaml") -> str:
|
||||
"""Validate deployment configuration."""
|
||||
try:
|
||||
import yaml
|
||||
with open(config_path, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
required = ["name", "type"]
|
||||
missing = [k for k in required if k not in config]
|
||||
if missing:
|
||||
return f"Invalid config: missing {missing}"
|
||||
|
||||
return f"Deployment config valid: {config_path}"
|
||||
except FileNotFoundError:
|
||||
return f"Config not found: {config_path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.deploy.status")
|
||||
def deploy_status(deployment_name: Optional[str] = None) -> str:
|
||||
"""Get deployment status."""
|
||||
try:
|
||||
from praisonai.deploy import get_deployment_status
|
||||
status = get_deployment_status(deployment_name)
|
||||
return str(status)
|
||||
except ImportError:
|
||||
return "Error: Deploy module not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Doctor/diagnostics tools
|
||||
@register_tool("praisonai.doctor.env")
|
||||
def doctor_env() -> str:
|
||||
"""Check environment configuration."""
|
||||
try:
|
||||
import os
|
||||
checks = {
|
||||
"OPENAI_API_KEY": bool(os.environ.get("OPENAI_API_KEY")),
|
||||
"ANTHROPIC_API_KEY": bool(os.environ.get("ANTHROPIC_API_KEY")),
|
||||
"GOOGLE_API_KEY": bool(os.environ.get("GOOGLE_API_KEY")),
|
||||
}
|
||||
return str(checks)
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.doctor.config")
|
||||
def doctor_config() -> str:
|
||||
"""Check configuration files."""
|
||||
try:
|
||||
import os
|
||||
config_files = [
|
||||
"agents.yaml",
|
||||
"config.yaml",
|
||||
".env",
|
||||
"pyproject.toml",
|
||||
]
|
||||
results = {}
|
||||
for f in config_files:
|
||||
results[f] = os.path.exists(f)
|
||||
return str(results)
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.doctor.mcp")
|
||||
def doctor_mcp() -> str:
|
||||
"""Check MCP server configuration."""
|
||||
try:
|
||||
from ..registry import get_tool_registry, get_resource_registry, get_prompt_registry
|
||||
|
||||
tools = get_tool_registry().list_all()
|
||||
resources = get_resource_registry().list_all()
|
||||
prompts = get_prompt_registry().list_all()
|
||||
|
||||
return f"MCP Status: {len(tools)} tools, {len(resources)} resources, {len(prompts)} prompts"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Eval tools
|
||||
@register_tool("praisonai.eval.accuracy")
|
||||
def eval_accuracy(
|
||||
agent_config: str,
|
||||
input_text: str,
|
||||
expected_output: str,
|
||||
iterations: int = 3,
|
||||
) -> str:
|
||||
"""Run accuracy evaluation on an agent."""
|
||||
try:
|
||||
from praisonaiagents.eval import AccuracyEval
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent.from_yaml(agent_config)
|
||||
eval_runner = AccuracyEval(
|
||||
agent=agent,
|
||||
input=input_text,
|
||||
expected_output=expected_output,
|
||||
num_iterations=iterations,
|
||||
)
|
||||
result = eval_runner.run()
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Eval module not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.eval.performance")
|
||||
def eval_performance(
|
||||
agent_config: str,
|
||||
input_text: str,
|
||||
iterations: int = 10,
|
||||
) -> str:
|
||||
"""Run performance evaluation on an agent."""
|
||||
try:
|
||||
from praisonaiagents.eval import PerformanceEval
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent.from_yaml(agent_config)
|
||||
eval_runner = PerformanceEval(
|
||||
func=lambda: agent.chat(input_text),
|
||||
num_iterations=iterations,
|
||||
)
|
||||
result = eval_runner.run()
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Eval module not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# MCP config management
|
||||
@register_tool("praisonai.mcp_config.list")
|
||||
def mcp_config_list() -> str:
|
||||
"""List MCP server configurations."""
|
||||
try:
|
||||
from praisonaiagents.memory.mcp_config import MCPConfigManager
|
||||
manager = MCPConfigManager()
|
||||
configs = manager.list_configs()
|
||||
return str(configs)
|
||||
except ImportError:
|
||||
return "Error: MCP config manager not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.mcp_config.show")
|
||||
def mcp_config_show(config_name: str) -> str:
|
||||
"""Show MCP server configuration."""
|
||||
try:
|
||||
from praisonaiagents.memory.mcp_config import MCPConfigManager
|
||||
manager = MCPConfigManager()
|
||||
config = manager.get_config(config_name)
|
||||
return str(config)
|
||||
except ImportError:
|
||||
return "Error: MCP config manager not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.mcp_config.enable")
|
||||
def mcp_config_enable(config_name: str) -> str:
|
||||
"""Enable an MCP server configuration."""
|
||||
try:
|
||||
from praisonaiagents.memory.mcp_config import MCPConfigManager
|
||||
manager = MCPConfigManager()
|
||||
manager.enable_config(config_name)
|
||||
return f"MCP config enabled: {config_name}"
|
||||
except ImportError:
|
||||
return "Error: MCP config manager not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.mcp_config.disable")
|
||||
def mcp_config_disable(config_name: str) -> str:
|
||||
"""Disable an MCP server configuration."""
|
||||
try:
|
||||
from praisonaiagents.memory.mcp_config import MCPConfigManager
|
||||
manager = MCPConfigManager()
|
||||
manager.disable_config(config_name)
|
||||
return f"MCP config disabled: {config_name}"
|
||||
except ImportError:
|
||||
return "Error: MCP config manager not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
logger.info("Registered CLI MCP tools")
|
||||
@@ -0,0 +1,462 @@
|
||||
"""
|
||||
Extended Capabilities Adapter
|
||||
|
||||
Maps additional PraisonAI capabilities to MCP tools:
|
||||
- Videos
|
||||
- Files
|
||||
- Batches
|
||||
- Vector Stores
|
||||
- OCR
|
||||
- Assistants
|
||||
- Fine-tuning
|
||||
- A2A
|
||||
- Containers
|
||||
- Skills
|
||||
- Realtime
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from ..registry import register_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_extended_capability_tools() -> None:
|
||||
"""Register extended capability-based MCP tools."""
|
||||
|
||||
# Video tools
|
||||
@register_tool("praisonai.videos.generate")
|
||||
def videos_generate(
|
||||
prompt: str,
|
||||
model: str = "runway-gen3",
|
||||
duration: int = 4,
|
||||
) -> str:
|
||||
"""Generate video from text prompt."""
|
||||
try:
|
||||
from praisonai.capabilities import video_generate
|
||||
result = video_generate(prompt=prompt, model=model, duration=duration)
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Video generation not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# File tools
|
||||
@register_tool("praisonai.files.create")
|
||||
def files_create(
|
||||
file_path: str,
|
||||
purpose: str = "assistants",
|
||||
) -> str:
|
||||
"""Upload a file."""
|
||||
try:
|
||||
from praisonai.capabilities import file_create
|
||||
result = file_create(file=file_path, purpose=purpose)
|
||||
if hasattr(result, "id"):
|
||||
return f"File created: {result.id}"
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: File operations not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.files.list")
|
||||
def files_list(purpose: Optional[str] = None) -> str:
|
||||
"""List uploaded files."""
|
||||
try:
|
||||
from praisonai.capabilities import file_list
|
||||
result = file_list(purpose=purpose)
|
||||
if hasattr(result, "data"):
|
||||
files = [{"id": f.id, "filename": f.filename} for f in result.data]
|
||||
return str(files)
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: File operations not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.files.retrieve")
|
||||
def files_retrieve(file_id: str) -> str:
|
||||
"""Retrieve file metadata."""
|
||||
try:
|
||||
from praisonai.capabilities import file_retrieve
|
||||
result = file_retrieve(file_id=file_id)
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: File operations not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.files.delete")
|
||||
def files_delete(file_id: str) -> str:
|
||||
"""Delete a file."""
|
||||
try:
|
||||
from praisonai.capabilities import file_delete
|
||||
file_delete(file_id=file_id)
|
||||
return f"File deleted: {file_id}"
|
||||
except ImportError:
|
||||
return "Error: File operations not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.files.content")
|
||||
def files_content(file_id: str) -> str:
|
||||
"""Get file content."""
|
||||
try:
|
||||
from praisonai.capabilities import file_content
|
||||
result = file_content(file_id=file_id)
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: File operations not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Batch tools
|
||||
@register_tool("praisonai.batches.create")
|
||||
def batches_create(
|
||||
input_file_id: str,
|
||||
endpoint: str = "/v1/chat/completions",
|
||||
completion_window: str = "24h",
|
||||
) -> str:
|
||||
"""Create a batch processing job."""
|
||||
try:
|
||||
from praisonai.capabilities import batch_create
|
||||
result = batch_create(
|
||||
input_file_id=input_file_id,
|
||||
endpoint=endpoint,
|
||||
completion_window=completion_window,
|
||||
)
|
||||
if hasattr(result, "id"):
|
||||
return f"Batch created: {result.id}"
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Batch operations not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.batches.list")
|
||||
def batches_list() -> str:
|
||||
"""List batch jobs."""
|
||||
try:
|
||||
from praisonai.capabilities import batch_list
|
||||
result = batch_list()
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Batch operations not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.batches.retrieve")
|
||||
def batches_retrieve(batch_id: str) -> str:
|
||||
"""Retrieve batch job status."""
|
||||
try:
|
||||
from praisonai.capabilities import batch_retrieve
|
||||
result = batch_retrieve(batch_id=batch_id)
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Batch operations not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.batches.cancel")
|
||||
def batches_cancel(batch_id: str) -> str:
|
||||
"""Cancel a batch job."""
|
||||
try:
|
||||
from praisonai.capabilities import batch_cancel
|
||||
batch_cancel(batch_id=batch_id)
|
||||
return f"Batch cancelled: {batch_id}"
|
||||
except ImportError:
|
||||
return "Error: Batch operations not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Vector Store tools
|
||||
@register_tool("praisonai.vector_stores.create")
|
||||
def vector_stores_create(
|
||||
name: str,
|
||||
file_ids: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
"""Create a vector store."""
|
||||
try:
|
||||
from praisonai.capabilities import vector_store_create
|
||||
result = vector_store_create(name=name, file_ids=file_ids or [])
|
||||
if hasattr(result, "id"):
|
||||
return f"Vector store created: {result.id}"
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Vector store operations not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.vector_stores.search")
|
||||
def vector_stores_search(
|
||||
vector_store_id: str,
|
||||
query: str,
|
||||
max_results: int = 10,
|
||||
) -> str:
|
||||
"""Search a vector store."""
|
||||
try:
|
||||
from praisonai.capabilities import vector_store_search
|
||||
result = vector_store_search(
|
||||
vector_store_id=vector_store_id,
|
||||
query=query,
|
||||
max_num_results=max_results,
|
||||
)
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Vector store operations not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.vector_stores.file_create")
|
||||
def vector_stores_file_create(
|
||||
vector_store_id: str,
|
||||
file_id: str,
|
||||
) -> str:
|
||||
"""Add a file to a vector store."""
|
||||
try:
|
||||
from praisonai.capabilities import vector_store_file_create
|
||||
vector_store_file_create(
|
||||
vector_store_id=vector_store_id,
|
||||
file_id=file_id,
|
||||
)
|
||||
return f"File {file_id} added to vector store {vector_store_id}"
|
||||
except ImportError:
|
||||
return "Error: Vector store operations not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.vector_stores.file_list")
|
||||
def vector_stores_file_list(vector_store_id: str) -> str:
|
||||
"""List files in a vector store."""
|
||||
try:
|
||||
from praisonai.capabilities import vector_store_file_list
|
||||
result = vector_store_file_list(vector_store_id=vector_store_id)
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Vector store operations not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# OCR tools
|
||||
@register_tool("praisonai.ocr")
|
||||
def ocr_extract(
|
||||
image_path: str,
|
||||
model: str = "gpt-4o",
|
||||
) -> str:
|
||||
"""Extract text from image using OCR."""
|
||||
try:
|
||||
from praisonai.capabilities import ocr
|
||||
result = ocr(file=image_path, model=model)
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: OCR not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Assistant tools
|
||||
@register_tool("praisonai.assistants.create")
|
||||
def assistants_create(
|
||||
name: str,
|
||||
instructions: str,
|
||||
model: str = "gpt-4o",
|
||||
tools: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
"""Create an OpenAI-style assistant."""
|
||||
try:
|
||||
from praisonai.capabilities import assistant_create
|
||||
result = assistant_create(
|
||||
name=name,
|
||||
instructions=instructions,
|
||||
model=model,
|
||||
tools=tools or [],
|
||||
)
|
||||
if hasattr(result, "id"):
|
||||
return f"Assistant created: {result.id}"
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Assistants not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.assistants.list")
|
||||
def assistants_list() -> str:
|
||||
"""List assistants."""
|
||||
try:
|
||||
from praisonai.capabilities import assistant_list
|
||||
result = assistant_list()
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Assistants not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Fine-tuning tools
|
||||
@register_tool("praisonai.fine_tuning.create")
|
||||
def fine_tuning_create(
|
||||
training_file: str,
|
||||
model: str = "gpt-4o-mini",
|
||||
suffix: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Create a fine-tuning job."""
|
||||
try:
|
||||
from praisonai.capabilities import fine_tuning_create
|
||||
result = fine_tuning_create(
|
||||
training_file=training_file,
|
||||
model=model,
|
||||
suffix=suffix,
|
||||
)
|
||||
if hasattr(result, "id"):
|
||||
return f"Fine-tuning job created: {result.id}"
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Fine-tuning not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.fine_tuning.list")
|
||||
def fine_tuning_list() -> str:
|
||||
"""List fine-tuning jobs."""
|
||||
try:
|
||||
from praisonai.capabilities import fine_tuning_list
|
||||
result = fine_tuning_list()
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Fine-tuning not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# A2A tools
|
||||
@register_tool("praisonai.a2a.send")
|
||||
def a2a_send(
|
||||
agent_url: str,
|
||||
message: str,
|
||||
task_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Send message to another agent via A2A protocol."""
|
||||
try:
|
||||
from praisonai.capabilities import a2a_send
|
||||
result = a2a_send(
|
||||
agent_url=agent_url,
|
||||
message=message,
|
||||
task_id=task_id,
|
||||
)
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: A2A not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Container tools
|
||||
@register_tool("praisonai.containers.create")
|
||||
def containers_create(
|
||||
name: str,
|
||||
image: str = "python:3.11",
|
||||
) -> str:
|
||||
"""Create a container for code execution."""
|
||||
try:
|
||||
from praisonai.capabilities import container_create
|
||||
result = container_create(name=name, image=image)
|
||||
if hasattr(result, "id"):
|
||||
return f"Container created: {result.id}"
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Containers not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.containers.file_read")
|
||||
def containers_file_read(
|
||||
container_id: str,
|
||||
file_path: str,
|
||||
) -> str:
|
||||
"""Read a file from a container."""
|
||||
try:
|
||||
from praisonai.capabilities import container_file_read
|
||||
result = container_file_read(
|
||||
container_id=container_id,
|
||||
file_path=file_path,
|
||||
)
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Containers not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.containers.file_write")
|
||||
def containers_file_write(
|
||||
container_id: str,
|
||||
file_path: str,
|
||||
content: str,
|
||||
) -> str:
|
||||
"""Write a file to a container."""
|
||||
try:
|
||||
from praisonai.capabilities import container_file_write
|
||||
container_file_write(
|
||||
container_id=container_id,
|
||||
file_path=file_path,
|
||||
content=content,
|
||||
)
|
||||
return f"File {file_path} written to container {container_id}"
|
||||
except ImportError:
|
||||
return "Error: Containers not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Skills tools
|
||||
@register_tool("praisonai.skills.list")
|
||||
def skills_list() -> str:
|
||||
"""List available skills."""
|
||||
try:
|
||||
from praisonai.capabilities import skill_list
|
||||
result = skill_list()
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Skills not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.skills.load")
|
||||
def skills_load(skill_path: str) -> str:
|
||||
"""Load a skill from path."""
|
||||
try:
|
||||
from praisonai.capabilities import skill_load
|
||||
result = skill_load(skill_path=skill_path)
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Skills not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Realtime tools
|
||||
@register_tool("praisonai.realtime.connect")
|
||||
def realtime_connect(
|
||||
model: str = "gpt-4o-realtime-preview",
|
||||
) -> str:
|
||||
"""Connect to realtime API."""
|
||||
try:
|
||||
from praisonai.capabilities import realtime_connect
|
||||
result = realtime_connect(model=model)
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Realtime not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.realtime.send")
|
||||
def realtime_send(
|
||||
session_id: str,
|
||||
message: str,
|
||||
) -> str:
|
||||
"""Send message to realtime session."""
|
||||
try:
|
||||
from praisonai.capabilities import realtime_send
|
||||
result = realtime_send(session_id=session_id, message=message)
|
||||
return str(result)
|
||||
except ImportError:
|
||||
return "Error: Realtime not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
logger.info("Registered extended capability MCP tools")
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Knowledge Adapter
|
||||
|
||||
Maps PraisonAI knowledge operations to MCP tools.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from ..registry import register_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_knowledge_tools() -> None:
|
||||
"""Register knowledge-related MCP tools."""
|
||||
|
||||
@register_tool("praisonai.knowledge.add")
|
||||
def knowledge_add(
|
||||
source: str,
|
||||
source_type: str = "text",
|
||||
) -> str:
|
||||
"""Add a knowledge source (text, file, or URL)."""
|
||||
try:
|
||||
from praisonaiagents.knowledge import Knowledge
|
||||
|
||||
knowledge = Knowledge()
|
||||
knowledge.add(source, source_type=source_type)
|
||||
return f"Knowledge added from {source_type}"
|
||||
except ImportError:
|
||||
return "Error: Knowledge module not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.knowledge.query")
|
||||
def knowledge_query(
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
) -> str:
|
||||
"""Query the knowledge base."""
|
||||
try:
|
||||
from praisonaiagents.knowledge import Knowledge
|
||||
|
||||
knowledge = Knowledge()
|
||||
results = knowledge.query(query, limit=limit)
|
||||
return str(results)
|
||||
except ImportError:
|
||||
return "Error: Knowledge module not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.knowledge.list")
|
||||
def knowledge_list() -> str:
|
||||
"""List all knowledge sources."""
|
||||
try:
|
||||
from praisonaiagents.knowledge import Knowledge
|
||||
|
||||
knowledge = Knowledge()
|
||||
sources = knowledge.list_sources()
|
||||
return str(sources)
|
||||
except ImportError:
|
||||
return "Error: Knowledge module not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.knowledge.clear")
|
||||
def knowledge_clear() -> str:
|
||||
"""Clear all knowledge."""
|
||||
try:
|
||||
from praisonaiagents.knowledge import Knowledge
|
||||
|
||||
knowledge = Knowledge()
|
||||
knowledge.clear()
|
||||
return "Knowledge cleared"
|
||||
except ImportError:
|
||||
return "Error: Knowledge module not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.knowledge.stats")
|
||||
def knowledge_stats() -> str:
|
||||
"""Get knowledge base statistics."""
|
||||
try:
|
||||
from praisonaiagents.knowledge import Knowledge
|
||||
|
||||
knowledge = Knowledge()
|
||||
stats = knowledge.stats()
|
||||
return str(stats)
|
||||
except ImportError:
|
||||
return "Error: Knowledge module not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
logger.info("Registered knowledge MCP tools")
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Memory Adapter
|
||||
|
||||
Maps PraisonAI memory operations to MCP tools.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from ..registry import register_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_memory_tools() -> None:
|
||||
"""Register memory-related MCP tools."""
|
||||
|
||||
@register_tool("praisonai.memory.show")
|
||||
def memory_show(session_id: Optional[str] = None) -> str:
|
||||
"""Show memory contents for a session."""
|
||||
try:
|
||||
from praisonaiagents.memory import Memory
|
||||
|
||||
memory = Memory()
|
||||
if session_id:
|
||||
data = memory.get_session(session_id)
|
||||
else:
|
||||
data = memory.get_all()
|
||||
return str(data)
|
||||
except ImportError:
|
||||
return "Error: Memory module not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.memory.add")
|
||||
def memory_add(
|
||||
content: str,
|
||||
session_id: Optional[str] = None,
|
||||
metadata: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Add content to memory."""
|
||||
try:
|
||||
from praisonaiagents.memory import Memory
|
||||
import json
|
||||
|
||||
memory = Memory()
|
||||
meta = json.loads(metadata) if metadata else {}
|
||||
memory.add(content, session_id=session_id, metadata=meta)
|
||||
return "Memory added successfully"
|
||||
except ImportError:
|
||||
return "Error: Memory module not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.memory.search")
|
||||
def memory_search(
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
) -> str:
|
||||
"""Search memory for relevant content."""
|
||||
try:
|
||||
from praisonaiagents.memory import Memory
|
||||
|
||||
memory = Memory()
|
||||
results = memory.search(query, limit=limit)
|
||||
return str(results)
|
||||
except ImportError:
|
||||
return "Error: Memory module not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.memory.clear")
|
||||
def memory_clear(session_id: Optional[str] = None) -> str:
|
||||
"""Clear memory contents."""
|
||||
try:
|
||||
from praisonaiagents.memory import Memory
|
||||
|
||||
memory = Memory()
|
||||
if session_id:
|
||||
memory.clear_session(session_id)
|
||||
return f"Session {session_id} cleared"
|
||||
else:
|
||||
memory.clear_all()
|
||||
return "All memory cleared"
|
||||
except ImportError:
|
||||
return "Error: Memory module not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@register_tool("praisonai.memory.sessions")
|
||||
def memory_sessions() -> str:
|
||||
"""List all memory sessions."""
|
||||
try:
|
||||
from praisonaiagents.memory import Memory
|
||||
|
||||
memory = Memory()
|
||||
sessions = memory.list_sessions()
|
||||
return str(sessions)
|
||||
except ImportError:
|
||||
return "Error: Memory module not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
logger.info("Registered memory MCP tools")
|
||||
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
MCP Prompts Adapter
|
||||
|
||||
Registers MCP prompts for PraisonAI:
|
||||
- deep-research
|
||||
- code-review
|
||||
- workflow-auto
|
||||
- guardrail-check
|
||||
- context-engineering
|
||||
- eval-criteria
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from ..registry import register_prompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_mcp_prompts() -> None:
|
||||
"""Register MCP prompts."""
|
||||
|
||||
@register_prompt(
|
||||
"deep-research",
|
||||
description="Generate a deep research prompt for comprehensive topic analysis",
|
||||
arguments=[
|
||||
{"name": "topic", "description": "Research topic", "required": True},
|
||||
{"name": "depth", "description": "Research depth (shallow/medium/deep)", "required": False},
|
||||
{"name": "focus_areas", "description": "Specific areas to focus on", "required": False},
|
||||
],
|
||||
)
|
||||
def deep_research_prompt(
|
||||
topic: str,
|
||||
depth: str = "medium",
|
||||
focus_areas: str = "",
|
||||
) -> list:
|
||||
"""Generate deep research prompt."""
|
||||
depth_instructions = {
|
||||
"shallow": "Provide a brief overview with key points.",
|
||||
"medium": "Conduct thorough research with multiple perspectives and sources.",
|
||||
"deep": "Perform exhaustive research covering all aspects, historical context, current state, and future implications.",
|
||||
}
|
||||
|
||||
focus_text = f"\n\nFocus particularly on: {focus_areas}" if focus_areas else ""
|
||||
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": f"""Research the following topic comprehensively:
|
||||
|
||||
Topic: {topic}
|
||||
|
||||
Research Depth: {depth}
|
||||
{depth_instructions.get(depth, depth_instructions["medium"])}
|
||||
{focus_text}
|
||||
|
||||
Please provide:
|
||||
1. Executive Summary
|
||||
2. Key Findings
|
||||
3. Detailed Analysis
|
||||
4. Sources and References
|
||||
5. Conclusions and Recommendations"""
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@register_prompt(
|
||||
"code-review",
|
||||
description="Generate a code review prompt for analyzing code quality",
|
||||
arguments=[
|
||||
{"name": "code", "description": "Code to review", "required": True},
|
||||
{"name": "language", "description": "Programming language", "required": False},
|
||||
{"name": "focus", "description": "Review focus (security/performance/style/all)", "required": False},
|
||||
],
|
||||
)
|
||||
def code_review_prompt(
|
||||
code: str,
|
||||
language: str = "auto",
|
||||
focus: str = "all",
|
||||
) -> list:
|
||||
"""Generate code review prompt."""
|
||||
focus_areas = {
|
||||
"security": "Focus on security vulnerabilities, input validation, and potential exploits.",
|
||||
"performance": "Focus on performance optimizations, algorithmic efficiency, and resource usage.",
|
||||
"style": "Focus on code style, readability, naming conventions, and best practices.",
|
||||
"all": "Review all aspects: security, performance, style, and correctness.",
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": f"""Please review the following {language} code:
|
||||
|
||||
```{language}
|
||||
{code}
|
||||
```
|
||||
|
||||
Review Focus: {focus}
|
||||
{focus_areas.get(focus, focus_areas["all"])}
|
||||
|
||||
Please provide:
|
||||
1. Overall Assessment
|
||||
2. Issues Found (with severity: critical/major/minor)
|
||||
3. Suggestions for Improvement
|
||||
4. Positive Aspects
|
||||
5. Refactored Code (if applicable)"""
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@register_prompt(
|
||||
"workflow-auto",
|
||||
description="Generate a workflow auto-generation prompt",
|
||||
arguments=[
|
||||
{"name": "task", "description": "Task description", "required": True},
|
||||
{"name": "pattern", "description": "Workflow pattern (sequential/parallel/routing)", "required": False},
|
||||
{"name": "agents_count", "description": "Number of agents to use", "required": False},
|
||||
],
|
||||
)
|
||||
def workflow_auto_prompt(
|
||||
task: str,
|
||||
pattern: str = "sequential",
|
||||
agents_count: str = "auto",
|
||||
) -> list:
|
||||
"""Generate workflow auto-generation prompt."""
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": f"""Generate a PraisonAI workflow configuration for the following task:
|
||||
|
||||
Task: {task}
|
||||
|
||||
Workflow Pattern: {pattern}
|
||||
Number of Agents: {agents_count}
|
||||
|
||||
Please generate a YAML configuration that includes:
|
||||
1. Framework specification
|
||||
2. Topic/goal definition
|
||||
3. Agent roles with:
|
||||
- Role name
|
||||
- Goal
|
||||
- Backstory
|
||||
- Tasks with descriptions and expected outputs
|
||||
4. Appropriate tools for each agent
|
||||
|
||||
Output the complete agents.yaml configuration."""
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@register_prompt(
|
||||
"guardrail-check",
|
||||
description="Generate a guardrail check prompt for content safety",
|
||||
arguments=[
|
||||
{"name": "content", "description": "Content to check", "required": True},
|
||||
{"name": "rules", "description": "Specific rules to enforce", "required": False},
|
||||
],
|
||||
)
|
||||
def guardrail_check_prompt(
|
||||
content: str,
|
||||
rules: str = "",
|
||||
) -> list:
|
||||
"""Generate guardrail check prompt."""
|
||||
rules_text = f"\n\nAdditional Rules:\n{rules}" if rules else ""
|
||||
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": f"""Analyze the following content for safety and policy compliance:
|
||||
|
||||
Content:
|
||||
{content}
|
||||
{rules_text}
|
||||
|
||||
Please check for:
|
||||
1. Harmful or dangerous content
|
||||
2. Personal information exposure
|
||||
3. Inappropriate language
|
||||
4. Policy violations
|
||||
5. Factual accuracy concerns
|
||||
|
||||
Provide:
|
||||
- Safety Score (1-10)
|
||||
- Issues Found
|
||||
- Recommendations
|
||||
- Modified Safe Version (if needed)"""
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@register_prompt(
|
||||
"context-engineering",
|
||||
description="Generate a context engineering prompt for optimal LLM interaction",
|
||||
arguments=[
|
||||
{"name": "goal", "description": "What you want to achieve", "required": True},
|
||||
{"name": "constraints", "description": "Any constraints or requirements", "required": False},
|
||||
],
|
||||
)
|
||||
def context_engineering_prompt(
|
||||
goal: str,
|
||||
constraints: str = "",
|
||||
) -> list:
|
||||
"""Generate context engineering prompt."""
|
||||
constraints_text = f"\n\nConstraints:\n{constraints}" if constraints else ""
|
||||
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": f"""Help me engineer an optimal prompt/context for the following goal:
|
||||
|
||||
Goal: {goal}
|
||||
{constraints_text}
|
||||
|
||||
Please provide:
|
||||
1. Optimal System Prompt
|
||||
2. User Prompt Template
|
||||
3. Few-shot Examples (if helpful)
|
||||
4. Recommended Model Settings (temperature, etc.)
|
||||
5. Expected Output Format
|
||||
6. Potential Edge Cases to Handle"""
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@register_prompt(
|
||||
"eval-criteria",
|
||||
description="Generate evaluation criteria for agent output assessment",
|
||||
arguments=[
|
||||
{"name": "task_type", "description": "Type of task being evaluated", "required": True},
|
||||
{"name": "output_format", "description": "Expected output format", "required": False},
|
||||
],
|
||||
)
|
||||
def eval_criteria_prompt(
|
||||
task_type: str,
|
||||
output_format: str = "text",
|
||||
) -> list:
|
||||
"""Generate evaluation criteria prompt."""
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": f"""Generate comprehensive evaluation criteria for the following:
|
||||
|
||||
Task Type: {task_type}
|
||||
Expected Output Format: {output_format}
|
||||
|
||||
Please provide:
|
||||
1. Accuracy Criteria (what makes output correct)
|
||||
2. Quality Metrics (completeness, clarity, relevance)
|
||||
3. Format Compliance Checks
|
||||
4. Edge Case Handling Assessment
|
||||
5. Scoring Rubric (1-10 scale with descriptions)
|
||||
6. Example Good/Bad Outputs"""
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@register_prompt(
|
||||
"agent-instructions",
|
||||
description="Generate optimal agent instructions for a specific role",
|
||||
arguments=[
|
||||
{"name": "role", "description": "Agent role/persona", "required": True},
|
||||
{"name": "capabilities", "description": "Available capabilities/tools", "required": False},
|
||||
],
|
||||
)
|
||||
def agent_instructions_prompt(
|
||||
role: str,
|
||||
capabilities: str = "",
|
||||
) -> list:
|
||||
"""Generate agent instructions prompt."""
|
||||
caps_text = f"\n\nAvailable Capabilities:\n{capabilities}" if capabilities else ""
|
||||
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": f"""Generate optimal instructions for an AI agent with the following role:
|
||||
|
||||
Role: {role}
|
||||
{caps_text}
|
||||
|
||||
Please provide:
|
||||
1. Core Identity Statement
|
||||
2. Primary Objectives
|
||||
3. Behavioral Guidelines
|
||||
4. Communication Style
|
||||
5. Decision-Making Framework
|
||||
6. Error Handling Approach
|
||||
7. Collaboration Guidelines (if working with other agents)"""
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
logger.info("Registered MCP prompts")
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
MCP Resources Adapter
|
||||
|
||||
Registers MCP resources for PraisonAI:
|
||||
- praisonai://memory/sessions
|
||||
- praisonai://memory/{session_id}
|
||||
- praisonai://workflows
|
||||
- praisonai://tools
|
||||
- praisonai://agents
|
||||
- praisonai://knowledge/sources
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from ..registry import register_resource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_mcp_resources() -> None:
|
||||
"""Register MCP resources."""
|
||||
|
||||
@register_resource("praisonai://memory/sessions")
|
||||
def memory_sessions_resource() -> dict:
|
||||
"""List all memory sessions."""
|
||||
try:
|
||||
from praisonaiagents.memory import Memory
|
||||
memory = Memory()
|
||||
sessions = memory.list_sessions()
|
||||
return {"sessions": sessions}
|
||||
except ImportError:
|
||||
return {"error": "Memory module not available", "sessions": []}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "sessions": []}
|
||||
|
||||
@register_resource("praisonai://workflows")
|
||||
def workflows_resource() -> dict:
|
||||
"""List available workflows in current directory."""
|
||||
try:
|
||||
import os
|
||||
import glob
|
||||
workflows = []
|
||||
for pattern in ["*.yaml", "*.yml"]:
|
||||
for f in glob.glob(pattern):
|
||||
if "agent" in f.lower() or "workflow" in f.lower():
|
||||
workflows.append(f)
|
||||
return {"workflows": workflows, "cwd": os.getcwd()}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "workflows": []}
|
||||
|
||||
@register_resource("praisonai://tools")
|
||||
def tools_resource() -> dict:
|
||||
"""List available tools."""
|
||||
try:
|
||||
from ..registry import get_tool_registry
|
||||
registry = get_tool_registry()
|
||||
tools = registry.list_schemas()
|
||||
return {"tools": [t["name"] for t in tools], "count": len(tools)}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "tools": []}
|
||||
|
||||
@register_resource("praisonai://agents")
|
||||
def agents_resource() -> dict:
|
||||
"""List agent configurations."""
|
||||
try:
|
||||
import os
|
||||
import yaml
|
||||
agents = []
|
||||
for f in ["agents.yaml", "agents.yml"]:
|
||||
if os.path.exists(f):
|
||||
with open(f, 'r') as file:
|
||||
config = yaml.safe_load(file)
|
||||
if config and "roles" in config:
|
||||
agents.extend(list(config["roles"].keys()))
|
||||
return {"agents": agents}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "agents": []}
|
||||
|
||||
@register_resource("praisonai://knowledge/sources")
|
||||
def knowledge_sources_resource() -> dict:
|
||||
"""List knowledge sources."""
|
||||
try:
|
||||
from praisonaiagents.knowledge import Knowledge
|
||||
knowledge = Knowledge()
|
||||
sources = knowledge.list_sources()
|
||||
return {"sources": sources}
|
||||
except ImportError:
|
||||
return {"error": "Knowledge module not available", "sources": []}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "sources": []}
|
||||
|
||||
@register_resource("praisonai://config")
|
||||
def config_resource() -> dict:
|
||||
"""Get current configuration."""
|
||||
try:
|
||||
import os
|
||||
config = {
|
||||
"openai_api_key_set": bool(os.environ.get("OPENAI_API_KEY")),
|
||||
"anthropic_api_key_set": bool(os.environ.get("ANTHROPIC_API_KEY")),
|
||||
"google_api_key_set": bool(os.environ.get("GOOGLE_API_KEY")),
|
||||
"cwd": os.getcwd(),
|
||||
}
|
||||
return config
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@register_resource("praisonai://mcp/status")
|
||||
def mcp_status_resource() -> dict:
|
||||
"""Get MCP server status."""
|
||||
try:
|
||||
from ..registry import get_tool_registry, get_resource_registry, get_prompt_registry
|
||||
from ..server import PROTOCOL_VERSION
|
||||
|
||||
return {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"tools_count": len(get_tool_registry().list_all()),
|
||||
"resources_count": len(get_resource_registry().list_all()),
|
||||
"prompts_count": len(get_prompt_registry().list_all()),
|
||||
"status": "healthy",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "status": "error"}
|
||||
|
||||
logger.info("Registered MCP resources")
|
||||
@@ -0,0 +1,517 @@
|
||||
"""
|
||||
MCP Server CLI Integration
|
||||
|
||||
Provides CLI commands for managing the MCP server:
|
||||
- praisonai mcp serve --transport stdio|http-stream
|
||||
- praisonai mcp list-tools
|
||||
- praisonai mcp config-generate
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MCPServerCLI:
|
||||
"""CLI handler for MCP server commands."""
|
||||
|
||||
EXIT_SUCCESS = 0
|
||||
EXIT_ERROR = 1
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize CLI handler."""
|
||||
self._server = None
|
||||
|
||||
def handle(self, args: List[str]) -> int:
|
||||
"""
|
||||
Handle MCP CLI subcommand.
|
||||
|
||||
Args:
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
Exit code
|
||||
"""
|
||||
if not args:
|
||||
self._print_help()
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
command = args[0]
|
||||
remaining = args[1:]
|
||||
|
||||
commands = {
|
||||
"serve": self.cmd_serve,
|
||||
"list-tools": self.cmd_list_tools,
|
||||
"list-resources": self.cmd_list_resources,
|
||||
"list-prompts": self.cmd_list_prompts,
|
||||
"config-generate": self.cmd_config_generate,
|
||||
"doctor": self.cmd_doctor,
|
||||
"help": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
"--help": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
"-h": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
}
|
||||
|
||||
if command in commands:
|
||||
return commands[command](remaining)
|
||||
else:
|
||||
self._print_error(f"Unknown command: {command}")
|
||||
self._print_help()
|
||||
return self.EXIT_ERROR
|
||||
|
||||
def _print_help(self) -> None:
|
||||
"""Print help message."""
|
||||
help_text = """
|
||||
[bold cyan]PraisonAI MCP Server (Protocol Version 2025-11-25)[/bold cyan]
|
||||
|
||||
Run PraisonAI as an MCP server for Claude Desktop, Cursor, Windsurf, and other MCP clients.
|
||||
|
||||
[bold]Usage:[/bold]
|
||||
praisonai mcp <command> [options]
|
||||
|
||||
[bold]Commands:[/bold]
|
||||
serve Start the MCP server
|
||||
list-tools List available MCP tools
|
||||
list-resources List available MCP resources
|
||||
list-prompts List available MCP prompts
|
||||
config-generate Generate client configuration
|
||||
doctor Check MCP server health
|
||||
|
||||
[bold]Serve Options:[/bold]
|
||||
--transport <type> Transport: stdio (default) or http-stream
|
||||
--host <host> HTTP host (default: 127.0.0.1)
|
||||
--port <port> HTTP port (default: 8080)
|
||||
--endpoint <path> HTTP endpoint (default: /mcp)
|
||||
--api-key <key> API key for authentication
|
||||
--name <name> Server name (default: praisonai)
|
||||
--response-mode <mode> Response mode: batch (default) or stream
|
||||
--cors-origins <list> Comma-separated CORS origins
|
||||
--allowed-origins <list> Comma-separated allowed origins for security
|
||||
--session-ttl <secs> Session TTL in seconds (default: 3600)
|
||||
--no-termination Disable client session termination
|
||||
--resumability Enable SSE resumability (default: true)
|
||||
--log-level <level> Log level: debug, info, warning, error
|
||||
--json Output in JSON format
|
||||
|
||||
[bold]Config Generate Options:[/bold]
|
||||
--client <name> Client type: claude-desktop, cursor, vscode, windsurf
|
||||
--output <path> Output file path
|
||||
--transport <type> Transport for config
|
||||
|
||||
[bold]Examples:[/bold]
|
||||
# Start STDIO server (for Claude Desktop)
|
||||
praisonai mcp serve --transport stdio
|
||||
|
||||
# Start HTTP Stream server with authentication
|
||||
praisonai mcp serve --transport http-stream --port 8080 --api-key mykey
|
||||
|
||||
# Start with custom allowed origins
|
||||
praisonai mcp serve --transport http-stream --allowed-origins "http://localhost:3000"
|
||||
|
||||
# Generate Claude Desktop config
|
||||
praisonai mcp config-generate --client claude-desktop
|
||||
|
||||
# List available tools
|
||||
praisonai mcp list-tools
|
||||
|
||||
# Check MCP server health
|
||||
praisonai mcp doctor
|
||||
"""
|
||||
self._print_rich(help_text)
|
||||
|
||||
def _print_rich(self, text: str) -> None:
|
||||
"""Print with rich formatting if available."""
|
||||
try:
|
||||
from rich import print as rprint
|
||||
rprint(text)
|
||||
except ImportError:
|
||||
import re
|
||||
plain = re.sub(r'\[/?[^\]]+\]', '', text)
|
||||
print(plain)
|
||||
|
||||
def _print_error(self, message: str) -> None:
|
||||
"""Print error message."""
|
||||
try:
|
||||
from rich import print as rprint
|
||||
rprint(f"[red]Error: {message}[/red]")
|
||||
except ImportError:
|
||||
print(f"Error: {message}", file=sys.stderr)
|
||||
|
||||
def _print_success(self, message: str) -> None:
|
||||
"""Print success message."""
|
||||
try:
|
||||
from rich import print as rprint
|
||||
rprint(f"[green]✓ {message}[/green]")
|
||||
except ImportError:
|
||||
print(f"✓ {message}")
|
||||
|
||||
def cmd_serve(self, args: List[str]) -> int:
|
||||
"""Start the MCP server."""
|
||||
parser = argparse.ArgumentParser(prog="praisonai mcp serve")
|
||||
parser.add_argument("--transport", default="stdio", choices=["stdio", "http-stream"])
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8080)
|
||||
parser.add_argument("--endpoint", default="/mcp")
|
||||
parser.add_argument("--api-key", default=None)
|
||||
parser.add_argument("--name", default="praisonai")
|
||||
parser.add_argument("--debug", action="store_true")
|
||||
parser.add_argument("--response-mode", default="batch", choices=["batch", "stream"])
|
||||
parser.add_argument("--cors-origins", default=None, help="Comma-separated CORS origins")
|
||||
parser.add_argument("--allowed-origins", default=None, help="Comma-separated allowed origins for security")
|
||||
parser.add_argument("--session-ttl", type=int, default=3600, help="Session TTL in seconds")
|
||||
parser.add_argument("--allow-termination", action="store_true", default=True, help="Allow client session termination")
|
||||
parser.add_argument("--no-termination", action="store_true", help="Disable client session termination")
|
||||
parser.add_argument("--resumability", action="store_true", default=True, help="Enable SSE resumability")
|
||||
parser.add_argument("--log-level", default="warning", choices=["debug", "info", "warning", "error"])
|
||||
parser.add_argument("--json", action="store_true", help="Output in JSON format")
|
||||
|
||||
try:
|
||||
parsed = parser.parse_args(args)
|
||||
except SystemExit:
|
||||
return self.EXIT_ERROR
|
||||
|
||||
# Configure logging
|
||||
log_levels = {
|
||||
"debug": logging.DEBUG,
|
||||
"info": logging.INFO,
|
||||
"warning": logging.WARNING,
|
||||
"error": logging.ERROR,
|
||||
}
|
||||
log_level = log_levels.get(parsed.log_level, logging.WARNING)
|
||||
if parsed.debug:
|
||||
log_level = logging.DEBUG
|
||||
logging.basicConfig(level=log_level, stream=sys.stderr)
|
||||
|
||||
try:
|
||||
from .server import MCPServer
|
||||
from .adapters import register_all
|
||||
|
||||
# Register all tools, resources, and prompts
|
||||
register_all()
|
||||
|
||||
# Create server
|
||||
server = MCPServer(
|
||||
name=parsed.name,
|
||||
instructions="PraisonAI MCP Server - AI agent capabilities exposed via MCP protocol.",
|
||||
)
|
||||
|
||||
if parsed.transport == "stdio":
|
||||
# STDIO mode - minimal output to stderr
|
||||
logger.info(f"Starting MCP server '{parsed.name}' on STDIO transport")
|
||||
server.run_stdio()
|
||||
else:
|
||||
# HTTP Stream mode
|
||||
cors_origins = parsed.cors_origins.split(",") if parsed.cors_origins else None
|
||||
allowed_origins = parsed.allowed_origins.split(",") if parsed.allowed_origins else None
|
||||
allow_termination = not parsed.no_termination
|
||||
|
||||
if not parsed.json:
|
||||
self._print_success(f"Starting MCP server '{parsed.name}' on http://{parsed.host}:{parsed.port}{parsed.endpoint}")
|
||||
|
||||
server.run_http_stream(
|
||||
host=parsed.host,
|
||||
port=parsed.port,
|
||||
endpoint=parsed.endpoint,
|
||||
api_key=parsed.api_key,
|
||||
cors_origins=cors_origins,
|
||||
allowed_origins=allowed_origins,
|
||||
session_ttl=parsed.session_ttl,
|
||||
allow_client_termination=allow_termination,
|
||||
response_mode=parsed.response_mode,
|
||||
resumability_enabled=parsed.resumability,
|
||||
)
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
except ImportError as e:
|
||||
self._print_error(f"Missing dependency: {e}")
|
||||
print("Install with: pip install praisonai[mcp]", file=sys.stderr)
|
||||
return self.EXIT_ERROR
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Server stopped by user")
|
||||
return self.EXIT_SUCCESS
|
||||
except Exception as e:
|
||||
self._print_error(str(e))
|
||||
return self.EXIT_ERROR
|
||||
|
||||
def cmd_list_tools(self, args: List[str]) -> int:
|
||||
"""List available MCP tools."""
|
||||
try:
|
||||
from .adapters import register_all_tools
|
||||
from .registry import get_tool_registry
|
||||
|
||||
# Register all tools
|
||||
register_all_tools()
|
||||
|
||||
registry = get_tool_registry()
|
||||
tools = registry.list_schemas()
|
||||
|
||||
if not tools:
|
||||
print("No tools registered")
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
print(f"\n[bold]Available MCP Tools ({len(tools)}):[/bold]\n")
|
||||
for tool in tools:
|
||||
name = tool.get("name", "unknown")
|
||||
desc = tool.get("description", "No description")
|
||||
print(f" • {name}")
|
||||
print(f" {desc}\n")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
except Exception as e:
|
||||
self._print_error(str(e))
|
||||
return self.EXIT_ERROR
|
||||
|
||||
def cmd_list_resources(self, args: List[str]) -> int:
|
||||
"""List available MCP resources."""
|
||||
try:
|
||||
from .registry import get_resource_registry
|
||||
|
||||
registry = get_resource_registry()
|
||||
resources = registry.list_schemas()
|
||||
|
||||
if not resources:
|
||||
print("No resources registered")
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
print(f"\n[bold]Available MCP Resources ({len(resources)}):[/bold]\n")
|
||||
for res in resources:
|
||||
uri = res.get("uri", "unknown")
|
||||
desc = res.get("description", "No description")
|
||||
print(f" • {uri}")
|
||||
print(f" {desc}\n")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
except Exception as e:
|
||||
self._print_error(str(e))
|
||||
return self.EXIT_ERROR
|
||||
|
||||
def cmd_list_prompts(self, args: List[str]) -> int:
|
||||
"""List available MCP prompts."""
|
||||
try:
|
||||
from .registry import get_prompt_registry
|
||||
|
||||
registry = get_prompt_registry()
|
||||
prompts = registry.list_schemas()
|
||||
|
||||
if not prompts:
|
||||
print("No prompts registered")
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
print(f"\n[bold]Available MCP Prompts ({len(prompts)}):[/bold]\n")
|
||||
for prompt in prompts:
|
||||
name = prompt.get("name", "unknown")
|
||||
desc = prompt.get("description", "No description")
|
||||
print(f" • {name}")
|
||||
print(f" {desc}\n")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
except Exception as e:
|
||||
self._print_error(str(e))
|
||||
return self.EXIT_ERROR
|
||||
|
||||
def cmd_config_generate(self, args: List[str]) -> int:
|
||||
"""Generate client configuration."""
|
||||
parser = argparse.ArgumentParser(prog="praisonai mcp config-generate")
|
||||
parser.add_argument("--client", default="claude-desktop",
|
||||
choices=["claude-desktop", "cursor", "vscode", "windsurf"])
|
||||
parser.add_argument("--output", default=None)
|
||||
parser.add_argument("--transport", default="stdio", choices=["stdio", "http-stream"])
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8080)
|
||||
|
||||
try:
|
||||
parsed = parser.parse_args(args)
|
||||
except SystemExit:
|
||||
return self.EXIT_ERROR
|
||||
|
||||
# Generate config based on client type
|
||||
if parsed.client == "claude-desktop":
|
||||
config = self._generate_claude_desktop_config(parsed)
|
||||
elif parsed.client == "cursor":
|
||||
config = self._generate_cursor_config(parsed)
|
||||
elif parsed.client == "vscode":
|
||||
config = self._generate_vscode_config(parsed)
|
||||
elif parsed.client == "windsurf":
|
||||
config = self._generate_windsurf_config(parsed)
|
||||
else:
|
||||
config = self._generate_claude_desktop_config(parsed)
|
||||
|
||||
# Output config
|
||||
config_json = json.dumps(config, indent=2)
|
||||
|
||||
if parsed.output:
|
||||
with open(parsed.output, 'w') as f:
|
||||
f.write(config_json)
|
||||
self._print_success(f"Config written to {parsed.output}")
|
||||
else:
|
||||
print(f"\n[bold]{parsed.client} Configuration:[/bold]\n")
|
||||
print(config_json)
|
||||
print()
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def _generate_claude_desktop_config(self, args) -> dict:
|
||||
"""Generate Claude Desktop config."""
|
||||
if args.transport == "stdio":
|
||||
return {
|
||||
"mcpServers": {
|
||||
"praisonai": {
|
||||
"command": "praisonai",
|
||||
"args": ["mcp", "serve", "--transport", "stdio"],
|
||||
}
|
||||
}
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"mcpServers": {
|
||||
"praisonai": {
|
||||
"url": f"http://{args.host}:{args.port}/mcp",
|
||||
"transport": "http-stream",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def _generate_cursor_config(self, args) -> dict:
|
||||
"""Generate Cursor config."""
|
||||
if args.transport == "stdio":
|
||||
return {
|
||||
"mcpServers": {
|
||||
"praisonai": {
|
||||
"command": "praisonai",
|
||||
"args": ["mcp", "serve", "--transport", "stdio"],
|
||||
}
|
||||
}
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"mcpServers": {
|
||||
"praisonai": {
|
||||
"url": f"http://{args.host}:{args.port}/mcp",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def _generate_vscode_config(self, args) -> dict:
|
||||
"""Generate VSCode MCP config."""
|
||||
if args.transport == "stdio":
|
||||
return {
|
||||
"mcp.servers": {
|
||||
"praisonai": {
|
||||
"command": "praisonai",
|
||||
"args": ["mcp", "serve", "--transport", "stdio"],
|
||||
}
|
||||
}
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"mcp.servers": {
|
||||
"praisonai": {
|
||||
"url": f"http://{args.host}:{args.port}/mcp",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def _generate_windsurf_config(self, args) -> dict:
|
||||
"""Generate Windsurf config."""
|
||||
return self._generate_cursor_config(args)
|
||||
|
||||
def cmd_doctor(self, args: List[str]) -> int:
|
||||
"""Check MCP server health and configuration."""
|
||||
try:
|
||||
from .server import PROTOCOL_VERSION, SUPPORTED_VERSIONS
|
||||
from .registry import get_tool_registry, get_resource_registry, get_prompt_registry
|
||||
from .adapters import register_all
|
||||
|
||||
print("\n[bold cyan]PraisonAI MCP Server Health Check[/bold cyan]\n")
|
||||
|
||||
# Register all components
|
||||
register_all()
|
||||
|
||||
# Check protocol version
|
||||
print(f"[bold]Protocol Version:[/bold] {PROTOCOL_VERSION}")
|
||||
print(f"[bold]Supported Versions:[/bold] {', '.join(SUPPORTED_VERSIONS)}")
|
||||
print()
|
||||
|
||||
# Check registries
|
||||
tools = get_tool_registry().list_all()
|
||||
resources = get_resource_registry().list_all()
|
||||
prompts = get_prompt_registry().list_all()
|
||||
|
||||
print("[bold]Registered Components:[/bold]")
|
||||
print(f" • Tools: {len(tools)}")
|
||||
print(f" • Resources: {len(resources)}")
|
||||
print(f" • Prompts: {len(prompts)}")
|
||||
print()
|
||||
|
||||
# Check environment
|
||||
import os
|
||||
env_checks = {
|
||||
"OPENAI_API_KEY": bool(os.environ.get("OPENAI_API_KEY")),
|
||||
"ANTHROPIC_API_KEY": bool(os.environ.get("ANTHROPIC_API_KEY")),
|
||||
"GOOGLE_API_KEY": bool(os.environ.get("GOOGLE_API_KEY")),
|
||||
}
|
||||
|
||||
print("[bold]Environment:[/bold]")
|
||||
for key, present in env_checks.items():
|
||||
status = "[green]✓[/green]" if present else "[yellow]○[/yellow]"
|
||||
print(f" {status} {key}")
|
||||
print()
|
||||
|
||||
# Check dependencies
|
||||
deps = {
|
||||
"starlette": False,
|
||||
"uvicorn": False,
|
||||
"praisonaiagents": False,
|
||||
}
|
||||
|
||||
for dep in deps:
|
||||
try:
|
||||
__import__(dep)
|
||||
deps[dep] = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
print("[bold]Dependencies:[/bold]")
|
||||
for dep, available in deps.items():
|
||||
status = "[green]✓[/green]" if available else "[red]✗[/red]"
|
||||
print(f" {status} {dep}")
|
||||
print()
|
||||
|
||||
# Overall status
|
||||
all_deps = all(deps.values())
|
||||
any_api_key = any(env_checks.values())
|
||||
|
||||
if all_deps and any_api_key:
|
||||
self._print_success("MCP server is ready to run")
|
||||
elif not all_deps:
|
||||
self._print_error("Missing required dependencies. Install with: pip install praisonai[mcp]")
|
||||
return self.EXIT_ERROR
|
||||
else:
|
||||
print("[yellow]Warning: No API keys configured. Some tools may not work.[/yellow]")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
except Exception as e:
|
||||
self._print_error(str(e))
|
||||
return self.EXIT_ERROR
|
||||
|
||||
|
||||
def handle_mcp_command(args: List[str]) -> int:
|
||||
"""
|
||||
Entry point for MCP CLI commands.
|
||||
|
||||
Args:
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
Exit code
|
||||
"""
|
||||
cli = MCPServerCLI()
|
||||
return cli.handle(args)
|
||||
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
MCP Tool/Resource/Prompt Registry
|
||||
|
||||
Provides a centralized registry for MCP tools, resources, and prompts.
|
||||
Supports lazy registration and schema generation.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPToolDefinition:
|
||||
"""Definition of an MCP tool."""
|
||||
name: str
|
||||
description: str
|
||||
handler: Callable
|
||||
input_schema: Dict[str, Any]
|
||||
output_schema: Optional[Dict[str, Any]] = None
|
||||
annotations: Optional[Dict[str, Any]] = None
|
||||
|
||||
def to_mcp_schema(self) -> Dict[str, Any]:
|
||||
"""Convert to MCP tool schema format."""
|
||||
schema = {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"inputSchema": self.input_schema,
|
||||
}
|
||||
if self.output_schema:
|
||||
schema["outputSchema"] = self.output_schema
|
||||
if self.annotations:
|
||||
schema["annotations"] = self.annotations
|
||||
return schema
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPResourceDefinition:
|
||||
"""Definition of an MCP resource."""
|
||||
uri: str
|
||||
name: str
|
||||
description: str
|
||||
handler: Callable
|
||||
mime_type: str = "application/json"
|
||||
|
||||
def to_mcp_schema(self) -> Dict[str, Any]:
|
||||
"""Convert to MCP resource schema format."""
|
||||
return {
|
||||
"uri": self.uri,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"mimeType": self.mime_type,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPPromptDefinition:
|
||||
"""Definition of an MCP prompt."""
|
||||
name: str
|
||||
description: str
|
||||
handler: Callable
|
||||
arguments: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def to_mcp_schema(self) -> Dict[str, Any]:
|
||||
"""Convert to MCP prompt schema format."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"arguments": self.arguments,
|
||||
}
|
||||
|
||||
|
||||
class MCPToolRegistry:
|
||||
"""Registry for MCP tools."""
|
||||
|
||||
def __init__(self):
|
||||
self._tools: Dict[str, MCPToolDefinition] = {}
|
||||
self._lazy_loaders: List[Callable[[], None]] = []
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
handler: Callable,
|
||||
description: Optional[str] = None,
|
||||
input_schema: Optional[Dict[str, Any]] = None,
|
||||
output_schema: Optional[Dict[str, Any]] = None,
|
||||
annotations: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Register a tool."""
|
||||
if description is None:
|
||||
description = inspect.getdoc(handler) or f"Tool: {name}"
|
||||
if "\n" in description:
|
||||
description = description.split("\n")[0]
|
||||
|
||||
if input_schema is None:
|
||||
input_schema = self._generate_input_schema(handler)
|
||||
|
||||
self._tools[name] = MCPToolDefinition(
|
||||
name=name,
|
||||
description=description,
|
||||
handler=handler,
|
||||
input_schema=input_schema,
|
||||
output_schema=output_schema,
|
||||
annotations=annotations,
|
||||
)
|
||||
logger.debug(f"Registered MCP tool: {name}")
|
||||
|
||||
def register_lazy(self, loader: Callable[[], None]) -> None:
|
||||
"""Register a lazy loader that will be called before listing tools."""
|
||||
self._lazy_loaders.append(loader)
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure all lazy loaders have been called."""
|
||||
for loader in self._lazy_loaders:
|
||||
try:
|
||||
loader()
|
||||
except Exception as e:
|
||||
logger.warning(f"Lazy loader failed: {e}")
|
||||
self._lazy_loaders.clear()
|
||||
|
||||
def get(self, name: str) -> Optional[MCPToolDefinition]:
|
||||
"""Get a tool by name."""
|
||||
self._ensure_loaded()
|
||||
return self._tools.get(name)
|
||||
|
||||
def list_all(self) -> List[MCPToolDefinition]:
|
||||
"""List all registered tools."""
|
||||
self._ensure_loaded()
|
||||
return list(self._tools.values())
|
||||
|
||||
def list_schemas(self) -> List[Dict[str, Any]]:
|
||||
"""List all tool schemas."""
|
||||
return [tool.to_mcp_schema() for tool in self.list_all()]
|
||||
|
||||
def _generate_input_schema(self, handler: Callable) -> Dict[str, Any]:
|
||||
"""Generate JSON schema from function signature."""
|
||||
sig = inspect.signature(handler)
|
||||
hints = getattr(handler, "__annotations__", {})
|
||||
|
||||
properties = {}
|
||||
required = []
|
||||
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name in ("self", "cls"):
|
||||
continue
|
||||
|
||||
prop = {"type": "string"} # Default
|
||||
|
||||
# Get type hint
|
||||
hint = hints.get(param_name)
|
||||
if hint:
|
||||
prop = self._type_to_json_schema(hint)
|
||||
|
||||
# Get description from docstring if available
|
||||
docstring = inspect.getdoc(handler)
|
||||
if docstring:
|
||||
# Try to extract param description from docstring
|
||||
for line in docstring.split("\n"):
|
||||
if f":param {param_name}:" in line:
|
||||
# Extract description after the param marker
|
||||
parts = line.split(f":param {param_name}:", 1)
|
||||
if len(parts) > 1:
|
||||
prop["description"] = parts[1].strip()
|
||||
|
||||
properties[param_name] = prop
|
||||
|
||||
if param.default is inspect.Parameter.empty:
|
||||
required.append(param_name)
|
||||
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required if required else None,
|
||||
}
|
||||
|
||||
def _type_to_json_schema(self, hint: Any) -> Dict[str, Any]:
|
||||
"""Convert Python type hint to JSON schema."""
|
||||
if hint is str:
|
||||
return {"type": "string"}
|
||||
elif hint is int:
|
||||
return {"type": "integer"}
|
||||
elif hint is float:
|
||||
return {"type": "number"}
|
||||
elif hint is bool:
|
||||
return {"type": "boolean"}
|
||||
elif hint is list or (hasattr(hint, "__origin__") and hint.__origin__ is list):
|
||||
return {"type": "array", "items": {"type": "string"}}
|
||||
elif hint is dict or (hasattr(hint, "__origin__") and hint.__origin__ is dict):
|
||||
return {"type": "object"}
|
||||
else:
|
||||
return {"type": "string"}
|
||||
|
||||
|
||||
class MCPResourceRegistry:
|
||||
"""Registry for MCP resources."""
|
||||
|
||||
def __init__(self):
|
||||
self._resources: Dict[str, MCPResourceDefinition] = {}
|
||||
self._lazy_loaders: List[Callable[[], None]] = []
|
||||
|
||||
def register(
|
||||
self,
|
||||
uri: str,
|
||||
handler: Callable,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
mime_type: str = "application/json",
|
||||
) -> None:
|
||||
"""Register a resource."""
|
||||
if name is None:
|
||||
name = uri.split("/")[-1] or uri
|
||||
if description is None:
|
||||
description = inspect.getdoc(handler) or f"Resource: {name}"
|
||||
if "\n" in description:
|
||||
description = description.split("\n")[0]
|
||||
|
||||
self._resources[uri] = MCPResourceDefinition(
|
||||
uri=uri,
|
||||
name=name,
|
||||
description=description,
|
||||
handler=handler,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
logger.debug(f"Registered MCP resource: {uri}")
|
||||
|
||||
def register_lazy(self, loader: Callable[[], None]) -> None:
|
||||
"""Register a lazy loader."""
|
||||
self._lazy_loaders.append(loader)
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure all lazy loaders have been called."""
|
||||
for loader in self._lazy_loaders:
|
||||
try:
|
||||
loader()
|
||||
except Exception as e:
|
||||
logger.warning(f"Lazy loader failed: {e}")
|
||||
self._lazy_loaders.clear()
|
||||
|
||||
def get(self, uri: str) -> Optional[MCPResourceDefinition]:
|
||||
"""Get a resource by URI."""
|
||||
self._ensure_loaded()
|
||||
return self._resources.get(uri)
|
||||
|
||||
def list_all(self) -> List[MCPResourceDefinition]:
|
||||
"""List all registered resources."""
|
||||
self._ensure_loaded()
|
||||
return list(self._resources.values())
|
||||
|
||||
def list_schemas(self) -> List[Dict[str, Any]]:
|
||||
"""List all resource schemas."""
|
||||
return [res.to_mcp_schema() for res in self.list_all()]
|
||||
|
||||
|
||||
class MCPPromptRegistry:
|
||||
"""Registry for MCP prompts."""
|
||||
|
||||
def __init__(self):
|
||||
self._prompts: Dict[str, MCPPromptDefinition] = {}
|
||||
self._lazy_loaders: List[Callable[[], None]] = []
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
handler: Callable,
|
||||
description: Optional[str] = None,
|
||||
arguments: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""Register a prompt."""
|
||||
if description is None:
|
||||
description = inspect.getdoc(handler) or f"Prompt: {name}"
|
||||
if "\n" in description:
|
||||
description = description.split("\n")[0]
|
||||
|
||||
if arguments is None:
|
||||
arguments = self._generate_arguments(handler)
|
||||
|
||||
self._prompts[name] = MCPPromptDefinition(
|
||||
name=name,
|
||||
description=description,
|
||||
handler=handler,
|
||||
arguments=arguments,
|
||||
)
|
||||
logger.debug(f"Registered MCP prompt: {name}")
|
||||
|
||||
def register_lazy(self, loader: Callable[[], None]) -> None:
|
||||
"""Register a lazy loader."""
|
||||
self._lazy_loaders.append(loader)
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure all lazy loaders have been called."""
|
||||
for loader in self._lazy_loaders:
|
||||
try:
|
||||
loader()
|
||||
except Exception as e:
|
||||
logger.warning(f"Lazy loader failed: {e}")
|
||||
self._lazy_loaders.clear()
|
||||
|
||||
def get(self, name: str) -> Optional[MCPPromptDefinition]:
|
||||
"""Get a prompt by name."""
|
||||
self._ensure_loaded()
|
||||
return self._prompts.get(name)
|
||||
|
||||
def list_all(self) -> List[MCPPromptDefinition]:
|
||||
"""List all registered prompts."""
|
||||
self._ensure_loaded()
|
||||
return list(self._prompts.values())
|
||||
|
||||
def list_schemas(self) -> List[Dict[str, Any]]:
|
||||
"""List all prompt schemas."""
|
||||
return [prompt.to_mcp_schema() for prompt in self.list_all()]
|
||||
|
||||
def _generate_arguments(self, handler: Callable) -> List[Dict[str, Any]]:
|
||||
"""Generate prompt arguments from function signature."""
|
||||
sig = inspect.signature(handler)
|
||||
arguments = []
|
||||
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name in ("self", "cls"):
|
||||
continue
|
||||
|
||||
arg = {
|
||||
"name": param_name,
|
||||
"required": param.default is inspect.Parameter.empty,
|
||||
}
|
||||
|
||||
# Add description if available
|
||||
docstring = inspect.getdoc(handler)
|
||||
if docstring and f":param {param_name}:" in docstring:
|
||||
# Extract description
|
||||
pass
|
||||
|
||||
arguments.append(arg)
|
||||
|
||||
return arguments
|
||||
|
||||
|
||||
# Global registries
|
||||
_tool_registry = MCPToolRegistry()
|
||||
_resource_registry = MCPResourceRegistry()
|
||||
_prompt_registry = MCPPromptRegistry()
|
||||
|
||||
|
||||
def register_tool(
|
||||
name: str,
|
||||
handler: Optional[Callable] = None,
|
||||
description: Optional[str] = None,
|
||||
input_schema: Optional[Dict[str, Any]] = None,
|
||||
output_schema: Optional[Dict[str, Any]] = None,
|
||||
annotations: Optional[Dict[str, Any]] = None,
|
||||
) -> Union[Callable, None]:
|
||||
"""
|
||||
Register a tool with the global registry.
|
||||
|
||||
Can be used as a decorator or called directly.
|
||||
|
||||
Example:
|
||||
@register_tool("praisonai.memory.show")
|
||||
def show_memory(session_id: str = None) -> str:
|
||||
'''Show memory contents.'''
|
||||
...
|
||||
|
||||
# Or directly:
|
||||
register_tool("praisonai.memory.show", show_memory)
|
||||
"""
|
||||
def decorator(fn: Callable) -> Callable:
|
||||
_tool_registry.register(
|
||||
name=name,
|
||||
handler=fn,
|
||||
description=description,
|
||||
input_schema=input_schema,
|
||||
output_schema=output_schema,
|
||||
annotations=annotations,
|
||||
)
|
||||
return fn
|
||||
|
||||
if handler is not None:
|
||||
decorator(handler)
|
||||
return None
|
||||
return decorator
|
||||
|
||||
|
||||
def register_resource(
|
||||
uri: str,
|
||||
handler: Optional[Callable] = None,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
mime_type: str = "application/json",
|
||||
) -> Union[Callable, None]:
|
||||
"""
|
||||
Register a resource with the global registry.
|
||||
|
||||
Can be used as a decorator or called directly.
|
||||
"""
|
||||
def decorator(fn: Callable) -> Callable:
|
||||
_resource_registry.register(
|
||||
uri=uri,
|
||||
handler=fn,
|
||||
name=name,
|
||||
description=description,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
return fn
|
||||
|
||||
if handler is not None:
|
||||
decorator(handler)
|
||||
return None
|
||||
return decorator
|
||||
|
||||
|
||||
def register_prompt(
|
||||
name: str,
|
||||
handler: Optional[Callable] = None,
|
||||
description: Optional[str] = None,
|
||||
arguments: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Union[Callable, None]:
|
||||
"""
|
||||
Register a prompt with the global registry.
|
||||
|
||||
Can be used as a decorator or called directly.
|
||||
"""
|
||||
def decorator(fn: Callable) -> Callable:
|
||||
_prompt_registry.register(
|
||||
name=name,
|
||||
handler=fn,
|
||||
description=description,
|
||||
arguments=arguments,
|
||||
)
|
||||
return fn
|
||||
|
||||
if handler is not None:
|
||||
decorator(handler)
|
||||
return None
|
||||
return decorator
|
||||
|
||||
|
||||
def get_tool_registry() -> MCPToolRegistry:
|
||||
"""Get the global tool registry."""
|
||||
return _tool_registry
|
||||
|
||||
|
||||
def get_resource_registry() -> MCPResourceRegistry:
|
||||
"""Get the global resource registry."""
|
||||
return _resource_registry
|
||||
|
||||
|
||||
def get_prompt_registry() -> MCPPromptRegistry:
|
||||
"""Get the global prompt registry."""
|
||||
return _prompt_registry
|
||||
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
MCP Server Implementation
|
||||
|
||||
Core MCP server that handles JSON-RPC messages and routes them to appropriate handlers.
|
||||
Supports both STDIO and HTTP Stream transports.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, Optional, Set
|
||||
|
||||
from .registry import (
|
||||
get_tool_registry,
|
||||
get_resource_registry,
|
||||
get_prompt_registry,
|
||||
MCPToolRegistry,
|
||||
MCPResourceRegistry,
|
||||
MCPPromptRegistry,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MCP Protocol Constants (Updated to 2025-11-25 spec)
|
||||
PROTOCOL_VERSION = "2025-11-25"
|
||||
SUPPORTED_VERSIONS = ["2025-11-25", "2025-03-26", "2024-11-05"]
|
||||
|
||||
# JSON-RPC Error Codes
|
||||
PARSE_ERROR = -32700
|
||||
INVALID_REQUEST = -32600
|
||||
METHOD_NOT_FOUND = -32601
|
||||
INVALID_PARAMS = -32602
|
||||
INTERNAL_ERROR = -32603
|
||||
|
||||
|
||||
class MCPServer:
|
||||
"""
|
||||
MCP Server that exposes PraisonAI capabilities.
|
||||
|
||||
Supports:
|
||||
- STDIO transport (for Claude Desktop, Cursor, etc.)
|
||||
- HTTP Stream transport (MCP 2025-11-25 spec)
|
||||
|
||||
Example:
|
||||
server = MCPServer(name="praisonai")
|
||||
server.run(transport="stdio")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "praisonai",
|
||||
version: str = "1.0.0",
|
||||
tool_registry: Optional[MCPToolRegistry] = None,
|
||||
resource_registry: Optional[MCPResourceRegistry] = None,
|
||||
prompt_registry: Optional[MCPPromptRegistry] = None,
|
||||
instructions: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize MCP server.
|
||||
|
||||
Args:
|
||||
name: Server name
|
||||
version: Server version
|
||||
tool_registry: Custom tool registry (uses global if None)
|
||||
resource_registry: Custom resource registry (uses global if None)
|
||||
prompt_registry: Custom prompt registry (uses global if None)
|
||||
instructions: Optional instructions for clients
|
||||
"""
|
||||
self.name = name
|
||||
self.version = version
|
||||
self.instructions = instructions
|
||||
|
||||
self._tool_registry = tool_registry or get_tool_registry()
|
||||
self._resource_registry = resource_registry or get_resource_registry()
|
||||
self._prompt_registry = prompt_registry or get_prompt_registry()
|
||||
|
||||
self._initialized = False
|
||||
self._client_info: Optional[Dict[str, Any]] = None
|
||||
self._protocol_version: str = PROTOCOL_VERSION
|
||||
|
||||
# Cancellation support
|
||||
self._active_requests: Dict[str, asyncio.Task] = {}
|
||||
self._cancelled_requests: Set[str] = set()
|
||||
|
||||
# Progress notification callback
|
||||
self._progress_callback: Optional[Callable] = None
|
||||
|
||||
# Method handlers
|
||||
self._handlers: Dict[str, Callable] = {
|
||||
"initialize": self._handle_initialize,
|
||||
"ping": self._handle_ping,
|
||||
"tools/list": self._handle_tools_list,
|
||||
"tools/call": self._handle_tools_call,
|
||||
"resources/list": self._handle_resources_list,
|
||||
"resources/read": self._handle_resources_read,
|
||||
"prompts/list": self._handle_prompts_list,
|
||||
"prompts/get": self._handle_prompts_get,
|
||||
"logging/setLevel": self._handle_set_log_level,
|
||||
}
|
||||
|
||||
def get_capabilities(self) -> Dict[str, Any]:
|
||||
"""Get server capabilities per MCP 2025-11-25 spec."""
|
||||
return {
|
||||
"tools": {
|
||||
"listChanged": True,
|
||||
},
|
||||
"resources": {
|
||||
"subscribe": False,
|
||||
"listChanged": True,
|
||||
},
|
||||
"prompts": {
|
||||
"listChanged": True,
|
||||
},
|
||||
"logging": {},
|
||||
}
|
||||
|
||||
def set_progress_callback(self, callback: Callable) -> None:
|
||||
"""Set callback for progress notifications."""
|
||||
self._progress_callback = callback
|
||||
|
||||
async def send_progress(self, progress_token: str, progress: float, total: Optional[float] = None) -> None:
|
||||
"""Send progress notification for long-running operations."""
|
||||
if self._progress_callback:
|
||||
notification = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/progress",
|
||||
"params": {
|
||||
"progressToken": progress_token,
|
||||
"progress": progress,
|
||||
}
|
||||
}
|
||||
if total is not None:
|
||||
notification["params"]["total"] = total
|
||||
await self._progress_callback(notification)
|
||||
|
||||
def is_cancelled(self, request_id: str) -> bool:
|
||||
"""Check if a request has been cancelled."""
|
||||
return request_id in self._cancelled_requests
|
||||
|
||||
async def handle_message(self, message: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Handle an incoming JSON-RPC message.
|
||||
|
||||
Args:
|
||||
message: JSON-RPC message
|
||||
|
||||
Returns:
|
||||
Response message or None for notifications
|
||||
"""
|
||||
# Validate JSON-RPC format
|
||||
if message.get("jsonrpc") != "2.0":
|
||||
return self._error_response(None, INVALID_REQUEST, "Invalid JSON-RPC version")
|
||||
|
||||
method = message.get("method")
|
||||
params = message.get("params", {})
|
||||
msg_id = message.get("id")
|
||||
|
||||
# Notifications don't have an id
|
||||
is_notification = msg_id is None
|
||||
|
||||
# Handle initialized notification
|
||||
if method == "notifications/initialized":
|
||||
self._initialized = True
|
||||
logger.debug("Client sent initialized notification")
|
||||
return None
|
||||
|
||||
# Handle cancellation notification
|
||||
if method == "notifications/cancelled":
|
||||
request_id = params.get("requestId")
|
||||
if request_id:
|
||||
self._cancelled_requests.add(str(request_id))
|
||||
# Cancel active task if exists
|
||||
if str(request_id) in self._active_requests:
|
||||
task = self._active_requests[str(request_id)]
|
||||
task.cancel()
|
||||
logger.debug(f"Cancelled request: {request_id}")
|
||||
return None
|
||||
|
||||
# Check if method exists
|
||||
handler = self._handlers.get(method)
|
||||
if handler is None:
|
||||
if is_notification:
|
||||
return None
|
||||
return self._error_response(msg_id, METHOD_NOT_FOUND, f"Method not found: {method}")
|
||||
|
||||
# Execute handler
|
||||
try:
|
||||
result = await handler(params)
|
||||
if is_notification:
|
||||
return None
|
||||
return self._success_response(msg_id, result)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error handling {method}")
|
||||
if is_notification:
|
||||
return None
|
||||
return self._error_response(msg_id, INTERNAL_ERROR, str(e))
|
||||
|
||||
async def _handle_initialize(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Handle initialize request."""
|
||||
self._client_info = params.get("clientInfo")
|
||||
client_version = params.get("protocolVersion", PROTOCOL_VERSION)
|
||||
|
||||
# Negotiate protocol version
|
||||
if client_version in SUPPORTED_VERSIONS:
|
||||
self._protocol_version = client_version
|
||||
else:
|
||||
self._protocol_version = PROTOCOL_VERSION
|
||||
|
||||
logger.info(f"Client initialized: {self._client_info}, protocol: {self._protocol_version}")
|
||||
|
||||
result = {
|
||||
"protocolVersion": self._protocol_version,
|
||||
"capabilities": self.get_capabilities(),
|
||||
"serverInfo": {
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
},
|
||||
}
|
||||
|
||||
if self.instructions:
|
||||
result["instructions"] = self.instructions
|
||||
|
||||
return result
|
||||
|
||||
async def _handle_ping(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Handle ping request."""
|
||||
return {}
|
||||
|
||||
async def _handle_tools_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Handle tools/list request."""
|
||||
tools = self._tool_registry.list_schemas()
|
||||
return {"tools": tools}
|
||||
|
||||
async def _handle_tools_call(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Handle tools/call request."""
|
||||
tool_name = params.get("name")
|
||||
arguments = params.get("arguments", {})
|
||||
|
||||
if not tool_name:
|
||||
raise ValueError("Tool name required")
|
||||
|
||||
tool = self._tool_registry.get(tool_name)
|
||||
if tool is None:
|
||||
raise ValueError(f"Tool not found: {tool_name}")
|
||||
|
||||
# Execute tool
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(tool.handler):
|
||||
result = await tool.handler(**arguments)
|
||||
else:
|
||||
result = tool.handler(**arguments)
|
||||
|
||||
# Format result as MCP content
|
||||
if isinstance(result, str):
|
||||
content = [{"type": "text", "text": result}]
|
||||
elif isinstance(result, dict):
|
||||
content = [{"type": "text", "text": json.dumps(result, indent=2)}]
|
||||
elif isinstance(result, list):
|
||||
content = [{"type": "text", "text": json.dumps(result, indent=2)}]
|
||||
else:
|
||||
content = [{"type": "text", "text": str(result)}]
|
||||
|
||||
return {"content": content, "isError": False}
|
||||
except Exception as e:
|
||||
logger.exception(f"Tool execution error: {tool_name}")
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Error: {str(e)}"}],
|
||||
"isError": True,
|
||||
}
|
||||
|
||||
async def _handle_resources_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Handle resources/list request."""
|
||||
resources = self._resource_registry.list_schemas()
|
||||
return {"resources": resources}
|
||||
|
||||
async def _handle_resources_read(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Handle resources/read request."""
|
||||
uri = params.get("uri")
|
||||
|
||||
if not uri:
|
||||
raise ValueError("Resource URI required")
|
||||
|
||||
resource = self._resource_registry.get(uri)
|
||||
if resource is None:
|
||||
raise ValueError(f"Resource not found: {uri}")
|
||||
|
||||
# Execute resource handler
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(resource.handler):
|
||||
result = await resource.handler()
|
||||
else:
|
||||
result = resource.handler()
|
||||
|
||||
# Format result
|
||||
if isinstance(result, str):
|
||||
content = [{"uri": uri, "mimeType": resource.mime_type, "text": result}]
|
||||
elif isinstance(result, dict):
|
||||
content = [{"uri": uri, "mimeType": "application/json", "text": json.dumps(result)}]
|
||||
else:
|
||||
content = [{"uri": uri, "mimeType": resource.mime_type, "text": str(result)}]
|
||||
|
||||
return {"contents": content}
|
||||
except Exception:
|
||||
logger.exception(f"Resource read error: {uri}")
|
||||
raise
|
||||
|
||||
async def _handle_prompts_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Handle prompts/list request."""
|
||||
prompts = self._prompt_registry.list_schemas()
|
||||
return {"prompts": prompts}
|
||||
|
||||
async def _handle_prompts_get(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Handle prompts/get request."""
|
||||
prompt_name = params.get("name")
|
||||
arguments = params.get("arguments", {})
|
||||
|
||||
if not prompt_name:
|
||||
raise ValueError("Prompt name required")
|
||||
|
||||
prompt = self._prompt_registry.get(prompt_name)
|
||||
if prompt is None:
|
||||
raise ValueError(f"Prompt not found: {prompt_name}")
|
||||
|
||||
# Execute prompt handler
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(prompt.handler):
|
||||
result = await prompt.handler(**arguments)
|
||||
else:
|
||||
result = prompt.handler(**arguments)
|
||||
|
||||
# Format result as messages
|
||||
if isinstance(result, str):
|
||||
messages = [{"role": "user", "content": {"type": "text", "text": result}}]
|
||||
elif isinstance(result, list):
|
||||
messages = result
|
||||
else:
|
||||
messages = [{"role": "user", "content": {"type": "text", "text": str(result)}}]
|
||||
|
||||
return {"messages": messages}
|
||||
except Exception:
|
||||
logger.exception(f"Prompt get error: {prompt_name}")
|
||||
raise
|
||||
|
||||
async def _handle_set_log_level(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Handle logging/setLevel request per MCP spec."""
|
||||
level = params.get("level", "info")
|
||||
level_map = {
|
||||
"debug": logging.DEBUG,
|
||||
"info": logging.INFO,
|
||||
"notice": logging.INFO,
|
||||
"warning": logging.WARNING,
|
||||
"error": logging.ERROR,
|
||||
"critical": logging.CRITICAL,
|
||||
"alert": logging.CRITICAL,
|
||||
"emergency": logging.CRITICAL,
|
||||
}
|
||||
log_level = level_map.get(level.lower(), logging.INFO)
|
||||
logging.getLogger().setLevel(log_level)
|
||||
logger.info(f"Log level set to: {level}")
|
||||
return {}
|
||||
|
||||
def _success_response(self, msg_id: Any, result: Any) -> Dict[str, Any]:
|
||||
"""Create a success response."""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": result,
|
||||
}
|
||||
|
||||
def _error_response(self, msg_id: Any, code: int, message: str, data: Any = None) -> Dict[str, Any]:
|
||||
"""Create an error response."""
|
||||
error = {"code": code, "message": message}
|
||||
if data is not None:
|
||||
error["data"] = data
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
def run(self, transport: str = "stdio", **kwargs) -> None:
|
||||
"""
|
||||
Run the MCP server.
|
||||
|
||||
Args:
|
||||
transport: Transport type ("stdio" or "http-stream")
|
||||
**kwargs: Transport-specific options
|
||||
"""
|
||||
if transport == "stdio":
|
||||
self.run_stdio()
|
||||
elif transport == "http-stream":
|
||||
self.run_http_stream(**kwargs)
|
||||
else:
|
||||
raise ValueError(f"Unknown transport: {transport}")
|
||||
|
||||
def run_stdio(self) -> None:
|
||||
"""Run server with STDIO transport."""
|
||||
from .transports.stdio import StdioTransport
|
||||
|
||||
transport = StdioTransport(self)
|
||||
asyncio.run(transport.run())
|
||||
|
||||
def run_http_stream(
|
||||
self,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8080,
|
||||
endpoint: str = "/mcp",
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
Run server with HTTP Stream transport.
|
||||
|
||||
Args:
|
||||
host: Server host
|
||||
port: Server port
|
||||
endpoint: MCP endpoint path
|
||||
"""
|
||||
from .transports.http_stream import HTTPStreamTransport
|
||||
|
||||
transport = HTTPStreamTransport(
|
||||
server=self,
|
||||
host=host,
|
||||
port=port,
|
||||
endpoint=endpoint,
|
||||
**kwargs,
|
||||
)
|
||||
transport.run()
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
MCP Transport Implementations
|
||||
|
||||
Provides STDIO and HTTP Stream transports for the MCP server.
|
||||
"""
|
||||
|
||||
__all__ = ["StdioTransport", "HTTPStreamTransport"]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy load transports."""
|
||||
if name == "StdioTransport":
|
||||
from .stdio import StdioTransport
|
||||
return StdioTransport
|
||||
elif name == "HTTPStreamTransport":
|
||||
from .http_stream import HTTPStreamTransport
|
||||
return HTTPStreamTransport
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,376 @@
|
||||
"""
|
||||
HTTP Stream Transport for MCP Server
|
||||
|
||||
Implements the MCP Streamable HTTP transport (MCP 2025-11-25 spec):
|
||||
- Single endpoint for all MCP communication
|
||||
- POST for client→server messages
|
||||
- GET for server→client SSE stream (optional)
|
||||
- Session management via MCP-Session-Id header
|
||||
- Supports batch (JSON) and stream (SSE) response modes
|
||||
- Origin header validation for security
|
||||
- MCP-Protocol-Version header support
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..server import MCPServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MCP Protocol Version (Updated to 2025-11-25)
|
||||
PROTOCOL_VERSION = "2025-11-25"
|
||||
SUPPORTED_VERSIONS = ["2025-11-25", "2025-03-26"]
|
||||
|
||||
|
||||
class HTTPStreamTransport:
|
||||
"""
|
||||
HTTP Stream transport for MCP server.
|
||||
|
||||
Implements the Streamable HTTP transport per MCP 2025-11-25 spec.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server: "MCPServer",
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8080,
|
||||
endpoint: str = "/mcp",
|
||||
cors_origins: Optional[list] = None,
|
||||
allowed_origins: Optional[list] = None,
|
||||
api_key: Optional[str] = None,
|
||||
session_ttl: int = 3600,
|
||||
allow_client_termination: bool = True,
|
||||
response_mode: str = "batch",
|
||||
resumability_enabled: bool = True,
|
||||
event_history_duration: int = 300,
|
||||
):
|
||||
"""
|
||||
Initialize HTTP Stream transport.
|
||||
|
||||
Args:
|
||||
server: MCPServer instance
|
||||
host: Server host
|
||||
port: Server port
|
||||
endpoint: MCP endpoint path
|
||||
cors_origins: CORS allowed origins
|
||||
allowed_origins: Origins allowed for security validation (None = localhost only)
|
||||
api_key: Optional API key for authentication
|
||||
session_ttl: Session TTL in seconds
|
||||
allow_client_termination: Allow client to terminate session via DELETE
|
||||
response_mode: Default response mode ("batch" or "stream")
|
||||
resumability_enabled: Enable SSE resumability
|
||||
event_history_duration: Duration to keep event history (seconds)
|
||||
"""
|
||||
self.server = server
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.endpoint = endpoint
|
||||
self.cors_origins = cors_origins or ["*"]
|
||||
self.api_key = api_key
|
||||
self.session_ttl = session_ttl
|
||||
self.allow_client_termination = allow_client_termination
|
||||
self.response_mode = response_mode
|
||||
self.resumability_enabled = resumability_enabled
|
||||
self.event_history_duration = event_history_duration
|
||||
|
||||
# Allowed origins for security (MCP 2025-11-25 requirement)
|
||||
# Default: only localhost if binding to localhost
|
||||
if allowed_origins is None:
|
||||
if host in ("127.0.0.1", "localhost", "::1"):
|
||||
self.allowed_origins = ["http://localhost", "http://127.0.0.1", "https://localhost", "https://127.0.0.1"]
|
||||
else:
|
||||
# External binding requires explicit origin config
|
||||
self.allowed_origins = None # Will reject all Origin headers
|
||||
logger.warning("External binding without allowed_origins - Origin validation will reject all requests with Origin header")
|
||||
else:
|
||||
self.allowed_origins = allowed_origins
|
||||
|
||||
# Session storage
|
||||
self._sessions: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# SSE event history for resumability
|
||||
self._event_history: Dict[str, list] = {}
|
||||
self._event_counter = 0
|
||||
self._event_timestamps: Dict[str, float] = {}
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the HTTP Stream server."""
|
||||
try:
|
||||
import uvicorn
|
||||
except ImportError:
|
||||
raise ImportError("uvicorn required. Install with: pip install uvicorn")
|
||||
|
||||
app = self._create_app()
|
||||
|
||||
logger.info(f"MCP server '{self.server.name}' starting on http://{self.host}:{self.port}{self.endpoint}")
|
||||
|
||||
uvicorn.run(app, host=self.host, port=self.port, log_level="warning")
|
||||
|
||||
def _validate_origin(self, request_origin: Optional[str]) -> bool:
|
||||
"""
|
||||
Validate Origin header per MCP 2025-11-25 security requirements.
|
||||
|
||||
Returns True if origin is valid or not present.
|
||||
Returns False if origin is present but invalid.
|
||||
"""
|
||||
if request_origin is None:
|
||||
# No Origin header - allow (same-origin requests don't send Origin)
|
||||
return True
|
||||
|
||||
if self.allowed_origins is None:
|
||||
# No allowed origins configured - reject all Origin headers
|
||||
return False
|
||||
|
||||
# Check if origin matches any allowed origin
|
||||
for allowed in self.allowed_origins:
|
||||
if request_origin == allowed or request_origin.startswith(allowed):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _validate_protocol_version(self, version: Optional[str]) -> bool:
|
||||
"""Validate MCP-Protocol-Version header."""
|
||||
if version is None:
|
||||
# No version header - assume 2025-03-26 for backwards compatibility
|
||||
return True
|
||||
return version in SUPPORTED_VERSIONS
|
||||
|
||||
def _create_app(self) -> Any:
|
||||
"""Create the Starlette/FastAPI application."""
|
||||
try:
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response, StreamingResponse
|
||||
from starlette.routing import Route
|
||||
except ImportError:
|
||||
raise ImportError("starlette required. Install with: pip install starlette")
|
||||
|
||||
async def mcp_post(request: Request) -> Response:
|
||||
"""Handle POST requests (client→server messages)."""
|
||||
# Validate Origin header (MCP 2025-11-25 security requirement)
|
||||
origin = request.headers.get("Origin")
|
||||
if not self._validate_origin(origin):
|
||||
logger.warning(f"Rejected request with invalid Origin: {origin}")
|
||||
return JSONResponse(
|
||||
{"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Forbidden: Invalid Origin"}},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
# Validate MCP-Protocol-Version header
|
||||
protocol_version = request.headers.get("MCP-Protocol-Version")
|
||||
if protocol_version and not self._validate_protocol_version(protocol_version):
|
||||
return JSONResponse(
|
||||
{"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": f"Unsupported protocol version: {protocol_version}"}},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Check authentication
|
||||
if self.api_key:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer ") or auth_header[7:] != self.api_key:
|
||||
return JSONResponse(
|
||||
{"error": "Unauthorized"},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Get or create session (check both header casings for compatibility)
|
||||
session_id = request.headers.get("MCP-Session-Id") or request.headers.get("Mcp-Session-Id")
|
||||
if session_id and session_id not in self._sessions:
|
||||
# Session expired
|
||||
return JSONResponse(
|
||||
{"error": "Session not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Parse request body
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse(
|
||||
{"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Handle message
|
||||
response = await self.server.handle_message(body)
|
||||
|
||||
# Check if this is an initialize request
|
||||
if body.get("method") == "initialize":
|
||||
# Create new session
|
||||
new_session_id = str(uuid.uuid4())
|
||||
self._sessions[new_session_id] = {
|
||||
"created_at": time.time(),
|
||||
"last_activity": time.time(),
|
||||
}
|
||||
|
||||
# Add session ID to response headers (MCP 2025-11-25 uses MCP-Session-Id)
|
||||
headers = {
|
||||
"MCP-Session-Id": new_session_id,
|
||||
"MCP-Protocol-Version": PROTOCOL_VERSION,
|
||||
}
|
||||
|
||||
if response:
|
||||
return JSONResponse(response, headers=headers)
|
||||
return Response(status_code=202, headers=headers)
|
||||
|
||||
# Update session activity
|
||||
if session_id and session_id in self._sessions:
|
||||
self._sessions[session_id]["last_activity"] = time.time()
|
||||
|
||||
# Check Accept header for response mode
|
||||
accept = request.headers.get("Accept", "application/json")
|
||||
|
||||
if "text/event-stream" in accept and response:
|
||||
# Stream mode - return SSE
|
||||
async def generate_sse():
|
||||
event_id = self._next_event_id()
|
||||
data = json.dumps(response)
|
||||
yield f"id:{event_id}\ndata:{data}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate_sse(),
|
||||
media_type="text/event-stream",
|
||||
headers={"MCP-Protocol-Version": PROTOCOL_VERSION},
|
||||
)
|
||||
|
||||
# Batch mode - return JSON
|
||||
if response:
|
||||
return JSONResponse(
|
||||
response,
|
||||
headers={"MCP-Protocol-Version": PROTOCOL_VERSION},
|
||||
)
|
||||
|
||||
return Response(
|
||||
status_code=202,
|
||||
headers={"MCP-Protocol-Version": PROTOCOL_VERSION},
|
||||
)
|
||||
|
||||
async def mcp_get(request: Request) -> Response:
|
||||
"""Handle GET requests (server→client SSE stream)."""
|
||||
# Check both header casings for compatibility
|
||||
session_id = request.headers.get("MCP-Session-Id") or request.headers.get("Mcp-Session-Id")
|
||||
if not session_id or session_id not in self._sessions:
|
||||
return JSONResponse(
|
||||
{"error": "Session required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Check for Last-Event-ID for resumability
|
||||
last_event_id = request.headers.get("Last-Event-ID")
|
||||
|
||||
async def generate_sse():
|
||||
# If resuming, replay missed events
|
||||
if last_event_id and session_id in self._event_history:
|
||||
history = self._event_history[session_id]
|
||||
replay = False
|
||||
for event_id, data in history:
|
||||
if replay:
|
||||
yield f"id:{event_id}\ndata:{data}\n\n"
|
||||
elif event_id == last_event_id:
|
||||
replay = True
|
||||
|
||||
# Keep connection alive
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
yield ":keepalive\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate_sse(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"MCP-Protocol-Version": PROTOCOL_VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
async def mcp_delete(request: Request) -> Response:
|
||||
"""Handle DELETE requests (session termination)."""
|
||||
if not self.allow_client_termination:
|
||||
return Response(status_code=405)
|
||||
|
||||
session_id = request.headers.get("MCP-Session-Id") or request.headers.get("Mcp-Session-Id")
|
||||
if session_id and session_id in self._sessions:
|
||||
del self._sessions[session_id]
|
||||
if session_id in self._event_history:
|
||||
del self._event_history[session_id]
|
||||
return Response(status_code=204)
|
||||
|
||||
return JSONResponse(
|
||||
{"error": "Session not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
async def mcp_options(request: Request) -> Response:
|
||||
"""Handle OPTIONS requests for CORS preflight."""
|
||||
return Response(
|
||||
status_code=204,
|
||||
headers={
|
||||
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Accept, Authorization, MCP-Session-Id, Mcp-Session-Id, MCP-Protocol-Version, Mcp-Protocol-Version, Last-Event-ID",
|
||||
},
|
||||
)
|
||||
|
||||
async def health(request: Request) -> Response:
|
||||
"""Health check endpoint."""
|
||||
return JSONResponse({
|
||||
"status": "healthy",
|
||||
"server": self.server.name,
|
||||
"version": self.server.version,
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"active_sessions": len(self._sessions),
|
||||
})
|
||||
|
||||
async def root(request: Request) -> Response:
|
||||
"""Root endpoint."""
|
||||
return JSONResponse({
|
||||
"message": f"PraisonAI MCP Server: {self.server.name}",
|
||||
"mcp_endpoint": self.endpoint,
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
})
|
||||
|
||||
# Create routes
|
||||
routes = [
|
||||
Route(self.endpoint, mcp_post, methods=["POST"]),
|
||||
Route(self.endpoint, mcp_get, methods=["GET"]),
|
||||
Route(self.endpoint, mcp_delete, methods=["DELETE"]),
|
||||
Route(self.endpoint, mcp_options, methods=["OPTIONS"]),
|
||||
Route("/health", health, methods=["GET"]),
|
||||
Route("/", root, methods=["GET"]),
|
||||
]
|
||||
|
||||
# Create middleware
|
||||
middleware = [
|
||||
Middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=self.cors_origins,
|
||||
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
),
|
||||
]
|
||||
|
||||
return Starlette(routes=routes, middleware=middleware)
|
||||
|
||||
def _next_event_id(self) -> str:
|
||||
"""Generate next SSE event ID."""
|
||||
self._event_counter += 1
|
||||
return str(self._event_counter)
|
||||
|
||||
def _cleanup_sessions(self) -> None:
|
||||
"""Clean up expired sessions."""
|
||||
now = time.time()
|
||||
expired = [
|
||||
sid for sid, data in self._sessions.items()
|
||||
if now - data["last_activity"] > self.session_ttl
|
||||
]
|
||||
for sid in expired:
|
||||
del self._sessions[sid]
|
||||
if sid in self._event_history:
|
||||
del self._event_history[sid]
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
STDIO Transport for MCP Server
|
||||
|
||||
Implements the MCP STDIO transport protocol:
|
||||
- JSON-RPC 2.0 messages over stdin/stdout
|
||||
- Messages delimited by newlines
|
||||
- Logs go to stderr only
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..server import MCPServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StdioTransport:
|
||||
"""
|
||||
STDIO transport for MCP server.
|
||||
|
||||
Reads JSON-RPC messages from stdin and writes responses to stdout.
|
||||
All logging goes to stderr to avoid corrupting the protocol stream.
|
||||
"""
|
||||
|
||||
def __init__(self, server: "MCPServer"):
|
||||
"""
|
||||
Initialize STDIO transport.
|
||||
|
||||
Args:
|
||||
server: MCPServer instance to handle messages
|
||||
"""
|
||||
self.server = server
|
||||
self._running = False
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Run the STDIO transport loop."""
|
||||
self._running = True
|
||||
|
||||
# Configure logging to stderr only
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
|
||||
logger.info(f"MCP server '{self.server.name}' starting on STDIO transport")
|
||||
|
||||
# Use asyncio streams for non-blocking I/O
|
||||
loop = asyncio.get_event_loop()
|
||||
reader = asyncio.StreamReader()
|
||||
protocol = asyncio.StreamReaderProtocol(reader)
|
||||
|
||||
await loop.connect_read_pipe(lambda: protocol, sys.stdin)
|
||||
|
||||
# Create writer for stdout
|
||||
write_transport, write_protocol = await loop.connect_write_pipe(
|
||||
asyncio.streams.FlowControlMixin, sys.stdout
|
||||
)
|
||||
writer = asyncio.StreamWriter(write_transport, write_protocol, reader, loop)
|
||||
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
# Read a line (JSON-RPC message)
|
||||
line = await reader.readline()
|
||||
if not line:
|
||||
# EOF reached
|
||||
break
|
||||
|
||||
line = line.decode("utf-8").strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Parse JSON-RPC message
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"JSON parse error: {e}")
|
||||
error_response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {
|
||||
"code": -32700,
|
||||
"message": f"Parse error: {e}",
|
||||
},
|
||||
}
|
||||
await self._write_message(writer, error_response)
|
||||
continue
|
||||
|
||||
# Handle message
|
||||
response = await self.server.handle_message(message)
|
||||
|
||||
# Write response if not a notification
|
||||
if response is not None:
|
||||
await self._write_message(writer, response)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.exception(f"Error processing message: {e}")
|
||||
|
||||
finally:
|
||||
self._running = False
|
||||
writer.close()
|
||||
logger.info("MCP server stopped")
|
||||
|
||||
async def _write_message(self, writer: asyncio.StreamWriter, message: dict) -> None:
|
||||
"""Write a JSON-RPC message to stdout."""
|
||||
try:
|
||||
data = json.dumps(message) + "\n"
|
||||
writer.write(data.encode("utf-8"))
|
||||
await writer.drain()
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing message: {e}")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the transport."""
|
||||
self._running = False
|
||||
|
||||
|
||||
def run_stdio_server(server: "MCPServer") -> None:
|
||||
"""
|
||||
Convenience function to run a server with STDIO transport.
|
||||
|
||||
Args:
|
||||
server: MCPServer instance
|
||||
"""
|
||||
transport = StdioTransport(server)
|
||||
asyncio.run(transport.run())
|
||||
@@ -20,10 +20,17 @@ __all__ = [
|
||||
"validate",
|
||||
"list_recipes",
|
||||
"describe",
|
||||
# Runtime Bridge API
|
||||
"resolve",
|
||||
"run_background",
|
||||
"submit_job",
|
||||
"schedule",
|
||||
# Data classes
|
||||
"RecipeResult",
|
||||
"RecipeEvent",
|
||||
"RecipeConfig",
|
||||
"ResolvedRecipe",
|
||||
"RuntimeConfig",
|
||||
# Exceptions
|
||||
"RecipeError",
|
||||
"RecipeNotFoundError",
|
||||
@@ -99,5 +106,29 @@ def __getattr__(name):
|
||||
from .models import ExitCode
|
||||
_module_cache[name] = ExitCode
|
||||
return ExitCode
|
||||
elif name == "resolve":
|
||||
from .bridge import resolve
|
||||
_module_cache[name] = resolve
|
||||
return resolve
|
||||
elif name == "run_background":
|
||||
from .operations import run_background
|
||||
_module_cache[name] = run_background
|
||||
return run_background
|
||||
elif name == "submit_job":
|
||||
from .operations import submit_job
|
||||
_module_cache[name] = submit_job
|
||||
return submit_job
|
||||
elif name == "schedule":
|
||||
from .operations import schedule
|
||||
_module_cache[name] = schedule
|
||||
return schedule
|
||||
elif name == "ResolvedRecipe":
|
||||
from .bridge import ResolvedRecipe
|
||||
_module_cache[name] = ResolvedRecipe
|
||||
return ResolvedRecipe
|
||||
elif name == "RuntimeConfig":
|
||||
from .runtime import RuntimeConfig
|
||||
_module_cache[name] = RuntimeConfig
|
||||
return RuntimeConfig
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Recipe Runtime Bridge.
|
||||
|
||||
Provides unified recipe resolution and execution for operational runtimes:
|
||||
- Background tasks
|
||||
- Async jobs
|
||||
- Scheduled execution
|
||||
|
||||
This module bridges the gap between recipes and operational features,
|
||||
keeping all glue code in the wrapper (praisonai) without modifying core SDK.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedRecipe:
|
||||
"""
|
||||
A fully resolved recipe ready for execution.
|
||||
|
||||
Contains all information needed to execute a recipe in any runtime mode.
|
||||
"""
|
||||
name: str
|
||||
version: str
|
||||
description: str
|
||||
path: Path
|
||||
|
||||
# Workflow/agents configuration
|
||||
workflow_config: Dict[str, Any] = field(default_factory=dict)
|
||||
agents_config: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# User-provided input and config
|
||||
input_data: Any = None
|
||||
config: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Runtime configuration
|
||||
runtime_config: Optional[Any] = None # RuntimeConfig
|
||||
|
||||
# Execution context
|
||||
session_id: Optional[str] = None
|
||||
run_id: str = field(default_factory=lambda: f"run_{uuid.uuid4().hex[:12]}")
|
||||
|
||||
# Options
|
||||
options: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def get_timeout_sec(self, default: int = 300) -> int:
|
||||
"""Get timeout from runtime config or options."""
|
||||
if self.options.get('timeout_sec'):
|
||||
return self.options['timeout_sec']
|
||||
if self.runtime_config and hasattr(self.runtime_config, 'schedule'):
|
||||
return self.runtime_config.schedule.timeout_sec
|
||||
return default
|
||||
|
||||
def get_max_cost_usd(self, default: float = 1.00) -> float:
|
||||
"""Get max cost from runtime config or options."""
|
||||
if self.options.get('max_cost_usd'):
|
||||
return self.options['max_cost_usd']
|
||||
if self.runtime_config and hasattr(self.runtime_config, 'schedule'):
|
||||
return self.runtime_config.schedule.max_cost_usd
|
||||
return default
|
||||
|
||||
|
||||
def resolve(
|
||||
name: str,
|
||||
input_data: Any = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
session_id: Optional[str] = None,
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
) -> ResolvedRecipe:
|
||||
"""
|
||||
Resolve a recipe name into a fully executable ResolvedRecipe.
|
||||
|
||||
Args:
|
||||
name: Recipe name or path
|
||||
input_data: User input for the recipe
|
||||
config: Configuration overrides
|
||||
session_id: Session ID for conversation continuity
|
||||
options: Execution options (timeout_sec, verbose, etc.)
|
||||
|
||||
Returns:
|
||||
ResolvedRecipe ready for execution
|
||||
|
||||
Raises:
|
||||
RecipeNotFoundError: If recipe cannot be found
|
||||
RecipeValidationError: If recipe is invalid
|
||||
"""
|
||||
from praisonai.templates import TemplateLoader
|
||||
from praisonai.recipe.exceptions import RecipeNotFoundError
|
||||
|
||||
options = options or {}
|
||||
config = config or {}
|
||||
|
||||
loader = TemplateLoader(offline=options.get('offline', False))
|
||||
|
||||
try:
|
||||
# Load template configuration
|
||||
template_config = loader.load(name, config=config, offline=options.get('offline', False))
|
||||
except Exception as e:
|
||||
# Try to find similar recipes for helpful error message
|
||||
try:
|
||||
from praisonai.recipe.core import list_recipes
|
||||
available = list_recipes()
|
||||
suggestions = _find_similar_recipes(name, [r.name for r in available])
|
||||
if suggestions:
|
||||
raise RecipeNotFoundError(
|
||||
f"Recipe '{name}' not found. Did you mean: {', '.join(suggestions[:3])}?"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise RecipeNotFoundError(f"Recipe '{name}' not found: {e}")
|
||||
|
||||
# Load workflow and agents configs
|
||||
try:
|
||||
workflow_config = loader.load_workflow_config(template_config)
|
||||
except Exception:
|
||||
workflow_config = {}
|
||||
|
||||
try:
|
||||
agents_config = loader.load_agents_config(template_config)
|
||||
except Exception:
|
||||
agents_config = {}
|
||||
|
||||
# Generate session_id if not provided
|
||||
if not session_id:
|
||||
session_id = f"session_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
return ResolvedRecipe(
|
||||
name=template_config.name,
|
||||
version=template_config.version,
|
||||
description=template_config.description,
|
||||
path=template_config.path,
|
||||
workflow_config=workflow_config,
|
||||
agents_config=agents_config,
|
||||
input_data=input_data,
|
||||
config={**template_config.defaults, **config},
|
||||
runtime_config=template_config.runtime,
|
||||
session_id=session_id,
|
||||
options=options,
|
||||
)
|
||||
|
||||
|
||||
def execute_resolved_recipe(
|
||||
resolved: ResolvedRecipe,
|
||||
progress_callback: Optional[callable] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Execute a resolved recipe and return the result.
|
||||
|
||||
This is the canonical execution function used by all runtime modes.
|
||||
|
||||
Args:
|
||||
resolved: Resolved recipe to execute
|
||||
progress_callback: Optional callback for progress updates
|
||||
|
||||
Returns:
|
||||
Recipe execution result
|
||||
"""
|
||||
from praisonai.recipe.core import run
|
||||
|
||||
# Build options dict
|
||||
options = {
|
||||
'timeout_sec': resolved.get_timeout_sec(),
|
||||
'verbose': resolved.options.get('verbose', False),
|
||||
'dry_run': resolved.options.get('dry_run', False),
|
||||
**resolved.options,
|
||||
}
|
||||
|
||||
# Execute using core recipe.run
|
||||
result = run(
|
||||
resolved.name,
|
||||
input=resolved.input_data,
|
||||
config=resolved.config,
|
||||
session_id=resolved.session_id,
|
||||
options=options,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def execute_resolved_recipe_stream(
|
||||
resolved: ResolvedRecipe,
|
||||
progress_callback: Optional[callable] = None,
|
||||
) -> Iterator[Any]:
|
||||
"""
|
||||
Execute a resolved recipe with streaming output.
|
||||
|
||||
Args:
|
||||
resolved: Resolved recipe to execute
|
||||
progress_callback: Optional callback for progress updates
|
||||
|
||||
Yields:
|
||||
RecipeEvent objects during execution
|
||||
"""
|
||||
from praisonai.recipe.core import run_stream
|
||||
|
||||
# Build options dict
|
||||
options = {
|
||||
'timeout_sec': resolved.get_timeout_sec(),
|
||||
'verbose': resolved.options.get('verbose', False),
|
||||
**resolved.options,
|
||||
}
|
||||
|
||||
# Execute using core recipe.run_stream
|
||||
for event in run_stream(
|
||||
resolved.name,
|
||||
input=resolved.input_data,
|
||||
config=resolved.config,
|
||||
session_id=resolved.session_id,
|
||||
options=options,
|
||||
):
|
||||
if progress_callback:
|
||||
try:
|
||||
progress_callback(event)
|
||||
except Exception as e:
|
||||
logger.warning(f"Progress callback error: {e}")
|
||||
yield event
|
||||
|
||||
|
||||
def _find_similar_recipes(name: str, available: List[str], max_results: int = 5) -> List[str]:
|
||||
"""Find recipes with similar names using simple string matching."""
|
||||
name_lower = name.lower()
|
||||
|
||||
# Exact prefix match
|
||||
prefix_matches = [r for r in available if r.lower().startswith(name_lower)]
|
||||
|
||||
# Contains match
|
||||
contains_matches = [r for r in available if name_lower in r.lower() and r not in prefix_matches]
|
||||
|
||||
# Levenshtein-like similarity (simplified)
|
||||
def similarity(a: str, b: str) -> float:
|
||||
a, b = a.lower(), b.lower()
|
||||
if a == b:
|
||||
return 1.0
|
||||
if a in b or b in a:
|
||||
return 0.8
|
||||
# Count common characters
|
||||
common = sum(1 for c in a if c in b)
|
||||
return common / max(len(a), len(b))
|
||||
|
||||
other_matches = sorted(
|
||||
[r for r in available if r not in prefix_matches and r not in contains_matches],
|
||||
key=lambda r: similarity(name, r),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
results = prefix_matches + contains_matches + other_matches
|
||||
return results[:max_results]
|
||||
|
||||
|
||||
# Convenience functions for different runtime modes
|
||||
|
||||
def get_recipe_task_description(resolved: ResolvedRecipe) -> str:
|
||||
"""Get a task description suitable for scheduler/background."""
|
||||
if resolved.input_data:
|
||||
if isinstance(resolved.input_data, str):
|
||||
return resolved.input_data
|
||||
elif isinstance(resolved.input_data, dict):
|
||||
return str(resolved.input_data.get('prompt', resolved.input_data.get('task', str(resolved.input_data))))
|
||||
return f"Execute recipe: {resolved.name}"
|
||||
|
||||
|
||||
def get_recipe_metadata(resolved: ResolvedRecipe) -> Dict[str, Any]:
|
||||
"""Get metadata dict for job/scheduler state persistence."""
|
||||
return {
|
||||
'recipe_name': resolved.name,
|
||||
'recipe_version': resolved.version,
|
||||
'recipe_path': str(resolved.path) if resolved.path else None,
|
||||
'session_id': resolved.session_id,
|
||||
'run_id': resolved.run_id,
|
||||
'config': resolved.config,
|
||||
'options': resolved.options,
|
||||
}
|
||||
@@ -59,6 +59,86 @@ def _get_timestamp() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def get_template_search_paths() -> List[Path]:
|
||||
"""
|
||||
Get list of paths to search for recipe templates.
|
||||
|
||||
Precedence order:
|
||||
1. PRAISONAI_RECIPE_PATH environment variable (colon-separated)
|
||||
2. Current working directory ./recipes
|
||||
3. User home ~/.praison/recipes
|
||||
4. Agent-Recipes package templates (if installed)
|
||||
5. Built-in templates
|
||||
|
||||
Returns:
|
||||
List of Path objects to search for templates
|
||||
"""
|
||||
paths = []
|
||||
|
||||
# 1. Environment variable
|
||||
env_path = os.environ.get("PRAISONAI_RECIPE_PATH")
|
||||
if env_path:
|
||||
for p in env_path.split(os.pathsep):
|
||||
path = Path(p).expanduser()
|
||||
if path.exists():
|
||||
paths.append(path)
|
||||
|
||||
# 2. Current working directory
|
||||
cwd_recipes = Path.cwd() / "recipes"
|
||||
if cwd_recipes.exists():
|
||||
paths.append(cwd_recipes)
|
||||
|
||||
# 3. User home
|
||||
home_recipes = Path.home() / ".praison" / "recipes"
|
||||
if home_recipes.exists():
|
||||
paths.append(home_recipes)
|
||||
|
||||
# 4. Agent-Recipes package (lazy import)
|
||||
try:
|
||||
import agent_recipes
|
||||
if hasattr(agent_recipes, 'get_template_path'):
|
||||
agent_path = Path(agent_recipes.get_template_path(""))
|
||||
if agent_path.parent.exists():
|
||||
paths.append(agent_path.parent)
|
||||
elif hasattr(agent_recipes, '__file__'):
|
||||
agent_templates = Path(agent_recipes.__file__).parent / "templates"
|
||||
if agent_templates.exists():
|
||||
paths.append(agent_templates)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 5. Built-in templates (relative to this package)
|
||||
builtin = Path(__file__).parent.parent / "templates"
|
||||
if builtin.exists():
|
||||
paths.append(builtin)
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def reload_registry():
|
||||
"""
|
||||
Reload the recipe registry, clearing any cached templates.
|
||||
|
||||
This is useful for hot-reloading recipes during development
|
||||
or when using the /admin/reload endpoint.
|
||||
"""
|
||||
global _recipe_cache
|
||||
if '_recipe_cache' in globals():
|
||||
_recipe_cache.clear()
|
||||
|
||||
# Also clear any module-level caches
|
||||
import importlib
|
||||
try:
|
||||
from praisonai import recipe as recipe_module
|
||||
importlib.reload(recipe_module)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Recipe cache for performance
|
||||
_recipe_cache: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def run(
|
||||
name: str,
|
||||
input: Union[str, Dict[str, Any]] = None,
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
"""
|
||||
Recipe Operations Module.
|
||||
|
||||
Provides high-level APIs for running recipes in different operational modes:
|
||||
- run_background(): Execute recipe as a background task
|
||||
- submit_job(): Submit recipe to async jobs server
|
||||
- schedule(): Create a scheduled recipe executor
|
||||
|
||||
All functions are lazy-loaded and have zero performance impact when not used.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Safe defaults
|
||||
DEFAULT_TIMEOUT_SEC = 300
|
||||
DEFAULT_MAX_COST_USD = 1.00
|
||||
DEFAULT_MAX_RETRIES = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackgroundTaskHandle:
|
||||
"""Handle for a background recipe task."""
|
||||
task_id: str
|
||||
recipe_name: str
|
||||
session_id: Optional[str] = None
|
||||
_runner: Any = field(default=None, repr=False)
|
||||
_task: Any = field(default=None, repr=False)
|
||||
|
||||
async def status(self) -> str:
|
||||
"""Get task status."""
|
||||
if self._runner and self._task:
|
||||
return self._task.status.value
|
||||
return "unknown"
|
||||
|
||||
async def wait(self, timeout: Optional[float] = None) -> Any:
|
||||
"""Wait for task completion and return result."""
|
||||
if self._runner and self._task:
|
||||
return await self._runner.wait_for_task(self._task.id, timeout=timeout)
|
||||
return None
|
||||
|
||||
async def cancel(self) -> bool:
|
||||
"""Cancel the task."""
|
||||
if self._runner:
|
||||
return await self._runner.cancel(self.task_id)
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobHandle:
|
||||
"""Handle for an async job."""
|
||||
job_id: str
|
||||
recipe_name: str
|
||||
status: str = "queued"
|
||||
poll_url: Optional[str] = None
|
||||
stream_url: Optional[str] = None
|
||||
api_url: str = "http://127.0.0.1:8005"
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get job status from API."""
|
||||
try:
|
||||
import httpx
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.get(f"{self.api_url}/api/v1/runs/{self.job_id}")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get job status: {e}")
|
||||
return {"job_id": self.job_id, "status": "unknown", "error": str(e)}
|
||||
|
||||
def get_result(self) -> Any:
|
||||
"""Get job result from API."""
|
||||
try:
|
||||
import httpx
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.get(f"{self.api_url}/api/v1/runs/{self.job_id}/result")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get job result: {e}")
|
||||
return {"job_id": self.job_id, "error": str(e)}
|
||||
|
||||
def cancel(self) -> bool:
|
||||
"""Cancel the job."""
|
||||
try:
|
||||
import httpx
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.post(f"{self.api_url}/api/v1/runs/{self.job_id}/cancel")
|
||||
return response.status_code < 400
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cancel job: {e}")
|
||||
return False
|
||||
|
||||
def wait(self, poll_interval: int = 5, timeout: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Wait for job completion by polling."""
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
status = self.get_status()
|
||||
if status.get("status") in ("succeeded", "failed", "cancelled"):
|
||||
return self.get_result()
|
||||
|
||||
if timeout and (time.time() - start_time) > timeout:
|
||||
return {"job_id": self.job_id, "status": "timeout", "error": "Wait timeout exceeded"}
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecipeScheduler:
|
||||
"""Wrapper around AgentScheduler for recipe-based scheduling."""
|
||||
recipe_name: str
|
||||
interval: str = "hourly"
|
||||
max_retries: int = DEFAULT_MAX_RETRIES
|
||||
timeout_sec: int = DEFAULT_TIMEOUT_SEC
|
||||
max_cost_usd: float = DEFAULT_MAX_COST_USD
|
||||
run_immediately: bool = False
|
||||
_scheduler: Any = field(default=None, repr=False)
|
||||
_resolved: Any = field(default=None, repr=False)
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the scheduler."""
|
||||
if self._scheduler:
|
||||
return self._scheduler.start(
|
||||
schedule_expr=self.interval,
|
||||
max_retries=self.max_retries,
|
||||
run_immediately=self.run_immediately
|
||||
)
|
||||
return False
|
||||
|
||||
def stop(self) -> bool:
|
||||
"""Stop the scheduler."""
|
||||
if self._scheduler:
|
||||
return self._scheduler.stop()
|
||||
return False
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get scheduler statistics."""
|
||||
if self._scheduler:
|
||||
return self._scheduler.get_stats()
|
||||
return {}
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if scheduler is running."""
|
||||
if self._scheduler:
|
||||
return self._scheduler.is_running
|
||||
return False
|
||||
|
||||
|
||||
def run_background(
|
||||
name: str,
|
||||
*,
|
||||
input: Any = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
session_id: Optional[str] = None,
|
||||
timeout_sec: Optional[int] = None,
|
||||
max_concurrent: int = 5,
|
||||
on_complete: Optional[callable] = None,
|
||||
) -> BackgroundTaskHandle:
|
||||
"""
|
||||
Run a recipe as a background task.
|
||||
|
||||
Args:
|
||||
name: Recipe name
|
||||
input: Input data for the recipe
|
||||
config: Configuration overrides
|
||||
session_id: Session ID for conversation continuity
|
||||
timeout_sec: Timeout in seconds (default: 300)
|
||||
max_concurrent: Max concurrent tasks (default: 5)
|
||||
on_complete: Callback when task completes
|
||||
|
||||
Returns:
|
||||
BackgroundTaskHandle for tracking the task
|
||||
|
||||
Example:
|
||||
task = recipe.run_background("my-recipe", input={"query": "test"})
|
||||
print(f"Task ID: {task.task_id}")
|
||||
result = await task.wait()
|
||||
"""
|
||||
import asyncio
|
||||
from .bridge import resolve, execute_resolved_recipe
|
||||
|
||||
# Resolve the recipe
|
||||
resolved = resolve(
|
||||
name,
|
||||
input_data=input,
|
||||
config=config,
|
||||
session_id=session_id,
|
||||
options={'timeout_sec': timeout_sec or DEFAULT_TIMEOUT_SEC},
|
||||
)
|
||||
|
||||
# Import BackgroundRunner lazily
|
||||
try:
|
||||
from praisonaiagents.background import BackgroundRunner
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"Background tasks require praisonaiagents. "
|
||||
"Install with: pip install praisonaiagents"
|
||||
)
|
||||
|
||||
# Create or get runner
|
||||
runner = BackgroundRunner(max_concurrent_tasks=max_concurrent)
|
||||
|
||||
# Define the task function
|
||||
def recipe_task():
|
||||
return execute_resolved_recipe(resolved)
|
||||
|
||||
# Submit the task
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# We're in an async context, use create_task
|
||||
import concurrent.futures
|
||||
future = concurrent.futures.Future()
|
||||
|
||||
async def submit_and_return():
|
||||
task = await runner.submit(
|
||||
recipe_task,
|
||||
name=f"recipe:{resolved.name}",
|
||||
timeout=timeout_sec,
|
||||
on_complete=on_complete,
|
||||
)
|
||||
return task
|
||||
|
||||
asyncio.ensure_future(submit_and_return()).add_done_callback(
|
||||
lambda f: future.set_result(f.result())
|
||||
)
|
||||
task = future.result(timeout=10)
|
||||
else:
|
||||
# Sync context
|
||||
task = loop.run_until_complete(runner.submit(
|
||||
recipe_task,
|
||||
name=f"recipe:{resolved.name}",
|
||||
timeout=timeout_sec,
|
||||
on_complete=on_complete,
|
||||
))
|
||||
|
||||
return BackgroundTaskHandle(
|
||||
task_id=task.id,
|
||||
recipe_name=resolved.name,
|
||||
session_id=resolved.session_id,
|
||||
_runner=runner,
|
||||
_task=task,
|
||||
)
|
||||
|
||||
|
||||
def submit_job(
|
||||
name: str,
|
||||
*,
|
||||
input: Any = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
session_id: Optional[str] = None,
|
||||
timeout_sec: Optional[int] = None,
|
||||
idempotency_key: Optional[str] = None,
|
||||
webhook_url: Optional[str] = None,
|
||||
api_url: str = "http://127.0.0.1:8005",
|
||||
wait: bool = False,
|
||||
poll_interval: int = 5,
|
||||
) -> JobHandle:
|
||||
"""
|
||||
Submit a recipe to the async jobs server.
|
||||
|
||||
Args:
|
||||
name: Recipe name
|
||||
input: Input data for the recipe
|
||||
config: Configuration overrides
|
||||
session_id: Session ID for conversation continuity
|
||||
timeout_sec: Timeout in seconds (default: 3600)
|
||||
idempotency_key: Key for deduplication
|
||||
webhook_url: URL for completion webhook
|
||||
api_url: Jobs API URL (default: http://127.0.0.1:8005)
|
||||
wait: If True, wait for completion before returning
|
||||
poll_interval: Polling interval when waiting (seconds)
|
||||
|
||||
Returns:
|
||||
JobHandle for tracking the job
|
||||
|
||||
Example:
|
||||
job = recipe.submit_job("my-recipe", input={"query": "test"}, wait=True)
|
||||
print(f"Result: {job.get_result()}")
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"Job submission requires httpx. "
|
||||
"Install with: pip install httpx"
|
||||
)
|
||||
|
||||
# Build request payload
|
||||
payload = {
|
||||
"prompt": str(input) if input else f"Execute recipe: {name}",
|
||||
"recipe_name": name,
|
||||
"timeout": timeout_sec or 3600,
|
||||
}
|
||||
|
||||
if config:
|
||||
payload["config"] = config
|
||||
if session_id:
|
||||
payload["session_id"] = session_id
|
||||
if idempotency_key:
|
||||
payload["idempotency_key"] = idempotency_key
|
||||
if webhook_url:
|
||||
payload["webhook_url"] = webhook_url
|
||||
|
||||
# Submit to API
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.post(
|
||||
f"{api_url}/api/v1/runs",
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
handle = JobHandle(
|
||||
job_id=data["job_id"],
|
||||
recipe_name=name,
|
||||
status=data.get("status", "queued"),
|
||||
poll_url=data.get("poll_url"),
|
||||
stream_url=data.get("stream_url"),
|
||||
api_url=api_url,
|
||||
)
|
||||
|
||||
if wait:
|
||||
handle.wait(poll_interval=poll_interval, timeout=timeout_sec)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def schedule(
|
||||
name: str,
|
||||
*,
|
||||
input: Any = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
interval: Optional[str] = None,
|
||||
max_retries: Optional[int] = None,
|
||||
run_immediately: Optional[bool] = None,
|
||||
timeout_sec: Optional[int] = None,
|
||||
max_cost_usd: Optional[float] = None,
|
||||
on_success: Optional[callable] = None,
|
||||
on_failure: Optional[callable] = None,
|
||||
) -> RecipeScheduler:
|
||||
"""
|
||||
Create a scheduler for periodic recipe execution.
|
||||
|
||||
Args:
|
||||
name: Recipe name
|
||||
input: Input data for the recipe
|
||||
config: Configuration overrides
|
||||
interval: Schedule interval (hourly, daily, */30m, etc.)
|
||||
max_retries: Max retry attempts (default: 3)
|
||||
run_immediately: Run once immediately (default: False)
|
||||
timeout_sec: Timeout per execution (default: 300)
|
||||
max_cost_usd: Budget limit (default: $1.00)
|
||||
on_success: Callback on successful execution
|
||||
on_failure: Callback on failed execution
|
||||
|
||||
Returns:
|
||||
RecipeScheduler instance (call .start() to begin)
|
||||
|
||||
Example:
|
||||
scheduler = recipe.schedule("news-monitor", interval="hourly")
|
||||
scheduler.start()
|
||||
# ... later ...
|
||||
scheduler.stop()
|
||||
"""
|
||||
from .bridge import resolve, execute_resolved_recipe, get_recipe_task_description
|
||||
|
||||
# Resolve the recipe
|
||||
resolved = resolve(
|
||||
name,
|
||||
input_data=input,
|
||||
config=config,
|
||||
options={'timeout_sec': timeout_sec or DEFAULT_TIMEOUT_SEC},
|
||||
)
|
||||
|
||||
# Get runtime config defaults
|
||||
runtime = resolved.runtime_config
|
||||
if runtime and hasattr(runtime, 'schedule'):
|
||||
sched_config = runtime.schedule
|
||||
interval = interval or sched_config.interval
|
||||
max_retries = max_retries if max_retries is not None else sched_config.max_retries
|
||||
run_immediately = run_immediately if run_immediately is not None else sched_config.run_immediately
|
||||
timeout_sec = timeout_sec or sched_config.timeout_sec
|
||||
max_cost_usd = max_cost_usd if max_cost_usd is not None else sched_config.max_cost_usd
|
||||
|
||||
# Apply defaults
|
||||
interval = interval or "hourly"
|
||||
max_retries = max_retries if max_retries is not None else DEFAULT_MAX_RETRIES
|
||||
run_immediately = run_immediately if run_immediately is not None else False
|
||||
timeout_sec = timeout_sec or DEFAULT_TIMEOUT_SEC
|
||||
max_cost_usd = max_cost_usd if max_cost_usd is not None else DEFAULT_MAX_COST_USD
|
||||
|
||||
# Import AgentScheduler lazily
|
||||
try:
|
||||
from praisonai.scheduler import AgentScheduler
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"Scheduling requires praisonai scheduler module."
|
||||
)
|
||||
|
||||
# Create a recipe executor agent wrapper
|
||||
class RecipeExecutorAgent:
|
||||
"""Wrapper that makes a recipe look like an agent for the scheduler."""
|
||||
def __init__(self, resolved_recipe):
|
||||
self.resolved = resolved_recipe
|
||||
self.name = f"RecipeAgent:{resolved_recipe.name}"
|
||||
|
||||
def start(self, task: str) -> Any:
|
||||
return execute_resolved_recipe(self.resolved)
|
||||
|
||||
# Create the agent wrapper
|
||||
agent = RecipeExecutorAgent(resolved)
|
||||
task = get_recipe_task_description(resolved)
|
||||
|
||||
# Create the scheduler
|
||||
scheduler = AgentScheduler(
|
||||
agent=agent,
|
||||
task=task,
|
||||
timeout=timeout_sec,
|
||||
max_cost=max_cost_usd,
|
||||
on_success=on_success,
|
||||
on_failure=on_failure,
|
||||
)
|
||||
|
||||
return RecipeScheduler(
|
||||
recipe_name=resolved.name,
|
||||
interval=interval,
|
||||
max_retries=max_retries,
|
||||
timeout_sec=timeout_sec,
|
||||
max_cost_usd=max_cost_usd,
|
||||
run_immediately=run_immediately,
|
||||
_scheduler=scheduler,
|
||||
_resolved=resolved,
|
||||
)
|
||||
@@ -4,8 +4,10 @@ Recipe Registry Module
|
||||
Provides local and remote registry support for recipe distribution.
|
||||
Supports:
|
||||
- Local filesystem registry (~/.praison/registry)
|
||||
- Remote HTTP registry (optional)
|
||||
- Local HTTP registry (http://localhost:7777)
|
||||
- Remote HTTP registry (https://registry.example.com)
|
||||
- Publish, pull, search, list operations
|
||||
- Atomic writes and file locking for concurrency safety
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -13,15 +15,18 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from typing import Any, Dict, List, Optional, Union, Protocol
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
# Default registry paths
|
||||
DEFAULT_REGISTRY_PATH = Path.home() / ".praison" / "registry"
|
||||
DEFAULT_RUNS_PATH = Path.home() / ".praison" / "runs"
|
||||
DEFAULT_REGISTRY_PORT = 7777
|
||||
|
||||
|
||||
class RegistryError(Exception):
|
||||
@@ -44,6 +49,52 @@ class RegistryAuthError(RegistryError):
|
||||
pass
|
||||
|
||||
|
||||
class RegistryNetworkError(RegistryError):
|
||||
"""Network error connecting to registry."""
|
||||
pass
|
||||
|
||||
|
||||
class RegistryConflictError(RegistryError):
|
||||
"""Conflict error (e.g., already exists without force)."""
|
||||
pass
|
||||
|
||||
|
||||
class RegistryProtocol(Protocol):
|
||||
"""Protocol defining registry interface for type checking."""
|
||||
|
||||
def publish(
|
||||
self,
|
||||
bundle_path: Union[str, Path],
|
||||
force: bool = False,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]: ...
|
||||
|
||||
def pull(
|
||||
self,
|
||||
name: str,
|
||||
version: Optional[str] = None,
|
||||
output_dir: Optional[Path] = None,
|
||||
verify_checksum: bool = True,
|
||||
) -> Dict[str, Any]: ...
|
||||
|
||||
def list_recipes(
|
||||
self,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]: ...
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]: ...
|
||||
|
||||
def get_versions(self, name: str) -> List[str]: ...
|
||||
|
||||
def get_info(self, name: str, version: Optional[str] = None) -> Dict[str, Any]: ...
|
||||
|
||||
def delete(self, name: str, version: Optional[str] = None) -> bool: ...
|
||||
|
||||
|
||||
def _get_timestamp() -> str:
|
||||
"""Get current timestamp in ISO format."""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@@ -58,6 +109,46 @@ def _calculate_checksum(file_path: Path) -> str:
|
||||
return sha256.hexdigest()
|
||||
|
||||
|
||||
def _normalize_name(name: str) -> str:
|
||||
"""Normalize recipe name per PEP 503 rules."""
|
||||
return re.sub(r"[-_.]+", "-", name).lower()
|
||||
|
||||
|
||||
def _validate_name(name: str) -> bool:
|
||||
"""Validate recipe name format."""
|
||||
if not name or len(name) > 128:
|
||||
return False
|
||||
return bool(re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$', name))
|
||||
|
||||
|
||||
def _validate_version(version: str) -> bool:
|
||||
"""Validate version string (semver-like)."""
|
||||
if not version:
|
||||
return False
|
||||
return bool(re.match(r'^\d+\.\d+\.\d+([a-zA-Z0-9._-]*)?$', version))
|
||||
|
||||
|
||||
def _atomic_write(file_path: Path, data: bytes) -> None:
|
||||
"""Write data atomically using temp file + rename."""
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(dir=file_path.parent, suffix='.tmp')
|
||||
try:
|
||||
os.write(fd, data)
|
||||
os.fsync(fd)
|
||||
os.close(fd)
|
||||
os.rename(tmp_path, file_path)
|
||||
except Exception:
|
||||
os.close(fd)
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
|
||||
|
||||
def _atomic_write_json(file_path: Path, data: Dict[str, Any]) -> None:
|
||||
"""Write JSON data atomically."""
|
||||
_atomic_write(file_path, json.dumps(data, indent=2).encode('utf-8'))
|
||||
|
||||
|
||||
class LocalRegistry:
|
||||
"""
|
||||
Local filesystem-based recipe registry.
|
||||
@@ -394,14 +485,18 @@ class LocalRegistry:
|
||||
return True
|
||||
|
||||
|
||||
class RemoteRegistry:
|
||||
class HttpRegistry:
|
||||
"""
|
||||
Remote HTTP-based recipe registry client.
|
||||
HTTP-based recipe registry client.
|
||||
|
||||
Works with both local HTTP registry (http://localhost:7777) and
|
||||
remote HTTP registries (https://registry.example.com).
|
||||
|
||||
Supports:
|
||||
- Token-based authentication
|
||||
- Publish/pull operations
|
||||
- Search and list
|
||||
- Token-based authentication (Bearer token)
|
||||
- Multipart file upload for publish
|
||||
- ETag/If-None-Match for efficient downloads
|
||||
- Proper error handling with specific exceptions
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -411,50 +506,147 @@ class RemoteRegistry:
|
||||
timeout: int = 30,
|
||||
):
|
||||
"""
|
||||
Initialize remote registry client.
|
||||
Initialize HTTP registry client.
|
||||
|
||||
Args:
|
||||
url: Registry base URL
|
||||
token: Authentication token
|
||||
url: Registry base URL (http://localhost:7777 or https://...)
|
||||
token: Authentication token (or set PRAISONAI_REGISTRY_TOKEN env var)
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.url = url.rstrip("/")
|
||||
self.token = token or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
|
||||
self.timeout = timeout
|
||||
self._etag_cache: Dict[str, str] = {}
|
||||
|
||||
def _get_headers(self, content_type: str = "application/json") -> Dict[str, str]:
|
||||
"""Get request headers with optional auth."""
|
||||
headers = {"Content-Type": content_type}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
return headers
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
data: Optional[Dict] = None,
|
||||
files: Optional[Dict] = None,
|
||||
data: Optional[bytes] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make HTTP request to registry."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
url = f"{self.url}{path}"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req_headers = headers or self._get_headers()
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
||||
# Cache ETag for future requests
|
||||
etag = response.headers.get("ETag")
|
||||
if etag:
|
||||
self._etag_cache[path] = etag
|
||||
return json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
raise RegistryAuthError("Authentication failed")
|
||||
raise RegistryAuthError("Authentication failed. Check your token.")
|
||||
elif e.code == 403:
|
||||
raise RegistryAuthError("Access denied. Token may lack required permissions.")
|
||||
elif e.code == 404:
|
||||
raise RecipeNotFoundError(f"Not found: {path}")
|
||||
elif e.code == 409:
|
||||
raise RegistryConflictError("Recipe version already exists. Use --force to overwrite.")
|
||||
else:
|
||||
body = e.read().decode() if e.fp else ""
|
||||
raise RegistryError(f"Registry error ({e.code}): {body}")
|
||||
except urllib.error.URLError as e:
|
||||
raise RegistryError(f"Connection error: {e.reason}")
|
||||
raise RegistryNetworkError(f"Connection error: {e.reason}")
|
||||
|
||||
def _download_file(self, path: str, dest_path: Path) -> Dict[str, Any]:
|
||||
"""Download file from registry with ETag support."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
url = f"{self.url}{path}"
|
||||
headers = self._get_headers()
|
||||
|
||||
# Add If-None-Match if we have cached ETag
|
||||
if path in self._etag_cache:
|
||||
headers["If-None-Match"] = self._etag_cache[path]
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, headers=headers, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
||||
etag = response.headers.get("ETag")
|
||||
if etag:
|
||||
self._etag_cache[path] = etag
|
||||
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(dest_path, "wb") as f:
|
||||
shutil.copyfileobj(response, f)
|
||||
|
||||
return {"downloaded": True, "path": str(dest_path)}
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 304:
|
||||
return {"downloaded": False, "cached": True}
|
||||
elif e.code == 404:
|
||||
raise RecipeNotFoundError(f"Not found: {path}")
|
||||
else:
|
||||
raise RegistryError(f"Download error ({e.code})")
|
||||
except urllib.error.URLError as e:
|
||||
raise RegistryNetworkError(f"Connection error: {e.reason}")
|
||||
|
||||
def _upload_file(self, path: str, file_path: Path, force: bool = False) -> Dict[str, Any]:
|
||||
"""Upload file to registry using multipart form."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import uuid
|
||||
|
||||
boundary = f"----PraisonAI{uuid.uuid4().hex}"
|
||||
|
||||
# Build multipart body
|
||||
body_parts = []
|
||||
|
||||
# Add force field
|
||||
body_parts.append(f"--{boundary}".encode())
|
||||
body_parts.append(b'Content-Disposition: form-data; name="force"')
|
||||
body_parts.append(b"")
|
||||
body_parts.append(b"true" if force else b"false")
|
||||
|
||||
# Add file
|
||||
body_parts.append(f"--{boundary}".encode())
|
||||
body_parts.append(f'Content-Disposition: form-data; name="bundle"; filename="{file_path.name}"'.encode())
|
||||
body_parts.append(b"Content-Type: application/gzip")
|
||||
body_parts.append(b"")
|
||||
with open(file_path, "rb") as f:
|
||||
body_parts.append(f.read())
|
||||
|
||||
body_parts.append(f"--{boundary}--".encode())
|
||||
body_parts.append(b"")
|
||||
|
||||
body = b"\r\n".join(body_parts)
|
||||
|
||||
headers = self._get_headers(f"multipart/form-data; boundary={boundary}")
|
||||
|
||||
url = f"{self.url}{path}"
|
||||
try:
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
||||
return json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
raise RegistryAuthError("Authentication required for publish")
|
||||
elif e.code == 409:
|
||||
raise RegistryConflictError("Recipe version already exists. Use --force to overwrite.")
|
||||
else:
|
||||
body_text = e.read().decode() if e.fp else ""
|
||||
raise RegistryError(f"Upload error ({e.code}): {body_text}")
|
||||
except urllib.error.URLError as e:
|
||||
raise RegistryNetworkError(f"Connection error: {e.reason}")
|
||||
|
||||
def health(self) -> Dict[str, Any]:
|
||||
"""Check registry health."""
|
||||
return self._request("GET", "/healthz")
|
||||
|
||||
def publish(
|
||||
self,
|
||||
@@ -462,24 +654,39 @@ class RemoteRegistry:
|
||||
force: bool = False,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Publish bundle to remote registry."""
|
||||
"""
|
||||
Publish bundle to HTTP registry.
|
||||
|
||||
Args:
|
||||
bundle_path: Path to .praison bundle file
|
||||
force: Overwrite existing version if True
|
||||
metadata: Additional metadata (ignored for HTTP, included in bundle)
|
||||
|
||||
Returns:
|
||||
Dict with name, version, checksum
|
||||
"""
|
||||
bundle_path = Path(bundle_path)
|
||||
if not bundle_path.exists():
|
||||
raise RegistryError(f"Bundle not found: {bundle_path}")
|
||||
|
||||
# For remote, we'd upload the file
|
||||
# This is a simplified implementation
|
||||
with open(bundle_path, "rb") as f:
|
||||
bundle_data = f.read()
|
||||
# Extract name/version from bundle to construct path
|
||||
try:
|
||||
with tarfile.open(bundle_path, "r:gz") as tar:
|
||||
manifest_file = tar.extractfile("manifest.json")
|
||||
if not manifest_file:
|
||||
raise RegistryError("Bundle missing manifest.json")
|
||||
manifest = json.load(manifest_file)
|
||||
except tarfile.TarError as e:
|
||||
raise RegistryError(f"Invalid bundle format: {e}")
|
||||
|
||||
import base64
|
||||
encoded = base64.b64encode(bundle_data).decode()
|
||||
name = manifest.get("name")
|
||||
version = manifest.get("version")
|
||||
|
||||
return self._request("POST", "/v1/recipes/publish", {
|
||||
"bundle": encoded,
|
||||
"force": force,
|
||||
"metadata": metadata or {},
|
||||
})
|
||||
if not name or not version:
|
||||
raise RegistryError("Bundle manifest missing name or version")
|
||||
|
||||
# Upload to /v1/recipes/{name}/{version}
|
||||
return self._upload_file(f"/v1/recipes/{name}/{version}", bundle_path, force=force)
|
||||
|
||||
def pull(
|
||||
self,
|
||||
@@ -488,20 +695,41 @@ class RemoteRegistry:
|
||||
output_dir: Optional[Path] = None,
|
||||
verify_checksum: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Pull recipe from remote registry."""
|
||||
version_str = f"@{version}" if version else ""
|
||||
result = self._request("GET", f"/v1/recipes/{name}{version_str}/download")
|
||||
"""
|
||||
Pull recipe from HTTP registry.
|
||||
|
||||
# Decode and save bundle
|
||||
import base64
|
||||
bundle_data = base64.b64decode(result["bundle"])
|
||||
Args:
|
||||
name: Recipe name
|
||||
version: Version to pull (default: latest)
|
||||
output_dir: Directory to extract to
|
||||
verify_checksum: Verify bundle checksum
|
||||
|
||||
Returns:
|
||||
Dict with name, version, path
|
||||
"""
|
||||
# Get recipe info to determine version
|
||||
if not version:
|
||||
info = self._request("GET", f"/v1/recipes/{name}")
|
||||
version = info.get("latest")
|
||||
if not version:
|
||||
raise RecipeNotFoundError(f"No versions found for: {name}")
|
||||
|
||||
output_dir = Path(output_dir) if output_dir else Path.cwd()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
bundle_path = output_dir / f"{name}-{version}.praison"
|
||||
|
||||
bundle_path = output_dir / f"{name}-{result['version']}.praison"
|
||||
with open(bundle_path, "wb") as f:
|
||||
f.write(bundle_data)
|
||||
# Download bundle
|
||||
download_path = f"/v1/recipes/{name}/{version}/download"
|
||||
self._download_file(download_path, bundle_path)
|
||||
|
||||
# Verify checksum if requested
|
||||
if verify_checksum:
|
||||
info = self._request("GET", f"/v1/recipes/{name}/{version}")
|
||||
expected = info.get("checksum")
|
||||
if expected:
|
||||
actual = _calculate_checksum(bundle_path)
|
||||
if expected != actual:
|
||||
bundle_path.unlink()
|
||||
raise RegistryError(f"Checksum mismatch for {name}@{version}")
|
||||
|
||||
# Extract
|
||||
recipe_dir = output_dir / name
|
||||
@@ -512,24 +740,87 @@ class RemoteRegistry:
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"version": result["version"],
|
||||
"version": version,
|
||||
"path": str(recipe_dir),
|
||||
"bundle_path": str(bundle_path),
|
||||
}
|
||||
|
||||
def list_recipes(self, tags: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
||||
"""List recipes from remote registry."""
|
||||
params = ""
|
||||
if tags:
|
||||
params = f"?tags={','.join(tags)}"
|
||||
return self._request("GET", f"/v1/recipes{params}").get("recipes", [])
|
||||
|
||||
def search(self, query: str, tags: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
||||
"""Search recipes in remote registry."""
|
||||
params = f"?q={query}"
|
||||
def list_recipes(
|
||||
self,
|
||||
tags: Optional[List[str]] = None,
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List recipes from HTTP registry.
|
||||
|
||||
Args:
|
||||
tags: Filter by tags
|
||||
page: Page number (1-indexed)
|
||||
per_page: Results per page
|
||||
|
||||
Returns:
|
||||
List of recipe info dicts
|
||||
"""
|
||||
params = f"?page={page}&per_page={per_page}"
|
||||
if tags:
|
||||
params += f"&tags={','.join(tags)}"
|
||||
return self._request("GET", f"/v1/recipes/search{params}").get("results", [])
|
||||
result = self._request("GET", f"/v1/recipes{params}")
|
||||
return result.get("recipes", [])
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search recipes in HTTP registry.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
tags: Filter by tags
|
||||
|
||||
Returns:
|
||||
List of matching recipe info dicts
|
||||
"""
|
||||
from urllib.parse import quote as url_quote
|
||||
params = f"?q={url_quote(query)}"
|
||||
if tags:
|
||||
params += f"&tags={','.join(tags)}"
|
||||
result = self._request("GET", f"/v1/search{params}")
|
||||
return result.get("results", [])
|
||||
|
||||
def get_versions(self, name: str) -> List[str]:
|
||||
"""Get all versions of a recipe."""
|
||||
result = self._request("GET", f"/v1/recipes/{name}")
|
||||
return result.get("versions", [])
|
||||
|
||||
def get_info(self, name: str, version: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Get detailed info about a recipe version."""
|
||||
if version:
|
||||
return self._request("GET", f"/v1/recipes/{name}/{version}")
|
||||
return self._request("GET", f"/v1/recipes/{name}")
|
||||
|
||||
def delete(self, name: str, version: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Delete a recipe or specific version.
|
||||
|
||||
Args:
|
||||
name: Recipe name
|
||||
version: Version to delete (None = all versions)
|
||||
|
||||
Returns:
|
||||
True if deleted
|
||||
"""
|
||||
if version:
|
||||
self._request("DELETE", f"/v1/recipes/{name}/{version}")
|
||||
else:
|
||||
self._request("DELETE", f"/v1/recipes/{name}")
|
||||
return True
|
||||
|
||||
|
||||
# Alias for backwards compatibility
|
||||
RemoteRegistry = HttpRegistry
|
||||
|
||||
|
||||
def get_registry(
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Recipe Runtime Configuration Models.
|
||||
|
||||
Defines the runtime configuration schema for recipes, enabling:
|
||||
- Background task execution
|
||||
- Async job submission
|
||||
- Scheduled execution (24/7 daemon)
|
||||
|
||||
All fields are optional and backward compatible with existing recipes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# Safe defaults for operational features
|
||||
DEFAULT_TIMEOUT_SEC = 300 # 5 minutes
|
||||
DEFAULT_MAX_COST_USD = 1.00 # $1.00 budget limit
|
||||
DEFAULT_MAX_RETRIES = 3
|
||||
DEFAULT_MAX_CONCURRENT = 5
|
||||
DEFAULT_CLEANUP_DELAY_SEC = 3600 # 1 hour
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackgroundRuntimeConfig:
|
||||
"""Configuration for background task execution."""
|
||||
enabled: bool = False
|
||||
max_concurrent: int = DEFAULT_MAX_CONCURRENT
|
||||
cleanup_delay_sec: int = DEFAULT_CLEANUP_DELAY_SEC
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> 'BackgroundRuntimeConfig':
|
||||
"""Create from dictionary with defaults."""
|
||||
if not data:
|
||||
return cls()
|
||||
return cls(
|
||||
enabled=data.get('enabled', False),
|
||||
max_concurrent=data.get('max_concurrent', DEFAULT_MAX_CONCURRENT),
|
||||
cleanup_delay_sec=data.get('cleanup_delay_sec', DEFAULT_CLEANUP_DELAY_SEC),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobRuntimeConfig:
|
||||
"""Configuration for async job execution."""
|
||||
enabled: bool = False
|
||||
timeout_sec: int = DEFAULT_TIMEOUT_SEC
|
||||
webhook_url: Optional[str] = None # Supports ${ENV_VAR} interpolation
|
||||
idempotency_scope: str = "none" # none|session|global
|
||||
events: List[str] = field(default_factory=lambda: ["completed", "failed"])
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> 'JobRuntimeConfig':
|
||||
"""Create from dictionary with defaults."""
|
||||
if not data:
|
||||
return cls()
|
||||
return cls(
|
||||
enabled=data.get('enabled', False),
|
||||
timeout_sec=data.get('timeout_sec', DEFAULT_TIMEOUT_SEC),
|
||||
webhook_url=data.get('webhook_url'),
|
||||
idempotency_scope=data.get('idempotency_scope', 'none'),
|
||||
events=data.get('events', ['completed', 'failed']),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduleRuntimeConfig:
|
||||
"""Configuration for scheduled execution."""
|
||||
enabled: bool = False
|
||||
interval: str = "hourly" # hourly|daily|*/30m|seconds|cron-like
|
||||
max_retries: int = DEFAULT_MAX_RETRIES
|
||||
run_immediately: bool = False
|
||||
timeout_sec: int = DEFAULT_TIMEOUT_SEC
|
||||
max_cost_usd: float = DEFAULT_MAX_COST_USD
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> 'ScheduleRuntimeConfig':
|
||||
"""Create from dictionary with defaults."""
|
||||
if not data:
|
||||
return cls()
|
||||
return cls(
|
||||
enabled=data.get('enabled', False),
|
||||
interval=data.get('interval', 'hourly'),
|
||||
max_retries=data.get('max_retries', DEFAULT_MAX_RETRIES),
|
||||
run_immediately=data.get('run_immediately', False),
|
||||
timeout_sec=data.get('timeout_sec', DEFAULT_TIMEOUT_SEC),
|
||||
max_cost_usd=data.get('max_cost_usd', DEFAULT_MAX_COST_USD),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeConfig:
|
||||
"""
|
||||
Complete runtime configuration for a recipe.
|
||||
|
||||
Parsed from the optional `runtime` block in TEMPLATE.yaml:
|
||||
|
||||
```yaml
|
||||
runtime:
|
||||
background:
|
||||
enabled: false
|
||||
max_concurrent: 5
|
||||
cleanup_delay_sec: 3600
|
||||
job:
|
||||
enabled: false
|
||||
timeout_sec: 3600
|
||||
webhook_url: "${WEBHOOK_URL}"
|
||||
idempotency_scope: "session"
|
||||
events: ["completed", "failed"]
|
||||
schedule:
|
||||
enabled: false
|
||||
interval: "hourly"
|
||||
max_retries: 3
|
||||
run_immediately: true
|
||||
timeout_sec: 300
|
||||
max_cost_usd: 1.00
|
||||
```
|
||||
"""
|
||||
background: BackgroundRuntimeConfig = field(default_factory=BackgroundRuntimeConfig)
|
||||
job: JobRuntimeConfig = field(default_factory=JobRuntimeConfig)
|
||||
schedule: ScheduleRuntimeConfig = field(default_factory=ScheduleRuntimeConfig)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> 'RuntimeConfig':
|
||||
"""Create from dictionary with defaults."""
|
||||
if not data:
|
||||
return cls()
|
||||
return cls(
|
||||
background=BackgroundRuntimeConfig.from_dict(data.get('background')),
|
||||
job=JobRuntimeConfig.from_dict(data.get('job')),
|
||||
schedule=ScheduleRuntimeConfig.from_dict(data.get('schedule')),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
'background': {
|
||||
'enabled': self.background.enabled,
|
||||
'max_concurrent': self.background.max_concurrent,
|
||||
'cleanup_delay_sec': self.background.cleanup_delay_sec,
|
||||
},
|
||||
'job': {
|
||||
'enabled': self.job.enabled,
|
||||
'timeout_sec': self.job.timeout_sec,
|
||||
'webhook_url': self.job.webhook_url,
|
||||
'idempotency_scope': self.job.idempotency_scope,
|
||||
'events': self.job.events,
|
||||
},
|
||||
'schedule': {
|
||||
'enabled': self.schedule.enabled,
|
||||
'interval': self.schedule.interval,
|
||||
'max_retries': self.schedule.max_retries,
|
||||
'run_immediately': self.schedule.run_immediately,
|
||||
'timeout_sec': self.schedule.timeout_sec,
|
||||
'max_cost_usd': self.schedule.max_cost_usd,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def expand_env_vars(value: Any) -> Any:
|
||||
"""
|
||||
Safely expand environment variables in a value.
|
||||
|
||||
Only supports ${VAR} syntax for safety (no eval, no shell expansion).
|
||||
|
||||
Args:
|
||||
value: Value to expand (string, dict, or list)
|
||||
|
||||
Returns:
|
||||
Value with environment variables expanded
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
# Match ${VAR} pattern only
|
||||
pattern = r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}'
|
||||
|
||||
def replace_env(match):
|
||||
var_name = match.group(1)
|
||||
return os.environ.get(var_name, match.group(0)) # Keep original if not found
|
||||
|
||||
return re.sub(pattern, replace_env, value)
|
||||
|
||||
elif isinstance(value, dict):
|
||||
return {k: expand_env_vars(v) for k, v in value.items()}
|
||||
|
||||
elif isinstance(value, list):
|
||||
return [expand_env_vars(item) for item in value]
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def parse_runtime_config(
|
||||
raw_runtime: Optional[Dict[str, Any]],
|
||||
expand_env: bool = True
|
||||
) -> RuntimeConfig:
|
||||
"""
|
||||
Parse runtime configuration from raw YAML data.
|
||||
|
||||
Args:
|
||||
raw_runtime: Raw runtime dict from TEMPLATE.yaml
|
||||
expand_env: Whether to expand environment variables
|
||||
|
||||
Returns:
|
||||
Parsed RuntimeConfig with defaults applied
|
||||
"""
|
||||
if not raw_runtime:
|
||||
return RuntimeConfig()
|
||||
|
||||
# Expand environment variables if requested
|
||||
if expand_env:
|
||||
raw_runtime = expand_env_vars(raw_runtime)
|
||||
|
||||
return RuntimeConfig.from_dict(raw_runtime)
|
||||
@@ -5,17 +5,21 @@ Provides HTTP endpoints for recipe execution.
|
||||
Optional dependency - requires: pip install praisonai[serve]
|
||||
|
||||
Endpoints:
|
||||
- GET /health - Health check
|
||||
- GET /v1/recipes - List recipes
|
||||
- GET /v1/recipes/{name} - Describe recipe
|
||||
- GET /health - Health check
|
||||
- GET /v1/recipes - List recipes
|
||||
- GET /v1/recipes/{name} - Describe recipe
|
||||
- GET /v1/recipes/{name}/schema - Get recipe schema
|
||||
- POST /v1/recipes/run - Run recipe (sync)
|
||||
- POST /v1/recipes/stream - Run recipe (SSE)
|
||||
- POST /v1/recipes/run - Run recipe (sync)
|
||||
- POST /v1/recipes/stream - Run recipe (SSE)
|
||||
- POST /v1/recipes/validate - Validate recipe
|
||||
- GET /metrics - Prometheus metrics (optional)
|
||||
- GET /openapi.json - OpenAPI specification
|
||||
- POST /admin/reload - Hot reload registry (auth required)
|
||||
|
||||
Auth modes:
|
||||
- none: No authentication (localhost only)
|
||||
- api-key: X-API-Key header required
|
||||
- jwt: Bearer token required (future)
|
||||
- jwt: Bearer token required
|
||||
|
||||
Config file (serve.yaml):
|
||||
```yaml
|
||||
@@ -28,12 +32,19 @@ recipes:
|
||||
- another-recipe
|
||||
preload: true
|
||||
cors_origins: "*"
|
||||
rate_limit: 100 # requests per minute (0 = disabled)
|
||||
max_request_size: 10485760 # 10MB default
|
||||
enable_metrics: false # Enable /metrics endpoint
|
||||
enable_admin: false # Enable /admin/* endpoints
|
||||
trace_exporter: none # none, otlp, jaeger, zipkin
|
||||
```
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
@@ -65,6 +76,238 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
return config
|
||||
|
||||
|
||||
# Default constants
|
||||
DEFAULT_MAX_REQUEST_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
DEFAULT_RATE_LIMIT = 100 # requests per minute
|
||||
DEFAULT_RATE_LIMIT_EXEMPT_PATHS = ["/health", "/metrics"]
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Simple in-memory rate limiter using sliding window."""
|
||||
|
||||
def __init__(self, requests_per_minute: int = DEFAULT_RATE_LIMIT):
|
||||
self.requests_per_minute = requests_per_minute
|
||||
self.window_seconds = 60
|
||||
self._requests: Dict[str, List[float]] = defaultdict(list)
|
||||
|
||||
def check(self, client_id: str) -> Tuple[bool, int]:
|
||||
"""
|
||||
Check if request is allowed.
|
||||
|
||||
Returns:
|
||||
Tuple of (allowed, retry_after_seconds)
|
||||
"""
|
||||
if self.requests_per_minute <= 0:
|
||||
return True, 0
|
||||
|
||||
now = time.time()
|
||||
window_start = now - self.window_seconds
|
||||
|
||||
# Clean old requests
|
||||
self._requests[client_id] = [
|
||||
t for t in self._requests[client_id] if t > window_start
|
||||
]
|
||||
|
||||
if len(self._requests[client_id]) >= self.requests_per_minute:
|
||||
# Calculate retry-after
|
||||
oldest = min(self._requests[client_id])
|
||||
retry_after = int(oldest + self.window_seconds - now) + 1
|
||||
return False, max(1, retry_after)
|
||||
|
||||
self._requests[client_id].append(now)
|
||||
return True, 0
|
||||
|
||||
|
||||
def create_rate_limiter(requests_per_minute: int = DEFAULT_RATE_LIMIT) -> RateLimiter:
|
||||
"""Create a rate limiter instance."""
|
||||
return RateLimiter(requests_per_minute=requests_per_minute)
|
||||
|
||||
|
||||
class MetricsCollector:
|
||||
"""Simple in-memory metrics collector for Prometheus format."""
|
||||
|
||||
def __init__(self):
|
||||
self._requests_total: Dict[str, int] = defaultdict(int)
|
||||
self._request_durations: Dict[str, List[float]] = defaultdict(list)
|
||||
self._errors_total: Dict[str, int] = defaultdict(int)
|
||||
|
||||
def record_request(self, path: str, method: str, status: int, duration: float):
|
||||
"""Record a request."""
|
||||
# Normalize path to avoid label explosion
|
||||
normalized_path = self._normalize_path(path)
|
||||
key = f'{normalized_path}|{method}|{status}'
|
||||
self._requests_total[key] += 1
|
||||
self._request_durations[f'{normalized_path}|{method}'].append(duration)
|
||||
|
||||
if status >= 400:
|
||||
error_type = "client_error" if status < 500 else "server_error"
|
||||
error_key = f'{normalized_path}|{method}|{error_type}'
|
||||
self._errors_total[error_key] += 1
|
||||
|
||||
def _normalize_path(self, path: str) -> str:
|
||||
"""Normalize path to avoid label explosion."""
|
||||
# Replace dynamic segments
|
||||
parts = path.split("/")
|
||||
normalized = []
|
||||
for i, part in enumerate(parts):
|
||||
if i > 0 and parts[i-1] == "recipes" and part not in ["run", "stream", "validate"]:
|
||||
normalized.append("{name}")
|
||||
else:
|
||||
normalized.append(part)
|
||||
return "/".join(normalized)
|
||||
|
||||
def get_prometheus_metrics(self) -> str:
|
||||
"""Get metrics in Prometheus exposition format."""
|
||||
lines = []
|
||||
|
||||
# Requests total
|
||||
lines.append("# HELP praisonai_http_requests_total Total HTTP requests")
|
||||
lines.append("# TYPE praisonai_http_requests_total counter")
|
||||
for key, count in self._requests_total.items():
|
||||
path, method, status = key.split("|")
|
||||
lines.append(f'praisonai_http_requests_total{{path="{path}",method="{method}",status="{status}"}} {count}')
|
||||
|
||||
# Request duration histogram (simplified - just sum and count)
|
||||
lines.append("# HELP praisonai_http_request_duration_seconds HTTP request duration")
|
||||
lines.append("# TYPE praisonai_http_request_duration_seconds histogram")
|
||||
for key, durations in self._request_durations.items():
|
||||
path, method = key.split("|")
|
||||
if durations:
|
||||
total = sum(durations)
|
||||
count = len(durations)
|
||||
lines.append(f'praisonai_http_request_duration_seconds_sum{{path="{path}",method="{method}"}} {total:.6f}')
|
||||
lines.append(f'praisonai_http_request_duration_seconds_count{{path="{path}",method="{method}"}} {count}')
|
||||
# Add buckets
|
||||
buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
|
||||
for bucket in buckets:
|
||||
bucket_count = sum(1 for d in durations if d <= bucket)
|
||||
lines.append(f'praisonai_http_request_duration_seconds_bucket{{path="{path}",method="{method}",le="{bucket}"}} {bucket_count}')
|
||||
lines.append(f'praisonai_http_request_duration_seconds_bucket{{path="{path}",method="{method}",le="+Inf"}} {count}')
|
||||
|
||||
# Errors total
|
||||
lines.append("# HELP praisonai_http_errors_total Total HTTP errors")
|
||||
lines.append("# TYPE praisonai_http_errors_total counter")
|
||||
for key, count in self._errors_total.items():
|
||||
path, method, error_type = key.split("|")
|
||||
lines.append(f'praisonai_http_errors_total{{path="{path}",method="{method}",error_type="{error_type}"}} {count}')
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Global metrics collector (created per app instance)
|
||||
_metrics_collector: Optional[MetricsCollector] = None
|
||||
|
||||
|
||||
def get_openapi_spec(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Generate OpenAPI specification."""
|
||||
spec = {
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "PraisonAI Recipe Runner API",
|
||||
"description": "HTTP API for running PraisonAI recipes",
|
||||
"version": _get_version(),
|
||||
},
|
||||
"servers": [
|
||||
{"url": f"http://{config.get('host', '127.0.0.1')}:{config.get('port', 8765)}"}
|
||||
],
|
||||
"paths": {
|
||||
"/health": {
|
||||
"get": {
|
||||
"summary": "Health check",
|
||||
"responses": {"200": {"description": "Server is healthy"}}
|
||||
}
|
||||
},
|
||||
"/v1/recipes": {
|
||||
"get": {
|
||||
"summary": "List available recipes",
|
||||
"parameters": [
|
||||
{"name": "source", "in": "query", "schema": {"type": "string"}},
|
||||
{"name": "tags", "in": "query", "schema": {"type": "string"}}
|
||||
],
|
||||
"responses": {"200": {"description": "List of recipes"}}
|
||||
}
|
||||
},
|
||||
"/v1/recipes/{name}": {
|
||||
"get": {
|
||||
"summary": "Describe a recipe",
|
||||
"parameters": [{"name": "name", "in": "path", "required": True, "schema": {"type": "string"}}],
|
||||
"responses": {"200": {"description": "Recipe details"}, "404": {"description": "Recipe not found"}}
|
||||
}
|
||||
},
|
||||
"/v1/recipes/{name}/schema": {
|
||||
"get": {
|
||||
"summary": "Get recipe JSON schema",
|
||||
"parameters": [{"name": "name", "in": "path", "required": True, "schema": {"type": "string"}}],
|
||||
"responses": {"200": {"description": "Recipe schema"}}
|
||||
}
|
||||
},
|
||||
"/v1/recipes/run": {
|
||||
"post": {
|
||||
"summary": "Run a recipe",
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["recipe"],
|
||||
"properties": {
|
||||
"recipe": {"type": "string"},
|
||||
"input": {"type": "object"},
|
||||
"config": {"type": "object"},
|
||||
"session_id": {"type": "string"},
|
||||
"options": {"type": "object"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {"200": {"description": "Recipe result"}}
|
||||
}
|
||||
},
|
||||
"/v1/recipes/stream": {
|
||||
"post": {
|
||||
"summary": "Stream recipe execution (SSE)",
|
||||
"responses": {"200": {"description": "SSE event stream"}}
|
||||
}
|
||||
},
|
||||
"/v1/recipes/validate": {
|
||||
"post": {
|
||||
"summary": "Validate a recipe",
|
||||
"responses": {"200": {"description": "Validation result"}}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# Add optional endpoints
|
||||
if config.get("enable_metrics"):
|
||||
spec["paths"]["/metrics"] = {
|
||||
"get": {
|
||||
"summary": "Prometheus metrics",
|
||||
"responses": {"200": {"description": "Metrics in Prometheus format"}}
|
||||
}
|
||||
}
|
||||
|
||||
if config.get("enable_admin"):
|
||||
spec["paths"]["/admin/reload"] = {
|
||||
"post": {
|
||||
"summary": "Hot reload recipe registry",
|
||||
"security": [{"apiKey": []}],
|
||||
"responses": {"200": {"description": "Reload successful"}, "401": {"description": "Unauthorized"}}
|
||||
}
|
||||
}
|
||||
|
||||
spec["paths"]["/openapi.json"] = {
|
||||
"get": {
|
||||
"summary": "OpenAPI specification",
|
||||
"responses": {"200": {"description": "OpenAPI JSON"}}
|
||||
}
|
||||
}
|
||||
|
||||
return spec
|
||||
|
||||
|
||||
def create_auth_middleware(auth_type: str, api_key: Optional[str] = None, jwt_secret: Optional[str] = None):
|
||||
"""Create authentication middleware."""
|
||||
try:
|
||||
@@ -346,6 +589,43 @@ def create_app(config: Optional[Dict[str, Any]] = None) -> Any:
|
||||
status_code = 200 if result.valid else 400
|
||||
return JSONResponse(result.to_dict(), status_code=status_code)
|
||||
|
||||
async def get_metrics(request: Request) -> Response:
|
||||
"""GET /metrics - Prometheus metrics."""
|
||||
global _metrics_collector
|
||||
if _metrics_collector is None:
|
||||
_metrics_collector = MetricsCollector()
|
||||
|
||||
content = _metrics_collector.get_prometheus_metrics()
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/plain; version=0.0.4; charset=utf-8"
|
||||
)
|
||||
|
||||
async def admin_reload(request: Request) -> JSONResponse:
|
||||
"""POST /admin/reload - Hot reload recipe registry."""
|
||||
try:
|
||||
from praisonai import recipe
|
||||
# Clear any cached recipes and reload
|
||||
if hasattr(recipe, '_recipe_cache'):
|
||||
recipe._recipe_cache.clear()
|
||||
if hasattr(recipe, 'reload_registry'):
|
||||
recipe.reload_registry()
|
||||
|
||||
return JSONResponse({
|
||||
"status": "reloaded",
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
})
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{"error": {"code": "reload_failed", "message": str(e)}},
|
||||
status_code=500
|
||||
)
|
||||
|
||||
async def get_openapi(request: Request) -> JSONResponse:
|
||||
"""GET /openapi.json - OpenAPI specification."""
|
||||
spec = get_openapi_spec(config)
|
||||
return JSONResponse(spec)
|
||||
|
||||
# Build routes
|
||||
routes = [
|
||||
Route("/health", health, methods=["GET"]),
|
||||
@@ -355,8 +635,33 @@ def create_app(config: Optional[Dict[str, Any]] = None) -> Any:
|
||||
Route("/v1/recipes/validate", validate_recipe, methods=["POST"]),
|
||||
Route("/v1/recipes/{name}", describe_recipe, methods=["GET"]),
|
||||
Route("/v1/recipes/{name}/schema", get_schema, methods=["GET"]),
|
||||
Route("/openapi.json", get_openapi, methods=["GET"]),
|
||||
]
|
||||
|
||||
# Add optional endpoints
|
||||
if config.get("enable_metrics"):
|
||||
routes.append(Route("/metrics", get_metrics, methods=["GET"]))
|
||||
|
||||
if config.get("enable_admin"):
|
||||
routes.append(Route("/admin/reload", admin_reload, methods=["POST"]))
|
||||
|
||||
# Initialize metrics collector if enabled
|
||||
global _metrics_collector
|
||||
if config.get("enable_metrics"):
|
||||
_metrics_collector = MetricsCollector()
|
||||
|
||||
# Create rate limiter if configured
|
||||
rate_limit = config.get("rate_limit", 0)
|
||||
rate_limiter = None
|
||||
if rate_limit > 0:
|
||||
rate_limiter = create_rate_limiter(rate_limit)
|
||||
|
||||
# Get max request size
|
||||
max_request_size = config.get("max_request_size", DEFAULT_MAX_REQUEST_SIZE)
|
||||
|
||||
# Exempt paths for rate limiting
|
||||
rate_limit_exempt = config.get("rate_limit_exempt_paths", DEFAULT_RATE_LIMIT_EXEMPT_PATHS)
|
||||
|
||||
# Add CORS middleware if configured
|
||||
middleware = []
|
||||
cors_origins = config.get("cors_origins")
|
||||
@@ -389,6 +694,75 @@ def create_app(config: Optional[Dict[str, Any]] = None) -> Any:
|
||||
if auth_middleware:
|
||||
middleware.append(Middleware(auth_middleware))
|
||||
|
||||
# Create rate limit and size limit middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Rate limiting middleware."""
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
if rate_limiter is None:
|
||||
return await call_next(request)
|
||||
|
||||
# Skip exempt paths
|
||||
if request.url.path in rate_limit_exempt:
|
||||
return await call_next(request)
|
||||
|
||||
# Get client identifier (IP or API key)
|
||||
client_id = request.headers.get("X-API-Key") or request.client.host if request.client else "unknown"
|
||||
|
||||
allowed, retry_after = rate_limiter.check(client_id)
|
||||
if not allowed:
|
||||
return JSONResponse(
|
||||
{"error": {"code": "rate_limited", "message": "Too many requests"}},
|
||||
status_code=429,
|
||||
headers={"Retry-After": str(retry_after)}
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Request size limit middleware."""
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length and int(content_length) > max_request_size:
|
||||
return JSONResponse(
|
||||
{"error": {"code": "request_too_large", "message": f"Request body too large. Max: {max_request_size} bytes"}},
|
||||
status_code=413
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
class MetricsMiddleware(BaseHTTPMiddleware):
|
||||
"""Metrics collection middleware."""
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
if _metrics_collector is None:
|
||||
return await call_next(request)
|
||||
|
||||
start_time = time.time()
|
||||
response = await call_next(request)
|
||||
duration = time.time() - start_time
|
||||
|
||||
_metrics_collector.record_request(
|
||||
path=request.url.path,
|
||||
method=request.method,
|
||||
status=response.status_code,
|
||||
duration=duration
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
# Add custom middleware (order matters - first added = outermost)
|
||||
if config.get("enable_metrics"):
|
||||
middleware.append(Middleware(MetricsMiddleware))
|
||||
|
||||
if max_request_size > 0:
|
||||
middleware.append(Middleware(RequestSizeLimitMiddleware))
|
||||
|
||||
if rate_limiter is not None:
|
||||
middleware.append(Middleware(RateLimitMiddleware))
|
||||
|
||||
return Starlette(routes=routes, middleware=middleware)
|
||||
|
||||
|
||||
@@ -397,6 +771,7 @@ def serve(
|
||||
port: int = 8765,
|
||||
reload: bool = False,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
workers: int = 1,
|
||||
):
|
||||
"""
|
||||
Start the recipe runner server.
|
||||
@@ -406,6 +781,7 @@ def serve(
|
||||
port: Server port (default: 8765)
|
||||
reload: Enable hot reload (default: False)
|
||||
config: Optional configuration dict
|
||||
workers: Number of worker processes (default: 1)
|
||||
"""
|
||||
try:
|
||||
import uvicorn
|
||||
@@ -414,8 +790,64 @@ def serve(
|
||||
"Serve dependencies not installed. Run: pip install praisonai[serve]"
|
||||
)
|
||||
|
||||
# Initialize OpenTelemetry tracing if configured
|
||||
trace_exporter = (config or {}).get("trace_exporter", "none")
|
||||
if trace_exporter and trace_exporter != "none":
|
||||
_init_tracing(trace_exporter, config or {})
|
||||
|
||||
app = create_app(config)
|
||||
uvicorn.run(app, host=host, port=port, reload=reload)
|
||||
|
||||
# Workers > 1 requires reload=False
|
||||
if workers > 1 and reload:
|
||||
import warnings
|
||||
warnings.warn("Cannot use reload with multiple workers. Disabling reload.")
|
||||
reload = False
|
||||
|
||||
uvicorn.run(app, host=host, port=port, reload=reload, workers=workers if workers > 1 else None)
|
||||
|
||||
|
||||
def _init_tracing(exporter: str, config: Dict[str, Any]):
|
||||
"""Initialize OpenTelemetry tracing (lazy import)."""
|
||||
try:
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
|
||||
resource = Resource.create({
|
||||
"service.name": config.get("service_name", "praisonai-recipe"),
|
||||
"service.version": _get_version(),
|
||||
})
|
||||
|
||||
provider = TracerProvider(resource=resource)
|
||||
|
||||
if exporter == "otlp":
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||
endpoint = config.get("otlp_endpoint", "http://localhost:4317")
|
||||
span_exporter = OTLPSpanExporter(endpoint=endpoint)
|
||||
elif exporter == "jaeger":
|
||||
from opentelemetry.exporter.jaeger.thrift import JaegerExporter
|
||||
span_exporter = JaegerExporter(
|
||||
agent_host_name=config.get("jaeger_host", "localhost"),
|
||||
agent_port=config.get("jaeger_port", 6831),
|
||||
)
|
||||
elif exporter == "zipkin":
|
||||
from opentelemetry.exporter.zipkin.json import ZipkinExporter
|
||||
span_exporter = ZipkinExporter(
|
||||
endpoint=config.get("zipkin_endpoint", "http://localhost:9411/api/v2/spans")
|
||||
)
|
||||
else:
|
||||
return # Unknown exporter
|
||||
|
||||
provider.add_span_processor(BatchSpanProcessor(span_exporter))
|
||||
trace.set_tracer_provider(provider)
|
||||
|
||||
except ImportError:
|
||||
import warnings
|
||||
warnings.warn(
|
||||
f"OpenTelemetry exporter '{exporter}' requested but dependencies not installed. "
|
||||
"Run: pip install opentelemetry-sdk opentelemetry-exporter-otlp"
|
||||
)
|
||||
|
||||
|
||||
def _get_version() -> str:
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
"""
|
||||
Recipe Registry HTTP Server
|
||||
|
||||
Provides a local HTTP server for recipe registry operations.
|
||||
Supports:
|
||||
- GET /healthz - Health check
|
||||
- GET /v1/recipes - List recipes (pagination)
|
||||
- GET /v1/recipes/{name} - Get recipe info (all versions)
|
||||
- GET /v1/recipes/{name}/{version} - Get specific version info
|
||||
- GET /v1/recipes/{name}/{version}/download - Download bundle
|
||||
- POST /v1/recipes/{name}/{version} - Publish bundle (multipart)
|
||||
- DELETE /v1/recipes/{name}/{version} - Delete version
|
||||
- GET /v1/search?q=... - Search recipes
|
||||
|
||||
Usage:
|
||||
from praisonai.recipe.server import create_app, run_server
|
||||
|
||||
# Run server
|
||||
run_server(host="127.0.0.1", port=7777)
|
||||
|
||||
# Or get ASGI app for custom deployment
|
||||
app = create_app(registry_path="~/.praison/registry")
|
||||
"""
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from urllib.parse import parse_qs, unquote
|
||||
|
||||
from .registry import (
|
||||
LocalRegistry,
|
||||
RegistryError,
|
||||
RecipeNotFoundError,
|
||||
RecipeExistsError,
|
||||
RegistryAuthError,
|
||||
DEFAULT_REGISTRY_PATH,
|
||||
DEFAULT_REGISTRY_PORT,
|
||||
_calculate_checksum,
|
||||
_get_timestamp,
|
||||
)
|
||||
|
||||
|
||||
class RegistryServer:
|
||||
"""
|
||||
Simple HTTP server for recipe registry.
|
||||
|
||||
Uses only stdlib for minimal dependencies.
|
||||
For production, consider using with uvicorn/gunicorn.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registry_path: Optional[Path] = None,
|
||||
token: Optional[str] = None,
|
||||
read_only: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize registry server.
|
||||
|
||||
Args:
|
||||
registry_path: Path to registry directory
|
||||
token: Required token for write operations (optional)
|
||||
read_only: If True, disable all write operations
|
||||
"""
|
||||
self.registry = LocalRegistry(registry_path)
|
||||
self.token = token or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
|
||||
self.read_only = read_only
|
||||
self._routes: List[Tuple[str, str, Callable]] = []
|
||||
self._setup_routes()
|
||||
|
||||
def _setup_routes(self):
|
||||
"""Setup URL routes."""
|
||||
self._routes = [
|
||||
("GET", r"^/healthz$", self._handle_health),
|
||||
("GET", r"^/v1/recipes$", self._handle_list_recipes),
|
||||
("GET", r"^/v1/recipes/([^/]+)$", self._handle_get_recipe),
|
||||
("GET", r"^/v1/recipes/([^/]+)/([^/]+)$", self._handle_get_version),
|
||||
("GET", r"^/v1/recipes/([^/]+)/([^/]+)/download$", self._handle_download),
|
||||
("POST", r"^/v1/recipes/([^/]+)/([^/]+)$", self._handle_publish),
|
||||
("DELETE", r"^/v1/recipes/([^/]+)/([^/]+)$", self._handle_delete_version),
|
||||
("DELETE", r"^/v1/recipes/([^/]+)$", self._handle_delete_recipe),
|
||||
("GET", r"^/v1/search$", self._handle_search),
|
||||
]
|
||||
|
||||
def _check_auth(self, headers: Dict[str, str]) -> bool:
|
||||
"""Check if request is authorized for write operations."""
|
||||
if not self.token:
|
||||
return True
|
||||
|
||||
auth = headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
return auth[7:] == self.token
|
||||
return False
|
||||
|
||||
def _json_response(
|
||||
self,
|
||||
data: Any,
|
||||
status: int = 200,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Create JSON response."""
|
||||
resp_headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Registry-Version": "1.0",
|
||||
}
|
||||
if headers:
|
||||
resp_headers.update(headers)
|
||||
|
||||
body = json.dumps(data, indent=2).encode("utf-8")
|
||||
return status, resp_headers, body
|
||||
|
||||
def _error_response(
|
||||
self,
|
||||
message: str,
|
||||
status: int = 400,
|
||||
code: Optional[str] = None,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Create error response."""
|
||||
return self._json_response({
|
||||
"ok": False,
|
||||
"error": message,
|
||||
"code": code or "error",
|
||||
}, status=status)
|
||||
|
||||
def _file_response(
|
||||
self,
|
||||
file_path: Path,
|
||||
filename: str,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Create file download response."""
|
||||
with open(file_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
checksum = hashlib.sha256(content).hexdigest()
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/gzip",
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Length": str(len(content)),
|
||||
"ETag": f'"{checksum[:16]}"',
|
||||
"X-Checksum-SHA256": checksum,
|
||||
}
|
||||
|
||||
return 200, headers, content
|
||||
|
||||
def _parse_multipart(
|
||||
self,
|
||||
body: bytes,
|
||||
content_type: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Parse multipart form data."""
|
||||
# Extract boundary
|
||||
boundary = None
|
||||
for part in content_type.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("boundary="):
|
||||
boundary = part[9:].strip('"')
|
||||
break
|
||||
|
||||
if not boundary:
|
||||
raise ValueError("Missing boundary in multipart data")
|
||||
|
||||
result = {"fields": {}, "files": {}}
|
||||
boundary_bytes = f"--{boundary}".encode()
|
||||
|
||||
parts = body.split(boundary_bytes)
|
||||
for part in parts[1:]: # Skip preamble
|
||||
if part.startswith(b"--"):
|
||||
break # End marker
|
||||
|
||||
# Split headers and content
|
||||
if b"\r\n\r\n" in part:
|
||||
header_section, content = part.split(b"\r\n\r\n", 1)
|
||||
else:
|
||||
continue
|
||||
|
||||
# Parse headers
|
||||
headers = {}
|
||||
for line in header_section.decode("utf-8", errors="ignore").split("\r\n"):
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
headers[key.strip().lower()] = value.strip()
|
||||
|
||||
# Get field name and filename
|
||||
disposition = headers.get("content-disposition", "")
|
||||
name = None
|
||||
filename = None
|
||||
|
||||
for item in disposition.split(";"):
|
||||
item = item.strip()
|
||||
if item.startswith("name="):
|
||||
name = item[5:].strip('"')
|
||||
elif item.startswith("filename="):
|
||||
filename = item[9:].strip('"')
|
||||
|
||||
if not name:
|
||||
continue
|
||||
|
||||
# Remove trailing \r\n
|
||||
content = content.rstrip(b"\r\n")
|
||||
|
||||
if filename:
|
||||
result["files"][name] = {
|
||||
"filename": filename,
|
||||
"content": content,
|
||||
"content_type": headers.get("content-type", "application/octet-stream"),
|
||||
}
|
||||
else:
|
||||
result["fields"][name] = content.decode("utf-8", errors="ignore")
|
||||
|
||||
return result
|
||||
|
||||
def handle_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
headers: Dict[str, str],
|
||||
body: bytes,
|
||||
query_string: str = "",
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""
|
||||
Handle HTTP request.
|
||||
|
||||
Args:
|
||||
method: HTTP method
|
||||
path: URL path
|
||||
headers: Request headers
|
||||
body: Request body
|
||||
query_string: Query string
|
||||
|
||||
Returns:
|
||||
Tuple of (status_code, headers, body)
|
||||
"""
|
||||
# Parse query string
|
||||
query = parse_qs(query_string)
|
||||
|
||||
# Find matching route
|
||||
for route_method, pattern, handler in self._routes:
|
||||
if method != route_method:
|
||||
continue
|
||||
|
||||
match = re.match(pattern, path)
|
||||
if match:
|
||||
try:
|
||||
return handler(
|
||||
headers=headers,
|
||||
body=body,
|
||||
query=query,
|
||||
path_params=match.groups(),
|
||||
)
|
||||
except RecipeNotFoundError as e:
|
||||
return self._error_response(str(e), status=404, code="not_found")
|
||||
except RecipeExistsError as e:
|
||||
return self._error_response(str(e), status=409, code="conflict")
|
||||
except RegistryAuthError as e:
|
||||
return self._error_response(str(e), status=401, code="auth_error")
|
||||
except RegistryError as e:
|
||||
return self._error_response(str(e), status=400, code="registry_error")
|
||||
except Exception as e:
|
||||
return self._error_response(f"Internal error: {e}", status=500, code="internal_error")
|
||||
|
||||
return self._error_response("Not found", status=404, code="not_found")
|
||||
|
||||
def _handle_health(self, **kwargs) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle GET /healthz."""
|
||||
return self._json_response({
|
||||
"ok": True,
|
||||
"status": "healthy",
|
||||
"timestamp": _get_timestamp(),
|
||||
"read_only": self.read_only,
|
||||
"auth_required": bool(self.token),
|
||||
})
|
||||
|
||||
def _handle_list_recipes(
|
||||
self,
|
||||
query: Dict[str, List[str]],
|
||||
**kwargs,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle GET /v1/recipes."""
|
||||
page = int(query.get("page", ["1"])[0])
|
||||
per_page = min(int(query.get("per_page", ["50"])[0]), 100)
|
||||
tags = query.get("tags", [""])[0].split(",") if query.get("tags") else None
|
||||
|
||||
if tags and tags[0] == "":
|
||||
tags = None
|
||||
|
||||
recipes = self.registry.list_recipes(tags=tags)
|
||||
|
||||
# Paginate
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
paginated = recipes[start:end]
|
||||
|
||||
return self._json_response({
|
||||
"ok": True,
|
||||
"recipes": paginated,
|
||||
"total": len(recipes),
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"has_more": end < len(recipes),
|
||||
})
|
||||
|
||||
def _handle_get_recipe(
|
||||
self,
|
||||
path_params: Tuple[str, ...],
|
||||
**kwargs,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle GET /v1/recipes/{name}."""
|
||||
name = unquote(path_params[0])
|
||||
|
||||
versions = self.registry.get_versions(name)
|
||||
info = self.registry.get_info(name)
|
||||
|
||||
return self._json_response({
|
||||
"ok": True,
|
||||
"name": name,
|
||||
"versions": versions,
|
||||
"latest": info.get("version"),
|
||||
"description": info.get("description", ""),
|
||||
"tags": info.get("tags", []),
|
||||
})
|
||||
|
||||
def _handle_get_version(
|
||||
self,
|
||||
path_params: Tuple[str, ...],
|
||||
**kwargs,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle GET /v1/recipes/{name}/{version}."""
|
||||
name = unquote(path_params[0])
|
||||
version = unquote(path_params[1])
|
||||
|
||||
info = self.registry.get_info(name, version)
|
||||
|
||||
return self._json_response({
|
||||
"ok": True,
|
||||
**info,
|
||||
})
|
||||
|
||||
def _handle_download(
|
||||
self,
|
||||
path_params: Tuple[str, ...],
|
||||
headers: Dict[str, str],
|
||||
**kwargs,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle GET /v1/recipes/{name}/{version}/download."""
|
||||
name = unquote(path_params[0])
|
||||
version = unquote(path_params[1])
|
||||
|
||||
# Get bundle path
|
||||
bundle_name = f"{name}-{version}.praison"
|
||||
bundle_path = self.registry.recipes_path / name / version / bundle_name
|
||||
|
||||
if not bundle_path.exists():
|
||||
raise RecipeNotFoundError(f"Bundle not found: {name}@{version}")
|
||||
|
||||
# Check ETag for caching
|
||||
checksum = _calculate_checksum(bundle_path)
|
||||
etag = f'"{checksum[:16]}"'
|
||||
|
||||
if_none_match = headers.get("If-None-Match", "")
|
||||
if if_none_match == etag:
|
||||
return 304, {"ETag": etag}, b""
|
||||
|
||||
return self._file_response(bundle_path, bundle_name)
|
||||
|
||||
def _handle_publish(
|
||||
self,
|
||||
path_params: Tuple[str, ...],
|
||||
headers: Dict[str, str],
|
||||
body: bytes,
|
||||
**kwargs,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle POST /v1/recipes/{name}/{version}."""
|
||||
if self.read_only:
|
||||
return self._error_response("Registry is read-only", status=403, code="read_only")
|
||||
|
||||
if not self._check_auth(headers):
|
||||
return self._error_response("Authentication required", status=401, code="auth_required")
|
||||
|
||||
name = unquote(path_params[0])
|
||||
version = unquote(path_params[1])
|
||||
|
||||
# Parse multipart data
|
||||
content_type = headers.get("Content-Type", "")
|
||||
if not content_type.startswith("multipart/form-data"):
|
||||
return self._error_response("Expected multipart/form-data", status=400)
|
||||
|
||||
try:
|
||||
data = self._parse_multipart(body, content_type)
|
||||
except ValueError as e:
|
||||
return self._error_response(f"Invalid multipart data: {e}", status=400)
|
||||
|
||||
if "bundle" not in data["files"]:
|
||||
return self._error_response("Missing bundle file", status=400)
|
||||
|
||||
force = data["fields"].get("force", "false").lower() == "true"
|
||||
bundle_content = data["files"]["bundle"]["content"]
|
||||
|
||||
# Save to temp file and publish
|
||||
with tempfile.NamedTemporaryFile(suffix=".praison", delete=False) as tmp:
|
||||
tmp.write(bundle_content)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
result = self.registry.publish(tmp_path, force=force)
|
||||
|
||||
# Verify name/version match
|
||||
if result["name"] != name or result["version"] != version:
|
||||
# Rollback
|
||||
self.registry.delete(result["name"], result["version"])
|
||||
return self._error_response(
|
||||
f"Bundle name/version ({result['name']}@{result['version']}) "
|
||||
f"doesn't match URL ({name}@{version})",
|
||||
status=400,
|
||||
)
|
||||
|
||||
return self._json_response({
|
||||
"ok": True,
|
||||
**result,
|
||||
}, status=201)
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
def _handle_delete_version(
|
||||
self,
|
||||
path_params: Tuple[str, ...],
|
||||
headers: Dict[str, str],
|
||||
**kwargs,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle DELETE /v1/recipes/{name}/{version}."""
|
||||
if self.read_only:
|
||||
return self._error_response("Registry is read-only", status=403, code="read_only")
|
||||
|
||||
if not self._check_auth(headers):
|
||||
return self._error_response("Authentication required", status=401, code="auth_required")
|
||||
|
||||
name = unquote(path_params[0])
|
||||
version = unquote(path_params[1])
|
||||
|
||||
self.registry.delete(name, version)
|
||||
|
||||
return self._json_response({
|
||||
"ok": True,
|
||||
"deleted": f"{name}@{version}",
|
||||
})
|
||||
|
||||
def _handle_delete_recipe(
|
||||
self,
|
||||
path_params: Tuple[str, ...],
|
||||
headers: Dict[str, str],
|
||||
**kwargs,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle DELETE /v1/recipes/{name}."""
|
||||
if self.read_only:
|
||||
return self._error_response("Registry is read-only", status=403, code="read_only")
|
||||
|
||||
if not self._check_auth(headers):
|
||||
return self._error_response("Authentication required", status=401, code="auth_required")
|
||||
|
||||
name = unquote(path_params[0])
|
||||
|
||||
self.registry.delete(name)
|
||||
|
||||
return self._json_response({
|
||||
"ok": True,
|
||||
"deleted": name,
|
||||
})
|
||||
|
||||
def _handle_search(
|
||||
self,
|
||||
query: Dict[str, List[str]],
|
||||
**kwargs,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle GET /v1/search."""
|
||||
q = query.get("q", [""])[0]
|
||||
tags = query.get("tags", [""])[0].split(",") if query.get("tags") else None
|
||||
|
||||
if tags and tags[0] == "":
|
||||
tags = None
|
||||
|
||||
if not q:
|
||||
return self._error_response("Query parameter 'q' required", status=400)
|
||||
|
||||
results = self.registry.search(q, tags=tags)
|
||||
|
||||
return self._json_response({
|
||||
"ok": True,
|
||||
"query": q,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
})
|
||||
|
||||
|
||||
def create_wsgi_app(
|
||||
registry_path: Optional[Path] = None,
|
||||
token: Optional[str] = None,
|
||||
read_only: bool = False,
|
||||
) -> Callable:
|
||||
"""
|
||||
Create WSGI application for the registry server.
|
||||
|
||||
Args:
|
||||
registry_path: Path to registry directory
|
||||
token: Required token for write operations
|
||||
read_only: If True, disable write operations
|
||||
|
||||
Returns:
|
||||
WSGI application callable
|
||||
"""
|
||||
server = RegistryServer(
|
||||
registry_path=registry_path,
|
||||
token=token,
|
||||
read_only=read_only,
|
||||
)
|
||||
|
||||
def app(environ, start_response):
|
||||
method = environ.get("REQUEST_METHOD", "GET")
|
||||
path = environ.get("PATH_INFO", "/")
|
||||
query_string = environ.get("QUERY_STRING", "")
|
||||
|
||||
# Read headers
|
||||
headers = {}
|
||||
for key, value in environ.items():
|
||||
if key.startswith("HTTP_"):
|
||||
header_name = key[5:].replace("_", "-").title()
|
||||
headers[header_name] = value
|
||||
headers["Content-Type"] = environ.get("CONTENT_TYPE", "")
|
||||
|
||||
# Read body
|
||||
try:
|
||||
content_length = int(environ.get("CONTENT_LENGTH", 0))
|
||||
except (ValueError, TypeError):
|
||||
content_length = 0
|
||||
|
||||
body = environ["wsgi.input"].read(content_length) if content_length > 0 else b""
|
||||
|
||||
# Handle request
|
||||
status_code, resp_headers, resp_body = server.handle_request(
|
||||
method=method,
|
||||
path=path,
|
||||
headers=headers,
|
||||
body=body,
|
||||
query_string=query_string,
|
||||
)
|
||||
|
||||
# Send response
|
||||
status = f"{status_code} {'OK' if status_code < 400 else 'Error'}"
|
||||
response_headers = [(k, v) for k, v in resp_headers.items()]
|
||||
start_response(status, response_headers)
|
||||
|
||||
return [resp_body]
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def run_server(
|
||||
host: str = "127.0.0.1",
|
||||
port: int = DEFAULT_REGISTRY_PORT,
|
||||
registry_path: Optional[Path] = None,
|
||||
token: Optional[str] = None,
|
||||
read_only: bool = False,
|
||||
):
|
||||
"""
|
||||
Run the registry server using stdlib wsgiref.
|
||||
|
||||
Args:
|
||||
host: Host to bind to
|
||||
port: Port to bind to
|
||||
registry_path: Path to registry directory
|
||||
token: Required token for write operations
|
||||
read_only: If True, disable write operations
|
||||
"""
|
||||
from wsgiref.simple_server import make_server, WSGIRequestHandler
|
||||
|
||||
# Custom handler to suppress logs unless verbose
|
||||
class QuietHandler(WSGIRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
pass # Suppress default logging
|
||||
|
||||
app = create_wsgi_app(
|
||||
registry_path=registry_path,
|
||||
token=token,
|
||||
read_only=read_only,
|
||||
)
|
||||
|
||||
server = make_server(host, port, app, handler_class=QuietHandler)
|
||||
|
||||
print(f"Recipe Registry Server running on http://{host}:{port}")
|
||||
print(f"Registry path: {registry_path or DEFAULT_REGISTRY_PATH}")
|
||||
if token:
|
||||
print("Authentication: enabled (token required for writes)")
|
||||
if read_only:
|
||||
print("Mode: read-only")
|
||||
print("\nEndpoints:")
|
||||
print(" GET /healthz - Health check")
|
||||
print(" GET /v1/recipes - List recipes")
|
||||
print(" GET /v1/recipes/{name} - Get recipe info")
|
||||
print(" GET /v1/recipes/{name}/{version} - Get version info")
|
||||
print(" GET /v1/recipes/{name}/{version}/download - Download bundle")
|
||||
print(" POST /v1/recipes/{name}/{version} - Publish bundle")
|
||||
print(" DELETE /v1/recipes/{name}/{version} - Delete version")
|
||||
print(" GET /v1/search?q=... - Search recipes")
|
||||
print("\nPress Ctrl+C to stop")
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down...")
|
||||
server.shutdown()
|
||||
@@ -436,6 +436,103 @@ class AgentScheduler:
|
||||
return self.start(interval, max_retries, run_immediately)
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_recipe(
|
||||
cls,
|
||||
recipe_name: str,
|
||||
*,
|
||||
input_data: Any = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
interval_override: Optional[str] = None,
|
||||
max_retries_override: Optional[int] = None,
|
||||
timeout_override: Optional[int] = None,
|
||||
max_cost_override: Optional[float] = None,
|
||||
on_success: Optional[Callable] = None,
|
||||
on_failure: Optional[Callable] = None
|
||||
) -> 'AgentScheduler':
|
||||
"""
|
||||
Create AgentScheduler from a recipe name.
|
||||
|
||||
Args:
|
||||
recipe_name: Name of the recipe to schedule
|
||||
input_data: Input data for the recipe
|
||||
config: Configuration overrides for the recipe
|
||||
interval_override: Override schedule interval from recipe runtime config
|
||||
max_retries_override: Override max_retries from recipe runtime config
|
||||
timeout_override: Override timeout from recipe runtime config
|
||||
max_cost_override: Override max_cost from recipe runtime config
|
||||
on_success: Callback function on successful execution
|
||||
on_failure: Callback function on failed execution
|
||||
|
||||
Returns:
|
||||
Configured AgentScheduler instance
|
||||
|
||||
Example:
|
||||
scheduler = AgentScheduler.from_recipe("news-monitor")
|
||||
scheduler.start(schedule_expr="hourly")
|
||||
"""
|
||||
from praisonai.recipe.bridge import resolve, execute_resolved_recipe, get_recipe_task_description
|
||||
|
||||
# Resolve the recipe
|
||||
resolved = resolve(
|
||||
recipe_name,
|
||||
input_data=input_data,
|
||||
config=config or {},
|
||||
options={'timeout_sec': timeout_override or 300},
|
||||
)
|
||||
|
||||
# Get runtime config defaults from recipe
|
||||
interval = interval_override or "hourly"
|
||||
max_retries = max_retries_override if max_retries_override is not None else 3
|
||||
timeout = timeout_override or 300
|
||||
max_cost = max_cost_override if max_cost_override is not None else 1.00
|
||||
|
||||
runtime = resolved.runtime_config
|
||||
if runtime and hasattr(runtime, 'schedule'):
|
||||
sched_config = runtime.schedule
|
||||
interval = interval_override or sched_config.interval
|
||||
max_retries = max_retries_override if max_retries_override is not None else sched_config.max_retries
|
||||
timeout = timeout_override or sched_config.timeout_sec
|
||||
max_cost = max_cost_override if max_cost_override is not None else sched_config.max_cost_usd
|
||||
|
||||
# Create a recipe executor agent wrapper
|
||||
class RecipeExecutorAgent:
|
||||
"""Wrapper that makes a recipe look like an agent for the scheduler."""
|
||||
def __init__(self, resolved_recipe):
|
||||
self.resolved = resolved_recipe
|
||||
self.name = f"RecipeAgent:{resolved_recipe.name}"
|
||||
|
||||
def start(self, task: str) -> Any:
|
||||
return execute_resolved_recipe(self.resolved)
|
||||
|
||||
# Create the agent wrapper
|
||||
agent = RecipeExecutorAgent(resolved)
|
||||
task = get_recipe_task_description(resolved)
|
||||
|
||||
# Create scheduler instance
|
||||
scheduler = cls(
|
||||
agent=agent,
|
||||
task=task,
|
||||
timeout=timeout,
|
||||
max_cost=max_cost,
|
||||
on_success=on_success,
|
||||
on_failure=on_failure,
|
||||
)
|
||||
|
||||
# Store recipe metadata and schedule config
|
||||
scheduler._recipe_name = recipe_name
|
||||
scheduler._recipe_resolved = resolved
|
||||
scheduler._yaml_schedule_config = {
|
||||
'interval': interval,
|
||||
'max_retries': max_retries,
|
||||
'run_immediately': False,
|
||||
'timeout': timeout,
|
||||
'max_cost': max_cost,
|
||||
}
|
||||
|
||||
return scheduler
|
||||
|
||||
|
||||
def create_agent_scheduler(
|
||||
agent,
|
||||
task: str,
|
||||
|
||||
@@ -69,8 +69,9 @@ class DaemonManager:
|
||||
def start_scheduler_daemon(
|
||||
self,
|
||||
name: str,
|
||||
task: str,
|
||||
interval: str,
|
||||
task: Optional[str] = None,
|
||||
recipe_name: Optional[str] = None,
|
||||
interval: str = "hourly",
|
||||
max_cost: Optional[float] = None,
|
||||
timeout: Optional[int] = None,
|
||||
max_retries: int = 3
|
||||
@@ -80,7 +81,8 @@ class DaemonManager:
|
||||
|
||||
Args:
|
||||
name: Scheduler name
|
||||
task: Task to schedule
|
||||
task: Task to schedule (mutually exclusive with recipe_name)
|
||||
recipe_name: Recipe name to schedule (mutually exclusive with task)
|
||||
interval: Schedule interval
|
||||
max_cost: Maximum cost budget
|
||||
timeout: Timeout per execution
|
||||
@@ -95,18 +97,26 @@ class DaemonManager:
|
||||
"-m",
|
||||
"praisonai.cli.main",
|
||||
"schedule",
|
||||
task,
|
||||
"--interval", interval,
|
||||
"--max-retries", str(max_retries)
|
||||
]
|
||||
|
||||
# Add task or recipe
|
||||
if recipe_name:
|
||||
command.append(recipe_name) # Recipe name will be auto-detected
|
||||
elif task:
|
||||
command.append(task)
|
||||
else:
|
||||
raise ValueError("Either task or recipe_name must be provided")
|
||||
|
||||
command.extend(["--interval", interval, "--max-retries", str(max_retries)])
|
||||
|
||||
if timeout:
|
||||
command.extend(["--timeout", str(timeout)])
|
||||
|
||||
if max_cost:
|
||||
command.extend(["--max-cost", str(max_cost)])
|
||||
|
||||
return self.start_daemon(name, task, interval, command)
|
||||
display_task = recipe_name or task
|
||||
return self.start_daemon(name, display_task, interval, command)
|
||||
|
||||
def stop_daemon(self, pid: int, timeout: int = 10) -> bool:
|
||||
"""
|
||||
|
||||
@@ -47,6 +47,9 @@ class TemplateConfig:
|
||||
# CLI integration
|
||||
cli: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Runtime configuration (background/job/schedule)
|
||||
runtime: Optional[Any] = None # RuntimeConfig, lazy loaded
|
||||
|
||||
# Raw config dict
|
||||
raw: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@@ -162,6 +165,12 @@ class TemplateLoader:
|
||||
# Handle agents - can be inline list or file reference string
|
||||
agents = raw.get("agents", "agents.yaml")
|
||||
|
||||
# Parse runtime configuration (lazy import to avoid circular deps)
|
||||
runtime = None
|
||||
if "runtime" in raw:
|
||||
from praisonai.recipe.runtime import parse_runtime_config
|
||||
runtime = parse_runtime_config(raw.get("runtime"), expand_env=True)
|
||||
|
||||
return TemplateConfig(
|
||||
name=raw.get("name", template_dir.name),
|
||||
description=raw.get("description", ""),
|
||||
@@ -176,6 +185,7 @@ class TemplateLoader:
|
||||
defaults=raw.get("defaults", {}),
|
||||
skills=raw.get("skills", []),
|
||||
cli=raw.get("cli", {}),
|
||||
runtime=runtime,
|
||||
raw=raw,
|
||||
path=template_dir
|
||||
)
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.7.3"
|
||||
__version__ = "2.7.7"
|
||||
@@ -36,7 +36,8 @@ gradio = ["gradio>=4.26.0"]
|
||||
api = [
|
||||
"flask>=3.0.0",
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn>=0.34.0"
|
||||
"uvicorn>=0.34.0",
|
||||
"sse-starlette>=1.6.0"
|
||||
]
|
||||
agentops = ["agentops>=0.3.12"]
|
||||
google = ["langchain-google-genai>=2.1.0"]
|
||||
|
||||
@@ -2,9 +2,40 @@ import pytest
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import warnings
|
||||
import gc
|
||||
from unittest.mock import Mock, patch
|
||||
from typing import Dict, Any, List
|
||||
|
||||
# Register pytest-asyncio plugin
|
||||
pytest_plugins = ('pytest_asyncio',)
|
||||
|
||||
# Suppress aiohttp unclosed session warnings during tests
|
||||
warnings.filterwarnings("ignore", message="Unclosed client session")
|
||||
warnings.filterwarnings("ignore", message="Unclosed connector")
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy():
|
||||
"""Use default event loop policy."""
|
||||
return asyncio.DefaultEventLoopPolicy()
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_async_resources():
|
||||
"""Clean up async resources after each test to prevent unclosed session warnings."""
|
||||
yield
|
||||
# Force garbage collection to clean up any lingering async resources
|
||||
gc.collect()
|
||||
# Give the event loop a chance to clean up
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
pass # Can't run cleanup if loop is running
|
||||
elif not loop.is_closed():
|
||||
# Run any pending callbacks
|
||||
loop.run_until_complete(asyncio.sleep(0))
|
||||
except RuntimeError:
|
||||
pass # No event loop, nothing to clean up
|
||||
|
||||
# Add the source path to sys.path for imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src', 'praisonai-agents'))
|
||||
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
Integration tests for recipe registry HTTP server.
|
||||
|
||||
Tests the full HTTP server with real requests.
|
||||
"""
|
||||
|
||||
import json
|
||||
import tarfile
|
||||
import pytest
|
||||
|
||||
from praisonai.recipe.server import RegistryServer, create_wsgi_app
|
||||
|
||||
|
||||
class TestRegistryServer:
|
||||
"""Test RegistryServer class."""
|
||||
|
||||
@pytest.fixture
|
||||
def registry_path(self, tmp_path):
|
||||
"""Create temporary registry path."""
|
||||
return tmp_path / "registry"
|
||||
|
||||
@pytest.fixture
|
||||
def server(self, registry_path):
|
||||
"""Create a RegistryServer instance."""
|
||||
return RegistryServer(registry_path=registry_path)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_bundle(self, tmp_path):
|
||||
"""Create a sample .praison bundle."""
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
|
||||
manifest = {
|
||||
"name": "test-recipe",
|
||||
"version": "1.0.0",
|
||||
"description": "A test recipe",
|
||||
"tags": ["test", "sample"],
|
||||
"author": "test",
|
||||
"files": ["recipe.yaml"],
|
||||
}
|
||||
|
||||
(bundle_dir / "manifest.json").write_text(json.dumps(manifest))
|
||||
(bundle_dir / "recipe.yaml").write_text("name: test")
|
||||
|
||||
bundle_path = tmp_path / "test-recipe-1.0.0.praison"
|
||||
with tarfile.open(bundle_path, "w:gz") as tar:
|
||||
tar.add(bundle_dir / "manifest.json", arcname="manifest.json")
|
||||
tar.add(bundle_dir / "recipe.yaml", arcname="recipe.yaml")
|
||||
|
||||
return bundle_path
|
||||
|
||||
def test_health_endpoint(self, server):
|
||||
"""Test GET /healthz."""
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/healthz",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
data = json.loads(body)
|
||||
assert data["ok"] is True
|
||||
assert data["status"] == "healthy"
|
||||
|
||||
def test_list_recipes_empty(self, server):
|
||||
"""Test GET /v1/recipes with empty registry."""
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/recipes",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
data = json.loads(body)
|
||||
assert data["ok"] is True
|
||||
assert data["recipes"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_recipes_with_data(self, server, sample_bundle):
|
||||
"""Test GET /v1/recipes with recipes."""
|
||||
# First publish a recipe
|
||||
server.registry.publish(sample_bundle)
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/recipes",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
data = json.loads(body)
|
||||
assert data["total"] == 1
|
||||
assert data["recipes"][0]["name"] == "test-recipe"
|
||||
|
||||
def test_get_recipe_info(self, server, sample_bundle):
|
||||
"""Test GET /v1/recipes/{name}."""
|
||||
server.registry.publish(sample_bundle)
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/recipes/test-recipe",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
data = json.loads(body)
|
||||
assert data["name"] == "test-recipe"
|
||||
assert "1.0.0" in data["versions"]
|
||||
|
||||
def test_get_recipe_not_found(self, server):
|
||||
"""Test GET /v1/recipes/{name} for non-existent recipe."""
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/recipes/nonexistent",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 404
|
||||
data = json.loads(body)
|
||||
assert data["ok"] is False
|
||||
|
||||
def test_get_version_info(self, server, sample_bundle):
|
||||
"""Test GET /v1/recipes/{name}/{version}."""
|
||||
server.registry.publish(sample_bundle)
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/recipes/test-recipe/1.0.0",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
data = json.loads(body)
|
||||
assert data["name"] == "test-recipe"
|
||||
assert data["version"] == "1.0.0"
|
||||
|
||||
def test_download_bundle(self, server, sample_bundle):
|
||||
"""Test GET /v1/recipes/{name}/{version}/download."""
|
||||
server.registry.publish(sample_bundle)
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/recipes/test-recipe/1.0.0/download",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
assert headers["Content-Type"] == "application/gzip"
|
||||
assert "X-Checksum-SHA256" in headers
|
||||
assert len(body) > 0
|
||||
|
||||
def test_download_with_etag(self, server, sample_bundle):
|
||||
"""Test ETag caching for downloads."""
|
||||
server.registry.publish(sample_bundle)
|
||||
|
||||
# First request
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/recipes/test-recipe/1.0.0/download",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
etag = headers.get("ETag")
|
||||
assert etag is not None
|
||||
|
||||
# Second request with If-None-Match
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/recipes/test-recipe/1.0.0/download",
|
||||
headers={"If-None-Match": etag},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 304 # Not Modified
|
||||
|
||||
def test_search_recipes(self, server, sample_bundle):
|
||||
"""Test GET /v1/search."""
|
||||
server.registry.publish(sample_bundle)
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/search",
|
||||
headers={},
|
||||
body=b"",
|
||||
query_string="q=test",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
data = json.loads(body)
|
||||
assert data["ok"] is True
|
||||
assert len(data["results"]) == 1
|
||||
|
||||
def test_search_no_query(self, server):
|
||||
"""Test GET /v1/search without query."""
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/search",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 400
|
||||
data = json.loads(body)
|
||||
assert data["ok"] is False
|
||||
|
||||
def test_publish_requires_auth(self, registry_path, sample_bundle):
|
||||
"""Test POST /v1/recipes/{name}/{version} requires auth when token set."""
|
||||
server = RegistryServer(registry_path=registry_path, token="secret")
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="POST",
|
||||
path="/v1/recipes/test-recipe/1.0.0",
|
||||
headers={"Content-Type": "multipart/form-data; boundary=----test"},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 401
|
||||
|
||||
def test_publish_with_auth(self, registry_path, sample_bundle):
|
||||
"""Test POST /v1/recipes/{name}/{version} with valid auth."""
|
||||
server = RegistryServer(registry_path=registry_path, token="secret")
|
||||
|
||||
# Build multipart body
|
||||
boundary = "----test"
|
||||
bundle_content = sample_bundle.read_bytes()
|
||||
|
||||
body_parts = [
|
||||
f"--{boundary}".encode(),
|
||||
b'Content-Disposition: form-data; name="force"',
|
||||
b"",
|
||||
b"false",
|
||||
f"--{boundary}".encode(),
|
||||
f'Content-Disposition: form-data; name="bundle"; filename="test.praison"'.encode(),
|
||||
b"Content-Type: application/gzip",
|
||||
b"",
|
||||
bundle_content,
|
||||
f"--{boundary}--".encode(),
|
||||
b"",
|
||||
]
|
||||
body = b"\r\n".join(body_parts)
|
||||
|
||||
status, headers, body_response = server.handle_request(
|
||||
method="POST",
|
||||
path="/v1/recipes/test-recipe/1.0.0",
|
||||
headers={
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Authorization": "Bearer secret",
|
||||
},
|
||||
body=body,
|
||||
)
|
||||
|
||||
assert status == 201
|
||||
data = json.loads(body_response)
|
||||
assert data["ok"] is True
|
||||
assert data["name"] == "test-recipe"
|
||||
|
||||
def test_delete_requires_auth(self, registry_path, sample_bundle):
|
||||
"""Test DELETE /v1/recipes/{name}/{version} requires auth."""
|
||||
server = RegistryServer(registry_path=registry_path, token="secret")
|
||||
server.registry.publish(sample_bundle)
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="DELETE",
|
||||
path="/v1/recipes/test-recipe/1.0.0",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 401
|
||||
|
||||
def test_delete_with_auth(self, registry_path, sample_bundle):
|
||||
"""Test DELETE /v1/recipes/{name}/{version} with valid auth."""
|
||||
server = RegistryServer(registry_path=registry_path, token="secret")
|
||||
server.registry.publish(sample_bundle)
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="DELETE",
|
||||
path="/v1/recipes/test-recipe/1.0.0",
|
||||
headers={"Authorization": "Bearer secret"},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
data = json.loads(body)
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_read_only_mode(self, registry_path, sample_bundle):
|
||||
"""Test read-only mode blocks writes."""
|
||||
server = RegistryServer(registry_path=registry_path, read_only=True)
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="POST",
|
||||
path="/v1/recipes/test-recipe/1.0.0",
|
||||
headers={"Content-Type": "multipart/form-data; boundary=----test"},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 403
|
||||
data = json.loads(body)
|
||||
assert "read-only" in data["error"].lower()
|
||||
|
||||
def test_not_found_route(self, server):
|
||||
"""Test 404 for unknown routes."""
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/unknown",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 404
|
||||
|
||||
|
||||
class TestWSGIApp:
|
||||
"""Test WSGI application."""
|
||||
|
||||
@pytest.fixture
|
||||
def app(self, tmp_path):
|
||||
"""Create WSGI app."""
|
||||
return create_wsgi_app(registry_path=tmp_path / "registry")
|
||||
|
||||
def test_wsgi_health(self, app):
|
||||
"""Test WSGI app health endpoint."""
|
||||
environ = {
|
||||
"REQUEST_METHOD": "GET",
|
||||
"PATH_INFO": "/healthz",
|
||||
"QUERY_STRING": "",
|
||||
"CONTENT_LENGTH": "0",
|
||||
"wsgi.input": MockInput(b""),
|
||||
}
|
||||
|
||||
response_started = []
|
||||
def start_response(status, headers):
|
||||
response_started.append((status, headers))
|
||||
|
||||
result = list(app(environ, start_response))
|
||||
|
||||
assert len(response_started) == 1
|
||||
assert "200" in response_started[0][0]
|
||||
|
||||
body = b"".join(result)
|
||||
data = json.loads(body)
|
||||
assert data["ok"] is True
|
||||
|
||||
|
||||
class MockInput:
|
||||
"""Mock WSGI input stream."""
|
||||
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
self.pos = 0
|
||||
|
||||
def read(self, size=-1):
|
||||
if size < 0:
|
||||
result = self.data[self.pos:]
|
||||
self.pos = len(self.data)
|
||||
else:
|
||||
result = self.data[self.pos:self.pos + size]
|
||||
self.pos += size
|
||||
return result
|
||||
@@ -0,0 +1,519 @@
|
||||
"""
|
||||
Tests for Recipe Serve Features - Rate Limiting, Size Limits, Metrics, Admin, OpenTelemetry
|
||||
|
||||
TDD tests for gap implementation:
|
||||
- Rate limiting middleware
|
||||
- Request size limits
|
||||
- GET /metrics endpoint (Prometheus format)
|
||||
- POST /admin/reload endpoint
|
||||
- OpenTelemetry export (lazy import)
|
||||
- --workers CLI flag
|
||||
- OpenAPI export
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Rate Limiting Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestRateLimiting:
|
||||
"""Tests for rate limiting middleware."""
|
||||
|
||||
def test_rate_limiter_creation(self):
|
||||
"""Test rate limiter can be created with config."""
|
||||
from praisonai.recipe.serve import create_rate_limiter
|
||||
|
||||
limiter = create_rate_limiter(requests_per_minute=100)
|
||||
assert limiter is not None
|
||||
|
||||
def test_rate_limiter_allows_requests_under_limit(self):
|
||||
"""Test rate limiter allows requests under limit."""
|
||||
from praisonai.recipe.serve import create_rate_limiter
|
||||
|
||||
limiter = create_rate_limiter(requests_per_minute=10)
|
||||
client_ip = "127.0.0.1"
|
||||
|
||||
# Should allow 10 requests
|
||||
for _ in range(10):
|
||||
allowed, _ = limiter.check(client_ip)
|
||||
assert allowed is True
|
||||
|
||||
def test_rate_limiter_blocks_over_limit(self):
|
||||
"""Test rate limiter blocks requests over limit."""
|
||||
from praisonai.recipe.serve import create_rate_limiter
|
||||
|
||||
limiter = create_rate_limiter(requests_per_minute=5)
|
||||
client_ip = "127.0.0.1"
|
||||
|
||||
# Use up the limit
|
||||
for _ in range(5):
|
||||
limiter.check(client_ip)
|
||||
|
||||
# Next request should be blocked
|
||||
allowed, retry_after = limiter.check(client_ip)
|
||||
assert allowed is False
|
||||
assert retry_after > 0
|
||||
|
||||
def test_rate_limiter_per_client(self):
|
||||
"""Test rate limiter tracks per-client."""
|
||||
from praisonai.recipe.serve import create_rate_limiter
|
||||
|
||||
limiter = create_rate_limiter(requests_per_minute=2)
|
||||
|
||||
# Client 1 uses up limit
|
||||
for _ in range(2):
|
||||
limiter.check("client1")
|
||||
|
||||
# Client 2 should still be allowed
|
||||
allowed, _ = limiter.check("client2")
|
||||
assert allowed is True
|
||||
|
||||
def test_rate_limit_middleware_returns_429(self):
|
||||
"""Test rate limit middleware returns 429 with proper error."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
config = {
|
||||
"rate_limit": 2, # 2 requests per minute
|
||||
"rate_limit_exempt_paths": ["/health"],
|
||||
}
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
# First 2 requests should succeed
|
||||
for _ in range(2):
|
||||
response = client.get("/v1/recipes")
|
||||
assert response.status_code in [200, 404] # OK or no recipes
|
||||
|
||||
# Third request should be rate limited
|
||||
response = client.get("/v1/recipes")
|
||||
assert response.status_code == 429
|
||||
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert data["error"]["code"] == "rate_limited"
|
||||
assert "Retry-After" in response.headers
|
||||
|
||||
def test_rate_limit_exempts_health(self):
|
||||
"""Test /health is exempt from rate limiting by default."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
config = {"rate_limit": 1}
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
# Use up rate limit
|
||||
client.get("/v1/recipes")
|
||||
|
||||
# Health should still work
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Request Size Limit Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestRequestSizeLimits:
|
||||
"""Tests for request size limits."""
|
||||
|
||||
def test_size_limit_allows_small_requests(self):
|
||||
"""Test small requests are allowed."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
config = {"max_request_size": 1024 * 1024} # 1MB
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
# Small request should work
|
||||
response = client.post(
|
||||
"/v1/recipes/run",
|
||||
json={"recipe": "test", "input": "small"}
|
||||
)
|
||||
# May return 404 if recipe doesn't exist, but not 413
|
||||
assert response.status_code != 413
|
||||
|
||||
def test_size_limit_blocks_large_requests(self):
|
||||
"""Test large requests are blocked with 413."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
config = {"max_request_size": 100} # 100 bytes
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
# Large request should be blocked
|
||||
large_data = {"recipe": "test", "input": "x" * 200}
|
||||
response = client.post(
|
||||
"/v1/recipes/run",
|
||||
json=large_data
|
||||
)
|
||||
assert response.status_code == 413
|
||||
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert data["error"]["code"] == "request_too_large"
|
||||
|
||||
def test_default_size_limit_is_10mb(self):
|
||||
"""Test default size limit is 10MB."""
|
||||
from praisonai.recipe.serve import DEFAULT_MAX_REQUEST_SIZE
|
||||
assert DEFAULT_MAX_REQUEST_SIZE == 10 * 1024 * 1024
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Metrics Endpoint Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestMetricsEndpoint:
|
||||
"""Tests for /metrics endpoint."""
|
||||
|
||||
def test_metrics_endpoint_exists_when_enabled(self):
|
||||
"""Test /metrics endpoint exists when enabled."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
config = {"enable_metrics": True}
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_metrics_endpoint_disabled_by_default_on_public(self):
|
||||
"""Test /metrics is disabled by default on non-localhost."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
# Simulate non-localhost config
|
||||
config = {"host": "0.0.0.0", "enable_metrics": False}
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/metrics")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_metrics_prometheus_format(self):
|
||||
"""Test metrics are in Prometheus exposition format."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
config = {"enable_metrics": True}
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
# Make some requests to generate metrics
|
||||
client.get("/health")
|
||||
client.get("/v1/recipes")
|
||||
|
||||
response = client.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
|
||||
content = response.text
|
||||
# Check for Prometheus format markers
|
||||
assert "praisonai_http_requests_total" in content
|
||||
assert "praisonai_http_request_duration_seconds" in content
|
||||
|
||||
def test_metrics_include_required_labels(self):
|
||||
"""Test metrics include path, method, status labels."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
config = {"enable_metrics": True}
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
client.get("/health")
|
||||
|
||||
response = client.get("/metrics")
|
||||
content = response.text
|
||||
|
||||
# Should have labels
|
||||
assert 'path="/health"' in content or 'path="health"' in content
|
||||
assert 'method="GET"' in content
|
||||
assert 'status="200"' in content
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Admin Reload Endpoint Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestAdminReload:
|
||||
"""Tests for /admin/reload endpoint."""
|
||||
|
||||
def test_admin_reload_requires_auth(self):
|
||||
"""Test /admin/reload requires authentication."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
config = {"enable_admin": True, "auth": "api-key", "api_key": "secret"}
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
# Without auth should fail
|
||||
response = client.post("/admin/reload")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_admin_reload_with_auth(self):
|
||||
"""Test /admin/reload works with authentication."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
config = {"enable_admin": True, "auth": "api-key", "api_key": "secret"}
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/admin/reload",
|
||||
headers={"X-API-Key": "secret"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["status"] == "reloaded"
|
||||
|
||||
def test_admin_reload_disabled_by_default(self):
|
||||
"""Test /admin/reload is disabled by default."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
config = {} # No enable_admin
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/admin/reload")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# OpenAPI Export Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestOpenAPIExport:
|
||||
"""Tests for OpenAPI spec export."""
|
||||
|
||||
def test_openapi_endpoint_exists(self):
|
||||
"""Test /openapi.json endpoint exists."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
config = {}
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "openapi" in data
|
||||
assert "paths" in data
|
||||
|
||||
def test_openapi_includes_all_endpoints(self):
|
||||
"""Test OpenAPI spec includes all endpoints."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
config = {"enable_metrics": True, "enable_admin": True}
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/openapi.json")
|
||||
data = response.json()
|
||||
|
||||
paths = data["paths"]
|
||||
assert "/health" in paths
|
||||
assert "/v1/recipes" in paths
|
||||
assert "/v1/recipes/run" in paths
|
||||
assert "/metrics" in paths
|
||||
assert "/admin/reload" in paths
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Workers CLI Flag Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestWorkersCLI:
|
||||
"""Tests for --workers CLI flag."""
|
||||
|
||||
def test_serve_command_accepts_workers_flag(self):
|
||||
"""Test serve command accepts --workers flag."""
|
||||
from praisonai.cli.features.recipe import RecipeHandler
|
||||
|
||||
handler = RecipeHandler()
|
||||
|
||||
# Parse args with workers flag
|
||||
spec = {
|
||||
"workers": {"default": "1"},
|
||||
"port": {"default": "8765"},
|
||||
"host": {"default": "127.0.0.1"},
|
||||
}
|
||||
args = ["--workers", "4", "--port", "8000"]
|
||||
parsed = handler._parse_args(args, spec)
|
||||
|
||||
assert parsed["workers"] == "4"
|
||||
|
||||
def test_workers_passed_to_uvicorn(self):
|
||||
"""Test workers count is passed to uvicorn."""
|
||||
# Test that serve function accepts workers parameter
|
||||
from praisonai.recipe.serve import serve
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(serve)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
assert "workers" in params, "serve() should accept workers parameter"
|
||||
|
||||
# Check default value
|
||||
workers_param = sig.parameters["workers"]
|
||||
assert workers_param.default == 1, "workers should default to 1"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# OpenTelemetry Lazy Import Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestOpenTelemetryLazyImport:
|
||||
"""Tests for OpenTelemetry lazy import behavior."""
|
||||
|
||||
def test_no_crash_without_otel_installed(self):
|
||||
"""Test server works without OpenTelemetry installed."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
# Should not crash even without otel
|
||||
config = {"trace_exporter": "none"}
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_otel_config_options(self):
|
||||
"""Test OpenTelemetry config options are recognized."""
|
||||
from praisonai.recipe.serve import load_config
|
||||
|
||||
# These should be valid config keys
|
||||
config = {
|
||||
"trace_exporter": "otlp",
|
||||
"otlp_endpoint": "http://localhost:4317",
|
||||
"service_name": "praisonai-recipe",
|
||||
}
|
||||
|
||||
# Should not raise
|
||||
assert config["trace_exporter"] == "otlp"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Agent-Recipes Integration Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestAgentRecipesIntegration:
|
||||
"""Tests for Agent-Recipes template integration."""
|
||||
|
||||
def test_template_discovery_precedence(self):
|
||||
"""Test template discovery follows precedence order."""
|
||||
from praisonai.recipe.core import get_template_search_paths
|
||||
|
||||
paths = get_template_search_paths()
|
||||
|
||||
# Should include standard paths
|
||||
assert any("praison" in str(p).lower() for p in paths)
|
||||
|
||||
def test_agent_recipes_path_included(self):
|
||||
"""Test Agent-Recipes path is in search paths."""
|
||||
from praisonai.recipe.core import get_template_search_paths
|
||||
|
||||
paths = get_template_search_paths()
|
||||
path_strs = [str(p) for p in paths]
|
||||
|
||||
# Should include Agent-Recipes or similar
|
||||
# The exact path depends on installation
|
||||
assert len(paths) > 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Structured Error Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestStructuredErrors:
|
||||
"""Tests for consistent structured error responses."""
|
||||
|
||||
def test_401_error_format(self):
|
||||
"""Test 401 errors have correct format."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
config = {"auth": "api-key", "api_key": "secret"}
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/v1/recipes")
|
||||
assert response.status_code == 401
|
||||
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert "code" in data["error"]
|
||||
assert "message" in data["error"]
|
||||
|
||||
def test_429_error_format(self):
|
||||
"""Test 429 errors have correct format."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
config = {"rate_limit": 1}
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
# Use up limit
|
||||
client.get("/v1/recipes")
|
||||
|
||||
response = client.get("/v1/recipes")
|
||||
if response.status_code == 429:
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert data["error"]["code"] == "rate_limited"
|
||||
|
||||
def test_413_error_format(self):
|
||||
"""Test 413 errors have correct format."""
|
||||
pytest.importorskip("starlette")
|
||||
from starlette.testclient import TestClient
|
||||
from praisonai.recipe.serve import create_app
|
||||
|
||||
config = {"max_request_size": 10} # Very small
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/v1/recipes/run",
|
||||
json={"recipe": "test", "input": "x" * 100}
|
||||
)
|
||||
|
||||
if response.status_code == 413:
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert data["error"]["code"] == "request_too_large"
|
||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user