Compare commits

...
15 Commits
Author SHA1 Message Date
Mervin Praison 985a612273 Release v2.7.7 2025-12-30 07:28:15 +00:00
Mervin Praison 9b4cc1d472 Fix: Add async cleanup fixtures to suppress aiohttp unclosed session warnings 2025-12-30 07:12:24 +00:00
Mervin Praison ad5377900b Split test run and output display for better visibility 2025-12-30 07:06:12 +00:00
Mervin Praison a0f2d9d11a Fix debug workflow to properly capture pytest exit code with PIPESTATUS 2025-12-30 06:59:59 +00:00
Mervin Praison 7428bed13f Simplify debug workflow to show full test output 2025-12-30 06:54:04 +00:00
Mervin Praison fbc191c2a2 Update debug workflow to show detailed failure info 2025-12-30 06:47:55 +00:00
Mervin Praison 8a474d680d Fix debug workflow to properly show test failures 2025-12-30 06:41:50 +00:00
Mervin Praison eb120376f7 Update debug workflow to capture full test output with artifact 2025-12-30 06:35:52 +00:00
Mervin Praison d376d88d74 Update debug workflow to capture all test failures 2025-12-30 06:29:42 +00:00
Mervin Praison f0c31ba8ec Add quick debug workflow to identify failing tests 2025-12-30 06:23:46 +00:00
Mervin Praison 439bf42eec Fix: Add pytest_plugins for pytest-asyncio support in conftest.py 2025-12-29 20:50:17 +00:00
Mervin Praison fcaeba6894 Fix: Add sse-starlette dependency and fix JobExecutor event loop issue 2025-12-29 20:43:50 +00:00
Mervin Praison 8a4c2c2c43 Release v2.7.6 2025-12-29 17:28:44 +00:00
Mervin Praison 0b2e6ff69b examples 2025-12-29 16:25:23 +00:00
Mervin Praison 1050d66970 Release v2.7.5 2025-12-29 14:31:52 +00:00
75 changed files with 12008 additions and 196 deletions

No files matched your search

+68
View File
@@ -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
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.7.4" \
"praisonai>=2.7.7" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.7.4" \
"praisonai>=2.7.7" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.7.4" \
"praisonai>=2.7.7" \
"praisonai[ui]" \
"praisonai[crewai]"
+163
View File
@@ -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()
+44
View File
@@ -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()
+238
View File
@@ -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()
+36
View File
@@ -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()
+101
View File
@@ -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
+77
View File
@@ -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()
+47 -24
View File
@@ -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`.
+230
View File
@@ -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())
+42
View File
@@ -77,6 +77,48 @@ praisonai endpoints types
| `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
+350
View File
@@ -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,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
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.4.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.4.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}")
@@ -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
+160 -6
View File
@@ -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,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
+45 -28
View File
@@ -350,22 +350,32 @@ Launch PraisonAI servers with unified discovery support.
return self.EXIT_SUCCESS
def cmd_mcp(self, args: List[str]) -> int:
"""Launch MCP server."""
spec = {
"host": {"default": self.DEFAULT_HOST},
"port": {"default": 8080, "type": "int"},
"transport": {"default": "http"},
"tools_file": {"default": None},
}
parsed = self._parse_args(args, spec)
"""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:
self._print_success(f"Starting MCP server on {parsed['host']}:{parsed['port']}")
print(f" Transport: {parsed['transport']}")
print(" Discovery: /__praisonai__/discovery")
from praisonai.mcp_server.cli import handle_mcp_command
app = self._create_mcp_app(parsed)
self._run_server(app, parsed["host"], parsed["port"], False)
# 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}")
@@ -373,8 +383,6 @@ Launch PraisonAI servers with unified discovery support.
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
return self.EXIT_SUCCESS
def _create_mcp_app(self, config: Dict[str, Any]) -> Any:
"""Create FastAPI app for MCP server."""
@@ -429,20 +437,31 @@ Launch PraisonAI servers with unified discovery support.
return app
def cmd_tools(self, args: List[str]) -> int:
"""Launch tools as MCP server."""
spec = {
"host": {"default": self.DEFAULT_HOST},
"port": {"default": 8081, "type": "int"},
"tools_file": {"default": None},
}
parsed = self._parse_args(args, spec)
"""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:
self._print_success(f"Starting tools MCP server on {parsed['host']}:{parsed['port']}")
print(" Discovery: /__praisonai__/discovery")
from praisonai.mcp_server.cli import handle_mcp_command
app = self._create_tools_app(parsed)
self._run_server(app, parsed["host"], parsed["port"], False)
# 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}")
@@ -450,8 +469,6 @@ Launch PraisonAI servers with unified discovery support.
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
return self.EXIT_SUCCESS
def _create_tools_app(self, config: Dict[str, Any]) -> Any:
"""Create FastAPI app for tools MCP server."""
+56 -2
View File
@@ -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")
@@ -1383,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):
@@ -2910,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")
@@ -2920,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]")
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==2.7.4 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')
+57 -2
View File
@@ -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:
+6
View File
@@ -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")
+517
View File
@@ -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}")
+278
View File
@@ -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,
}
+80
View File
@@ -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,
)
+346 -55
View File
@@ -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(
+214
View File
@@ -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)
+440 -8
View File
@@ -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:
+613
View File
@@ -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
View File
@@ -1 +1 @@
__version__ = "2.7.4"
__version__ = "2.7.7"
+2 -1
View File
@@ -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"]
+31
View File
@@ -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
+519
View File
@@ -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"
@@ -0,0 +1,284 @@
"""
Unit tests for Recipe Runtime Configuration.
Tests:
- RuntimeConfig parsing from dict
- Environment variable expansion
- Default values
- Schema validation
"""
import os
from unittest.mock import patch
class TestRuntimeConfig:
"""Tests for RuntimeConfig dataclass."""
def test_empty_runtime_config(self):
"""Test creating RuntimeConfig with no data."""
from praisonai.recipe.runtime import RuntimeConfig
config = RuntimeConfig()
assert config.background.enabled is False
assert config.job.enabled is False
assert config.schedule.enabled is False
def test_runtime_config_from_dict(self):
"""Test creating RuntimeConfig from dictionary."""
from praisonai.recipe.runtime import RuntimeConfig
data = {
'background': {
'enabled': True,
'max_concurrent': 10,
},
'job': {
'enabled': True,
'timeout_sec': 600,
'webhook_url': 'https://example.com/webhook',
},
'schedule': {
'enabled': True,
'interval': 'daily',
'max_retries': 5,
},
}
config = RuntimeConfig.from_dict(data)
assert config.background.enabled is True
assert config.background.max_concurrent == 10
assert config.job.enabled is True
assert config.job.timeout_sec == 600
assert config.job.webhook_url == 'https://example.com/webhook'
assert config.schedule.enabled is True
assert config.schedule.interval == 'daily'
assert config.schedule.max_retries == 5
def test_runtime_config_defaults(self):
"""Test that defaults are applied correctly."""
from praisonai.recipe.runtime import (
RuntimeConfig,
DEFAULT_TIMEOUT_SEC,
DEFAULT_MAX_COST_USD,
DEFAULT_MAX_RETRIES,
DEFAULT_MAX_CONCURRENT,
)
config = RuntimeConfig.from_dict({})
assert config.background.max_concurrent == DEFAULT_MAX_CONCURRENT
assert config.job.timeout_sec == DEFAULT_TIMEOUT_SEC
assert config.schedule.max_retries == DEFAULT_MAX_RETRIES
assert config.schedule.max_cost_usd == DEFAULT_MAX_COST_USD
def test_runtime_config_to_dict(self):
"""Test converting RuntimeConfig to dictionary."""
from praisonai.recipe.runtime import RuntimeConfig
config = RuntimeConfig()
data = config.to_dict()
assert 'background' in data
assert 'job' in data
assert 'schedule' in data
assert data['background']['enabled'] is False
assert data['job']['enabled'] is False
assert data['schedule']['enabled'] is False
class TestEnvVarExpansion:
"""Tests for environment variable expansion."""
def test_expand_simple_env_var(self):
"""Test expanding a simple environment variable."""
from praisonai.recipe.runtime import expand_env_vars
with patch.dict(os.environ, {'MY_VAR': 'test_value'}):
result = expand_env_vars('${MY_VAR}')
assert result == 'test_value'
def test_expand_env_var_in_string(self):
"""Test expanding env var embedded in string."""
from praisonai.recipe.runtime import expand_env_vars
with patch.dict(os.environ, {'API_KEY': 'secret123'}):
result = expand_env_vars('Bearer ${API_KEY}')
assert result == 'Bearer secret123'
def test_expand_missing_env_var(self):
"""Test that missing env vars are kept as-is."""
from praisonai.recipe.runtime import expand_env_vars
result = expand_env_vars('${NONEXISTENT_VAR}')
assert result == '${NONEXISTENT_VAR}'
def test_expand_env_var_in_dict(self):
"""Test expanding env vars in nested dict."""
from praisonai.recipe.runtime import expand_env_vars
with patch.dict(os.environ, {'WEBHOOK': 'https://example.com'}):
data = {
'url': '${WEBHOOK}/callback',
'nested': {
'value': '${WEBHOOK}/nested'
}
}
result = expand_env_vars(data)
assert result['url'] == 'https://example.com/callback'
assert result['nested']['value'] == 'https://example.com/nested'
def test_expand_env_var_in_list(self):
"""Test expanding env vars in list."""
from praisonai.recipe.runtime import expand_env_vars
with patch.dict(os.environ, {'VAL': 'expanded'}):
data = ['${VAL}', 'static', '${VAL}_suffix']
result = expand_env_vars(data)
assert result == ['expanded', 'static', 'expanded_suffix']
def test_no_expansion_for_non_string(self):
"""Test that non-string values are returned as-is."""
from praisonai.recipe.runtime import expand_env_vars
assert expand_env_vars(123) == 123
assert expand_env_vars(True) is True
assert expand_env_vars(None) is None
class TestParseRuntimeConfig:
"""Tests for parse_runtime_config function."""
def test_parse_none(self):
"""Test parsing None returns default config."""
from praisonai.recipe.runtime import parse_runtime_config, RuntimeConfig
config = parse_runtime_config(None)
assert isinstance(config, RuntimeConfig)
assert config.background.enabled is False
def test_parse_with_env_expansion(self):
"""Test parsing with environment variable expansion."""
from praisonai.recipe.runtime import parse_runtime_config
with patch.dict(os.environ, {'WEBHOOK_URL': 'https://hooks.example.com'}):
data = {
'job': {
'enabled': True,
'webhook_url': '${WEBHOOK_URL}/notify',
}
}
config = parse_runtime_config(data, expand_env=True)
assert config.job.webhook_url == 'https://hooks.example.com/notify'
def test_parse_without_env_expansion(self):
"""Test parsing without environment variable expansion."""
from praisonai.recipe.runtime import parse_runtime_config
data = {
'job': {
'webhook_url': '${WEBHOOK_URL}',
}
}
config = parse_runtime_config(data, expand_env=False)
assert config.job.webhook_url == '${WEBHOOK_URL}'
class TestBackgroundRuntimeConfig:
"""Tests for BackgroundRuntimeConfig."""
def test_from_dict_with_all_fields(self):
"""Test creating from dict with all fields."""
from praisonai.recipe.runtime import BackgroundRuntimeConfig
data = {
'enabled': True,
'max_concurrent': 20,
'cleanup_delay_sec': 7200,
}
config = BackgroundRuntimeConfig.from_dict(data)
assert config.enabled is True
assert config.max_concurrent == 20
assert config.cleanup_delay_sec == 7200
def test_from_dict_partial(self):
"""Test creating from dict with partial fields."""
from praisonai.recipe.runtime import BackgroundRuntimeConfig, DEFAULT_MAX_CONCURRENT
data = {'enabled': True}
config = BackgroundRuntimeConfig.from_dict(data)
assert config.enabled is True
assert config.max_concurrent == DEFAULT_MAX_CONCURRENT
class TestJobRuntimeConfig:
"""Tests for JobRuntimeConfig."""
def test_from_dict_with_all_fields(self):
"""Test creating from dict with all fields."""
from praisonai.recipe.runtime import JobRuntimeConfig
data = {
'enabled': True,
'timeout_sec': 1800,
'webhook_url': 'https://example.com',
'idempotency_scope': 'global',
'events': ['completed'],
}
config = JobRuntimeConfig.from_dict(data)
assert config.enabled is True
assert config.timeout_sec == 1800
assert config.webhook_url == 'https://example.com'
assert config.idempotency_scope == 'global'
assert config.events == ['completed']
def test_default_events(self):
"""Test default events list."""
from praisonai.recipe.runtime import JobRuntimeConfig
config = JobRuntimeConfig.from_dict({})
assert config.events == ['completed', 'failed']
class TestScheduleRuntimeConfig:
"""Tests for ScheduleRuntimeConfig."""
def test_from_dict_with_all_fields(self):
"""Test creating from dict with all fields."""
from praisonai.recipe.runtime import ScheduleRuntimeConfig
data = {
'enabled': True,
'interval': '*/30m',
'max_retries': 5,
'run_immediately': True,
'timeout_sec': 600,
'max_cost_usd': 2.50,
}
config = ScheduleRuntimeConfig.from_dict(data)
assert config.enabled is True
assert config.interval == '*/30m'
assert config.max_retries == 5
assert config.run_immediately is True
assert config.timeout_sec == 600
assert config.max_cost_usd == 2.50
def test_default_interval(self):
"""Test default interval is hourly."""
from praisonai.recipe.runtime import ScheduleRuntimeConfig
config = ScheduleRuntimeConfig.from_dict({})
assert config.interval == 'hourly'
+364
View File
@@ -0,0 +1,364 @@
"""
Unit tests for recipe registry module.
Tests LocalRegistry, HttpRegistry, and server components.
"""
import json
import tarfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from praisonai.recipe.registry import (
LocalRegistry,
HttpRegistry,
RemoteRegistry,
get_registry,
RegistryError,
RecipeNotFoundError,
RecipeExistsError,
_calculate_checksum,
_get_timestamp,
_normalize_name,
_validate_name,
_validate_version,
_atomic_write,
_atomic_write_json,
DEFAULT_REGISTRY_PATH,
DEFAULT_REGISTRY_PORT,
)
class TestHelperFunctions:
"""Test helper functions."""
def test_get_timestamp(self):
"""Test timestamp generation."""
ts = _get_timestamp()
assert ts is not None
assert "T" in ts # ISO format
def test_calculate_checksum(self, tmp_path):
"""Test checksum calculation."""
test_file = tmp_path / "test.txt"
test_file.write_text("hello world")
checksum = _calculate_checksum(test_file)
assert len(checksum) == 64 # SHA256 hex
# Same content should give same checksum
test_file2 = tmp_path / "test2.txt"
test_file2.write_text("hello world")
assert _calculate_checksum(test_file2) == checksum
def test_normalize_name(self):
"""Test name normalization."""
assert _normalize_name("My-Recipe") == "my-recipe"
assert _normalize_name("my_recipe") == "my-recipe"
assert _normalize_name("My.Recipe") == "my-recipe"
assert _normalize_name("my--recipe") == "my-recipe"
def test_validate_name(self):
"""Test name validation."""
assert _validate_name("my-recipe") is True
assert _validate_name("my_recipe") is True
assert _validate_name("MyRecipe123") is True
assert _validate_name("") is False
assert _validate_name("-invalid") is False
assert _validate_name("a" * 200) is False # Too long
def test_validate_version(self):
"""Test version validation."""
assert _validate_version("1.0.0") is True
assert _validate_version("0.1.0") is True
assert _validate_version("1.0.0-alpha") is True
assert _validate_version("1.0.0.beta1") is True
assert _validate_version("") is False
assert _validate_version("1.0") is False # Missing patch
assert _validate_version("invalid") is False
def test_atomic_write(self, tmp_path):
"""Test atomic write."""
test_file = tmp_path / "test.txt"
_atomic_write(test_file, b"hello world")
assert test_file.exists()
assert test_file.read_bytes() == b"hello world"
def test_atomic_write_json(self, tmp_path):
"""Test atomic JSON write."""
test_file = tmp_path / "test.json"
data = {"key": "value", "number": 42}
_atomic_write_json(test_file, data)
assert test_file.exists()
loaded = json.loads(test_file.read_text())
assert loaded == data
class TestLocalRegistry:
"""Test LocalRegistry class."""
@pytest.fixture
def registry(self, tmp_path):
"""Create a temporary registry."""
return LocalRegistry(tmp_path / "registry")
@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_init_creates_structure(self, registry):
"""Test registry initialization creates directory structure."""
assert registry.path.exists()
assert registry.recipes_path.exists()
assert registry.index_path.exists()
def test_publish_bundle(self, registry, sample_bundle):
"""Test publishing a bundle."""
result = registry.publish(sample_bundle)
assert result["name"] == "test-recipe"
assert result["version"] == "1.0.0"
assert "checksum" in result
assert "published_at" in result
def test_publish_duplicate_fails(self, registry, sample_bundle):
"""Test publishing duplicate version fails."""
registry.publish(sample_bundle)
with pytest.raises(RecipeExistsError):
registry.publish(sample_bundle)
def test_publish_force_overwrites(self, registry, sample_bundle):
"""Test force publish overwrites existing."""
registry.publish(sample_bundle)
result = registry.publish(sample_bundle, force=True)
assert result["name"] == "test-recipe"
def test_publish_invalid_bundle(self, registry, tmp_path):
"""Test publishing invalid bundle fails."""
invalid_bundle = tmp_path / "invalid.praison"
invalid_bundle.write_text("not a tarball")
with pytest.raises(RegistryError):
registry.publish(invalid_bundle)
def test_pull_recipe(self, registry, sample_bundle, tmp_path):
"""Test pulling a recipe."""
registry.publish(sample_bundle)
output_dir = tmp_path / "output"
result = registry.pull("test-recipe", output_dir=output_dir)
assert result["name"] == "test-recipe"
assert result["version"] == "1.0.0"
assert (output_dir / "test-recipe" / "manifest.json").exists()
def test_pull_specific_version(self, registry, sample_bundle, tmp_path):
"""Test pulling specific version."""
registry.publish(sample_bundle)
output_dir = tmp_path / "output"
result = registry.pull("test-recipe", version="1.0.0", output_dir=output_dir)
assert result["version"] == "1.0.0"
def test_pull_not_found(self, registry, tmp_path):
"""Test pulling non-existent recipe."""
with pytest.raises(RecipeNotFoundError):
registry.pull("nonexistent")
def test_list_recipes(self, registry, sample_bundle):
"""Test listing recipes."""
registry.publish(sample_bundle)
recipes = registry.list_recipes()
assert len(recipes) == 1
assert recipes[0]["name"] == "test-recipe"
def test_list_recipes_with_tags(self, registry, sample_bundle):
"""Test listing recipes filtered by tags."""
registry.publish(sample_bundle)
# Should find with matching tag
recipes = registry.list_recipes(tags=["test"])
assert len(recipes) == 1
# Should not find with non-matching tag
recipes = registry.list_recipes(tags=["nonexistent"])
assert len(recipes) == 0
def test_search_recipes(self, registry, sample_bundle):
"""Test searching recipes."""
registry.publish(sample_bundle)
# Search by name
results = registry.search("test")
assert len(results) == 1
# Search by tag (search includes tags)
results = registry.search("sample")
assert len(results) == 1 # "sample" is in tags
# Search for non-existent term
results = registry.search("nonexistent")
assert len(results) == 0
def test_get_versions(self, registry, sample_bundle):
"""Test getting versions."""
registry.publish(sample_bundle)
versions = registry.get_versions("test-recipe")
assert "1.0.0" in versions
def test_get_info(self, registry, sample_bundle):
"""Test getting recipe info."""
registry.publish(sample_bundle)
info = registry.get_info("test-recipe")
assert info["name"] == "test-recipe"
assert info["version"] == "1.0.0"
def test_delete_version(self, registry, sample_bundle):
"""Test deleting specific version."""
registry.publish(sample_bundle)
result = registry.delete("test-recipe", version="1.0.0")
assert result is True
with pytest.raises(RecipeNotFoundError):
registry.get_info("test-recipe")
def test_delete_all_versions(self, registry, sample_bundle):
"""Test deleting all versions."""
registry.publish(sample_bundle)
result = registry.delete("test-recipe")
assert result is True
with pytest.raises(RecipeNotFoundError):
registry.get_versions("test-recipe")
class TestHttpRegistry:
"""Test HttpRegistry class."""
@pytest.fixture
def registry(self):
"""Create an HttpRegistry instance."""
return HttpRegistry("http://localhost:7777", token="test-token")
def test_init(self, registry):
"""Test initialization."""
assert registry.url == "http://localhost:7777"
assert registry.token == "test-token"
assert registry.timeout == 30
def test_get_headers(self, registry):
"""Test header generation."""
headers = registry._get_headers()
assert headers["Content-Type"] == "application/json"
assert headers["Authorization"] == "Bearer test-token"
def test_get_headers_no_token(self):
"""Test headers without token."""
registry = HttpRegistry("http://localhost:7777")
registry.token = None
headers = registry._get_headers()
assert "Authorization" not in headers
@patch("urllib.request.urlopen")
def test_health_check(self, mock_urlopen, registry):
"""Test health check."""
mock_response = MagicMock()
mock_response.read.return_value = b'{"ok": true, "status": "healthy"}'
mock_response.headers = {}
mock_response.__enter__ = lambda s: mock_response
mock_response.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_response
result = registry.health()
assert result["ok"] is True
@patch("urllib.request.urlopen")
def test_list_recipes(self, mock_urlopen, registry):
"""Test listing recipes."""
mock_response = MagicMock()
mock_response.read.return_value = b'{"recipes": [{"name": "test"}]}'
mock_response.headers = {}
mock_response.__enter__ = lambda s: mock_response
mock_response.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_response
recipes = registry.list_recipes()
assert len(recipes) == 1
def test_remote_registry_alias(self):
"""Test RemoteRegistry is alias for HttpRegistry."""
assert RemoteRegistry is HttpRegistry
class TestGetRegistry:
"""Test get_registry factory function."""
def test_default_returns_local(self):
"""Test default returns LocalRegistry."""
registry = get_registry()
assert isinstance(registry, LocalRegistry)
def test_http_url_returns_http(self):
"""Test HTTP URL returns HttpRegistry."""
registry = get_registry("http://localhost:7777")
assert isinstance(registry, HttpRegistry)
def test_https_url_returns_http(self):
"""Test HTTPS URL returns HttpRegistry."""
registry = get_registry("https://registry.example.com")
assert isinstance(registry, HttpRegistry)
def test_path_returns_local(self, tmp_path):
"""Test path returns LocalRegistry."""
registry = get_registry(str(tmp_path))
assert isinstance(registry, LocalRegistry)
def test_token_passed_to_http(self):
"""Test token is passed to HttpRegistry."""
registry = get_registry("http://localhost:7777", token="secret")
assert registry.token == "secret"
class TestConstants:
"""Test module constants."""
def test_default_registry_path(self):
"""Test default registry path."""
assert DEFAULT_REGISTRY_PATH == Path.home() / ".praison" / "registry"
def test_default_registry_port(self):
"""Test default registry port."""
assert DEFAULT_REGISTRY_PORT == 7777
+2
View File
@@ -3986,6 +3986,7 @@ anthropic = [
api = [
{ name = "fastapi" },
{ name = "flask" },
{ name = "sse-starlette" },
{ name = "uvicorn" },
]
autogen = [
@@ -4133,6 +4134,7 @@ requires-dist = [
{ name = "sqlalchemy", marker = "extra == 'code'", specifier = ">=2.0.36" },
{ name = "sqlalchemy", marker = "extra == 'realtime'", specifier = ">=2.0.36" },
{ name = "sqlalchemy", marker = "extra == 'ui'", specifier = ">=2.0.36" },
{ name = "sse-starlette", marker = "extra == 'api'", specifier = ">=1.6.0" },
{ name = "tavily-python", marker = "extra == 'chat'", specifier = "==0.5.0" },
{ name = "tavily-python", marker = "extra == 'code'", specifier = "==0.5.0" },
{ name = "tavily-python", marker = "extra == 'realtime'", specifier = "==0.5.0" },