Compare commits

...
6 Commits
Author SHA1 Message Date
Mervin Praison 1050d66970 Release v2.7.5 2025-12-29 14:31:52 +00:00
Mervin Praison bf483f3bd6 Release v2.7.4 2025-12-29 13:01:04 +00:00
Mervin Praison 00ee8eb2f3 Release v2.7.3 2025-12-29 12:19:58 +00:00
Mervin Praison 9a5a432d96 Release v2.7.2 2025-12-29 11:40:55 +00:00
Mervin Praison 8cb5bbb605 Release v2.7.2 - update doctor examples with improved check listing and CI formatting
- Add doctor examples to main README with documentation link
- Sort checks by category in list_available_checks()
- Update registry method call from list_checks() to get_all_checks()
- Remove unused results variable in run_environment_checks()
- Fix f-string formatting in CI integration print statement
2025-12-29 02:19:16 +00:00
Mervin Praison 952514d73c Release v2.7.1 2025-12-29 02:15:51 +00:00
119 changed files with 26972 additions and 18 deletions

No files matched your search

+18
View File
@@ -2359,6 +2359,24 @@ agent = Agent(
| 🌐 Web Search - Real-time search | [📖](https://docs.praison.ai/docs/cli/web-search) |
| 📥 Web Fetch - URL content retrieval | [📖](https://docs.praison.ai/docs/cli/web-fetch) |
| 💾 Prompt Caching - Cost reduction | [📖](https://docs.praison.ai/docs/cli/prompt-caching) |
| 📦 Template Catalog - Browse & discover templates | [📖](https://docs.praison.ai/docs/cli/template-catalog) |
### Template Catalog CLI
| Command | Description |
|---------|-------------|
| `praisonai templates browse` | Open template catalog in browser |
| `praisonai templates browse --print` | Print catalog URL only |
| `praisonai templates validate` | Validate template YAML files |
| `praisonai templates validate --source <dir>` | Validate specific directory |
| `praisonai templates validate --strict` | Strict validation mode |
| `praisonai templates validate --json` | JSON output format |
| `praisonai templates catalog build` | Build catalog locally |
| `praisonai templates catalog build --out <dir>` | Build to specific directory |
| `praisonai templates catalog sync` | Sync template sources |
| `praisonai templates catalog sync --source <name>` | Sync specific source |
**Examples:** [examples/catalog/](examples/catalog/) | **Docs:** [Code](https://docs.praison.ai/docs/cli/template-catalog-code) | [CLI](https://docs.praison.ai/docs/cli/template-catalog)
---
+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.0" \
"praisonai>=2.7.5" \
"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.0" \
"praisonai>=2.7.5" \
"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.0" \
"praisonai>=2.7.5" \
"praisonai[ui]" \
"praisonai[crewai]"
+18
View File
@@ -14,6 +14,7 @@ examples/
│ ├── memory/ # Memory and sessions
│ ├── code/ # Code editing and external CLI tools
│ └── ...
├── serve/ # Server and endpoints examples
├── yaml/ # YAML workflow examples
└── cookbooks/ # Complete use-case examples
```
@@ -30,6 +31,23 @@ examples/
| **Tools** | [python/tools/](python/tools/) | [📖](https://docs.praison.ai/tools) |
| **Code** | [python/code/](python/code/) | [📖](https://docs.praison.ai/code) |
| **YAML** | [yaml/](yaml/) | [📖](https://docs.praison.ai/features/yaml-workflows) |
| **Doctor** | [doctor/](doctor/) | [📖](https://docs.praison.ai/cli/doctor) |
| **Serve** | [serve/](serve/) | [📖](https://docs.praison.ai/cli/serve) |
| **Endpoints** | [serve/](serve/) | [📖](https://docs.praison.ai/cli/endpoints) |
## Serve Examples
| Example | Description | CLI Command |
|---------|-------------|-------------|
| [unified_server.py](serve/unified_server.py) | All providers in one server | `praisonai serve unified` |
| [agent_as_api_single.py](serve/agent_as_api_single.py) | Single agent HTTP API | `praisonai serve agents` |
| [agents_as_api_router.py](serve/agents_as_api_router.py) | Multi-agent router API | `praisonai serve agents` |
| [a2a_server_client.py](serve/a2a_server_client.py) | A2A protocol server | `praisonai serve a2a` |
| [a2u_events_stream.py](serve/a2u_events_stream.py) | A2U event stream | `praisonai serve a2u` |
| [mcp_http_server.py](serve/mcp_http_server.py) | MCP HTTP server | `praisonai serve mcp` |
| [tools_as_mcp_server.py](serve/tools_as_mcp_server.py) | Tools as MCP server | `praisonai serve tools` |
| [agent_launch_modes.py](serve/agent_launch_modes.py) | Agent.launch() API | Python only |
| [endpoints_unified_client.py](serve/endpoints_unified_client.py) | Unified client | `praisonai endpoints` |
## Running Examples
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""
Example: Fetch and search PraisonAI templates from the catalog.
This example demonstrates how to:
- Fetch templates.json from the deployed catalog
- Search templates by keyword
- Filter by tags and requirements
- Generate CLI commands
"""
import json
import urllib.request
from typing import List, Dict, Any, Optional
CATALOG_URL = "https://mervinpraison.github.io/praisonai-template-catalog/data/templates.json"
def fetch_catalog(url: str = CATALOG_URL) -> Dict[str, Any]:
"""Fetch the template catalog from URL."""
try:
with urllib.request.urlopen(url, timeout=10) as response:
return json.loads(response.read().decode())
except Exception as e:
print(f"Failed to fetch catalog: {e}")
# Return empty catalog on failure
return {"version": "0.0.0", "count": 0, "templates": []}
def search_templates(templates: List[Dict], query: str) -> List[Dict]:
"""Search templates by name, description, or tags."""
query = query.lower()
results = []
for t in templates:
if (query in t.get("name", "").lower() or
query in t.get("description", "").lower() or
any(query in tag.lower() for tag in t.get("tags", []))):
results.append(t)
return results
def filter_by_tags(templates: List[Dict], tags: List[str]) -> List[Dict]:
"""Filter templates that have all specified tags."""
return [
t for t in templates
if all(tag in t.get("tags", []) for tag in tags)
]
def filter_by_tool(templates: List[Dict], tool_name: str) -> List[Dict]:
"""Filter templates that require a specific tool."""
return [
t for t in templates
if tool_name in t.get("requires", {}).get("tools", [])
]
def get_template(templates: List[Dict], name: str) -> Optional[Dict]:
"""Get a template by name."""
for t in templates:
if t.get("name") == name:
return t
return None
def generate_cli_command(template: Dict, action: str = "run") -> str:
"""Generate CLI command for a template."""
name = template.get("name", "unknown")
if action == "run":
return f"praisonai templates run {name}"
elif action == "info":
return f"praisonai templates info {name}"
elif action == "init":
return f"praisonai templates init my-project --template {name}"
return f"praisonai templates {action} {name}"
def print_template_summary(template: Dict):
"""Print a summary of a template."""
print(f"\n{'='*60}")
print(f"Name: {template.get('name')}")
print(f"Version: {template.get('version')}")
print(f"Description: {template.get('description', '')[:100]}...")
print(f"Tags: {', '.join(template.get('tags', []))}")
requires = template.get("requires", {})
if requires.get("tools"):
print(f"Tools: {', '.join(requires['tools'])}")
if requires.get("packages"):
print(f"Packages: {', '.join(requires['packages'])}")
if requires.get("env"):
print(f"Env vars: {', '.join(requires['env'])}")
print(f"\nRun: {generate_cli_command(template, 'run')}")
print(f"{'='*60}")
def main():
print("PraisonAI Template Catalog - Python Example")
print("-" * 45)
# Fetch catalog
print("\n1. Fetching template catalog...")
catalog = fetch_catalog()
templates = catalog.get("templates", [])
print(f" Catalog version: {catalog.get('version', 'unknown')}")
print(f" Total templates: {len(templates)}")
if not templates:
print(" No templates found. The catalog may not be deployed yet.")
print(" Try building locally: praisonai templates catalog build")
return
# List all templates
print("\n2. Available templates:")
for t in templates:
print(f" - {t.get('name')} (v{t.get('version')})")
# Search for video templates
print("\n3. Searching for 'video' templates...")
video_templates = search_templates(templates, "video")
print(f" Found {len(video_templates)} video templates:")
for t in video_templates:
print(f" - {t.get('name')}: {t.get('description', '')[:50]}...")
# Filter by tool
print("\n4. Templates using 'shell_tool':")
shell_templates = filter_by_tool(templates, "shell_tool")
for t in shell_templates:
print(f" - {t.get('name')}")
# Get specific template details
if templates:
first_template = templates[0]
print(f"\n5. Template details for '{first_template.get('name')}':")
print_template_summary(first_template)
# Generate CLI commands
print("\n6. CLI commands for all templates:")
for t in templates[:5]: # First 5 only
print(f" {generate_cli_command(t, 'run')}")
print("\nDone!")
if __name__ == "__main__":
main()
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env npx ts-node
/**
* Example: Fetch and search PraisonAI templates from the catalog.
*
* This TypeScript example demonstrates how to:
* - Fetch templates.json from the deployed catalog
* - Search templates by keyword
* - Filter by tags and requirements
* - Generate CLI commands
*/
const CATALOG_URL = "https://mervinpraison.github.io/praisonai-template-catalog/data/templates.json";
interface Template {
name: string;
version: string;
description: string;
author?: string;
license?: string;
tags?: string[];
category?: string;
difficulty?: "beginner" | "intermediate" | "advanced";
requires?: {
tools?: string[];
packages?: string[];
env?: string[];
};
}
interface Catalog {
version: string;
generated_at: string;
count: number;
templates: Template[];
}
async function fetchCatalog(url: string = CATALOG_URL): Promise<Catalog> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch catalog: ${response.statusText}`);
}
return response.json();
}
function searchTemplates(templates: Template[], query: string): Template[] {
const q = query.toLowerCase();
return templates.filter(t =>
t.name.toLowerCase().includes(q) ||
t.description?.toLowerCase().includes(q) ||
t.tags?.some(tag => tag.toLowerCase().includes(q))
);
}
function filterByTags(templates: Template[], tags: string[]): Template[] {
return templates.filter(t =>
tags.every(tag => t.tags?.includes(tag))
);
}
function filterByTool(templates: Template[], toolName: string): Template[] {
return templates.filter(t =>
t.requires?.tools?.includes(toolName)
);
}
function getTemplate(templates: Template[], name: string): Template | undefined {
return templates.find(t => t.name === name);
}
function generateCLICommand(template: Template, action: string = "run"): string {
const name = template.name;
switch (action) {
case "run":
return `praisonai templates run ${name}`;
case "info":
return `praisonai templates info ${name}`;
case "init":
return `praisonai templates init my-project --template ${name}`;
default:
return `praisonai templates ${action} ${name}`;
}
}
function printTemplateSummary(template: Template): void {
console.log("\n" + "=".repeat(60));
console.log(`Name: ${template.name}`);
console.log(`Version: ${template.version}`);
console.log(`Description: ${template.description?.slice(0, 100)}...`);
console.log(`Tags: ${template.tags?.join(", ") || "none"}`);
const requires = template.requires;
if (requires?.tools?.length) {
console.log(`Tools: ${requires.tools.join(", ")}`);
}
if (requires?.packages?.length) {
console.log(`Packages: ${requires.packages.join(", ")}`);
}
if (requires?.env?.length) {
console.log(`Env vars: ${requires.env.join(", ")}`);
}
console.log(`\nRun: ${generateCLICommand(template, "run")}`);
console.log("=".repeat(60));
}
async function main(): Promise<void> {
console.log("PraisonAI Template Catalog - TypeScript Example");
console.log("-".repeat(45));
// Fetch catalog
console.log("\n1. Fetching template catalog...");
let catalog: Catalog;
try {
catalog = await fetchCatalog();
} catch (error) {
console.log(` Failed to fetch: ${error}`);
console.log(" The catalog may not be deployed yet.");
return;
}
const templates = catalog.templates;
console.log(` Catalog version: ${catalog.version}`);
console.log(` Total templates: ${templates.length}`);
if (templates.length === 0) {
console.log(" No templates found.");
return;
}
// List all templates
console.log("\n2. Available templates:");
templates.forEach(t => {
console.log(` - ${t.name} (v${t.version})`);
});
// Search for video templates
console.log("\n3. Searching for 'video' templates...");
const videoTemplates = searchTemplates(templates, "video");
console.log(` Found ${videoTemplates.length} video templates:`);
videoTemplates.forEach(t => {
console.log(` - ${t.name}: ${t.description?.slice(0, 50)}...`);
});
// Filter by tool
console.log("\n4. Templates using 'shell_tool':");
const shellTemplates = filterByTool(templates, "shell_tool");
shellTemplates.forEach(t => {
console.log(` - ${t.name}`);
});
// Get specific template details
if (templates.length > 0) {
const firstTemplate = templates[0];
console.log(`\n5. Template details for '${firstTemplate.name}':`);
printTemplateSummary(firstTemplate);
}
// Generate CLI commands
console.log("\n6. CLI commands for all templates:");
templates.slice(0, 5).forEach(t => {
console.log(` ${generateCLICommand(t, "run")}`);
});
console.log("\nDone!");
}
main().catch(console.error);
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
Run all catalog examples and report pass/fail status.
Usage:
python run_all_catalog_examples.py
Environment variables:
OPENAI_API_KEY or PRAISONAI_OPENAI_API_KEY - Required for agent example
PRAISONAI_RUN_INTEGRATION - Set to "1" to run integration tests
"""
import os
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
def run_example(name: str, script: str, requires_api_key: bool = False) -> bool:
"""Run an example script and return success status."""
print(f"\n{'='*60}")
print(f"Running: {name}")
print(f"Script: {script}")
print("="*60)
# Check API key requirement
if requires_api_key:
api_key = os.environ.get("OPENAI_API_KEY") or os.environ.get("PRAISONAI_OPENAI_API_KEY")
run_integration = os.environ.get("PRAISONAI_RUN_INTEGRATION") == "1"
if not api_key:
print(f"⏭️ SKIPPED: {name} (no API key)")
print(" Set OPENAI_API_KEY to run this example")
return True # Not a failure, just skipped
if not run_integration:
print(f"⏭️ SKIPPED: {name} (integration tests disabled)")
print(" Set PRAISONAI_RUN_INTEGRATION=1 to run")
return True
script_path = SCRIPT_DIR / script
if not script_path.exists():
print(f"❌ FAILED: Script not found: {script_path}")
return False
try:
result = subprocess.run(
[sys.executable, str(script_path)],
capture_output=True,
text=True,
timeout=60,
cwd=str(SCRIPT_DIR)
)
print(result.stdout)
if result.stderr:
print(f"STDERR: {result.stderr}")
if result.returncode == 0:
print(f"✅ PASSED: {name}")
return True
else:
print(f"❌ FAILED: {name} (exit code {result.returncode})")
return False
except subprocess.TimeoutExpired:
print(f"❌ FAILED: {name} (timeout)")
return False
except Exception as e:
print(f"❌ FAILED: {name} ({e})")
return False
def main():
print("PraisonAI Template Catalog - Example Runner")
print("=" * 50)
# Check environment
api_key = os.environ.get("OPENAI_API_KEY") or os.environ.get("PRAISONAI_OPENAI_API_KEY")
if api_key:
print(f"API key: present (...{api_key[-4:]})")
else:
print("API key: not set")
run_integration = os.environ.get("PRAISONAI_RUN_INTEGRATION") == "1"
print(f"Integration tests: {'enabled' if run_integration else 'disabled'}")
# Define examples
examples = [
("Fetch Templates (Python)", "fetch_templates.py", False),
("Template Finder Agent", "template_finder_agent.py", True),
]
# Run examples
results = []
for name, script, requires_key in examples:
success = run_example(name, script, requires_key)
results.append((name, success))
# Summary
print("\n" + "=" * 60)
print("SUMMARY")
print("=" * 60)
passed = sum(1 for _, s in results if s)
failed = sum(1 for _, s in results if not s)
for name, success in results:
status = "✅ PASS" if success else "❌ FAIL"
print(f" {status}: {name}")
print(f"\nTotal: {passed} passed, {failed} failed")
# Exit with appropriate code
sys.exit(0 if failed == 0 else 1)
if __name__ == "__main__":
main()
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""
Example: Template Finder Agent
An Agent()-centric example that uses an AI agent to find templates
based on natural language queries.
Requires: OPENAI_API_KEY environment variable
"""
import json
import os
import urllib.request
CATALOG_URL = "https://mervinpraison.github.io/praisonai-template-catalog/data/templates.json"
def fetch_templates() -> list:
"""Fetch templates from catalog."""
try:
with urllib.request.urlopen(CATALOG_URL, timeout=10) as response:
data = json.loads(response.read().decode())
return data.get("templates", [])
except Exception:
return []
def search_templates_tool(query: str) -> str:
"""
Search templates by keyword.
Args:
query: Search query (e.g., "video editing", "transcript", "data")
Returns:
JSON string with matching templates
"""
templates = fetch_templates()
query_lower = query.lower()
results = []
for t in templates:
if (query_lower in t.get("name", "").lower() or
query_lower in t.get("description", "").lower() or
any(query_lower in tag.lower() for tag in t.get("tags", []))):
results.append({
"name": t.get("name"),
"description": t.get("description", "")[:100],
"tags": t.get("tags", []),
"cli_command": f"praisonai templates run {t.get('name')}"
})
if not results:
return json.dumps({"message": "No templates found matching your query", "templates": []})
return json.dumps({"count": len(results), "templates": results})
def list_all_templates_tool() -> str:
"""
List all available templates.
Returns:
JSON string with all template names and descriptions
"""
templates = fetch_templates()
if not templates:
return json.dumps({"message": "No templates available", "templates": []})
results = [
{"name": t.get("name"), "description": t.get("description", "")[:80]}
for t in templates
]
return json.dumps({"count": len(results), "templates": results})
def get_template_details_tool(name: str) -> str:
"""
Get detailed information about a specific template.
Args:
name: Template name (e.g., "ai-video-editor")
Returns:
JSON string with template details
"""
templates = fetch_templates()
for t in templates:
if t.get("name") == name:
return json.dumps({
"name": t.get("name"),
"version": t.get("version"),
"description": t.get("description"),
"author": t.get("author"),
"tags": t.get("tags", []),
"requires": t.get("requires", {}),
"cli_commands": {
"run": f"praisonai templates run {name}",
"info": f"praisonai templates info {name}",
"init": f"praisonai templates init my-project --template {name}"
}
})
return json.dumps({"error": f"Template '{name}' not found"})
def check_api_key() -> bool:
"""Check if API key is available."""
key = os.environ.get("OPENAI_API_KEY") or os.environ.get("PRAISONAI_OPENAI_API_KEY")
if key:
print(f"API key present: ...{key[-4:]}")
return True
return False
def main():
print("PraisonAI Template Finder Agent")
print("=" * 40)
# Check for API key
if not check_api_key():
print("\n⚠️ No API key found.")
print("Set OPENAI_API_KEY or PRAISONAI_OPENAI_API_KEY to use the agent.")
print("\nFalling back to direct tool usage:\n")
# Demo the tools directly
print("1. Listing all templates:")
result = list_all_templates_tool()
data = json.loads(result)
for t in data.get("templates", [])[:5]:
print(f" - {t['name']}: {t['description'][:50]}...")
print("\n2. Searching for 'video' templates:")
result = search_templates_tool("video")
data = json.loads(result)
for t in data.get("templates", []):
print(f" - {t['name']}")
print(f" Run: {t['cli_command']}")
return
# Import Agent only if API key is available
try:
from praisonaiagents import Agent
except ImportError:
print("praisonaiagents not installed. Install with: pip install praisonaiagents")
return
# Create the Template Finder Agent
agent = Agent(
name="Template Finder",
role="PraisonAI Template Expert",
goal="Help users find the right template for their needs",
backstory="""You are an expert on PraisonAI templates. You help users
discover templates that match their requirements. You can search templates,
list all available templates, and provide detailed information about specific
templates. Always provide the CLI command to run the template.""",
tools=[
search_templates_tool,
list_all_templates_tool,
get_template_details_tool
],
verbose=True
)
# Example queries
queries = [
"Find me a template for editing videos",
"What templates are available for working with transcripts?",
"Show me details about the ai-video-editor template"
]
print("\nRunning Template Finder Agent...")
print("-" * 40)
for query in queries:
print(f"\n📝 Query: {query}")
print("-" * 40)
try:
result = agent.start(query)
print(f"\n🤖 Response:\n{result}")
except Exception as e:
print(f"Error: {e}")
print("\n" + "=" * 40)
if __name__ == "__main__":
main()
+65
View File
@@ -0,0 +1,65 @@
# PraisonAI Doctor Examples
Examples demonstrating the PraisonAI Doctor health check and diagnostics system.
## CLI Examples
| Command | Description |
|---------|-------------|
| `praisonai doctor` | Run all fast health checks |
| `praisonai doctor --version` | Show doctor version |
| `praisonai doctor --list-checks` | List all available checks |
| `praisonai doctor env` | Check environment configuration |
| `praisonai doctor config` | Validate configuration files |
| `praisonai doctor tools` | Check tool availability |
| `praisonai doctor db` | Check database drivers |
| `praisonai doctor mcp` | Check MCP configuration |
| `praisonai doctor obs` | Check observability providers |
| `praisonai doctor skills` | Check agent skills |
| `praisonai doctor memory` | Check memory storage |
| `praisonai doctor permissions` | Check filesystem permissions |
| `praisonai doctor network` | Check network connectivity |
| `praisonai doctor performance` | Check import times |
| `praisonai doctor ci` | CI mode with JSON output |
| `praisonai doctor selftest` | Test agent functionality |
## Global Flags
| Flag | Description |
|------|-------------|
| `--json` | Output in JSON format |
| `--format text\|json` | Output format |
| `--output PATH` | Write report to file |
| `--deep` | Enable deeper probes |
| `--timeout SEC` | Per-check timeout |
| `--strict` | Treat warnings as failures |
| `--quiet` | Minimal output |
| `--no-color` | Disable colors |
| `--only IDS` | Only run these checks |
| `--skip IDS` | Skip these checks |
## Python Examples
| File | Description |
|------|-------------|
| [basic_doctor.py](basic_doctor.py) | Programmatic health checks |
| [ci_integration.py](ci_integration.py) | CI/CD pipeline integration |
## Quick Start
```bash
# Run basic health checks
praisonai doctor
# Run with JSON output
praisonai doctor --json
# Run specific checks
praisonai doctor --only python_version,openai_api_key
# CI mode
praisonai doctor ci
# Save report to file
praisonai doctor --output report.json
```
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
Basic Doctor Example - Programmatic health checks for PraisonAI
This example demonstrates how to use the Doctor module programmatically
to run health checks and diagnostics.
"""
import json
import sys
# Add the praisonai path for development
sys.path.insert(0, '/Users/praison/praisonai-package/src/praisonai')
from praisonai.cli.features.doctor import DoctorEngine
from praisonai.cli.features.doctor.models import DoctorConfig, CheckCategory
from praisonai.cli.features.doctor.registry import get_registry
from praisonai.cli.features.doctor.checks import register_all_checks
from praisonai.cli.features.doctor.formatters import get_formatter
def run_basic_checks():
"""Run basic health checks."""
print("=" * 60)
print("Running Basic Health Checks")
print("=" * 60)
# Register all checks
register_all_checks()
# Create config for fast checks only
config = DoctorConfig(
deep=False,
timeout=10.0,
strict=False,
quiet=False,
)
# Create and run engine
engine = DoctorEngine(config)
report = engine.run()
# Format and print results
formatter = get_formatter("text", no_color=True)
print(formatter.format_report(report))
return report.exit_code
def run_environment_checks():
"""Run only environment checks."""
print("\n" + "=" * 60)
print("Running Environment Checks Only")
print("=" * 60)
# Register all checks
register_all_checks()
config = DoctorConfig(deep=False)
engine = DoctorEngine(config)
# Run only environment category
engine.run_checks(categories=[CheckCategory.ENVIRONMENT])
report = engine.generate_report()
formatter = get_formatter("text", no_color=True)
print(formatter.format_report(report))
return report.exit_code
def run_specific_checks():
"""Run specific checks by ID."""
print("\n" + "=" * 60)
print("Running Specific Checks")
print("=" * 60)
# Register all checks
register_all_checks()
# Only run these specific checks
config = DoctorConfig(
only=["python_version", "openai_api_key", "os_info"]
)
engine = DoctorEngine(config)
report = engine.run()
formatter = get_formatter("text", no_color=True)
print(formatter.format_report(report))
return report.exit_code
def run_json_output():
"""Run checks and get JSON output."""
print("\n" + "=" * 60)
print("Running Checks with JSON Output")
print("=" * 60)
# Register all checks
register_all_checks()
config = DoctorConfig(
only=["python_version", "os_info"],
format="json"
)
engine = DoctorEngine(config)
report = engine.run()
# Get JSON output
formatter = get_formatter("json")
json_output = formatter.format_report(report)
# Parse and pretty print
data = json.loads(json_output)
print(json.dumps(data, indent=2))
return report.exit_code
def list_available_checks():
"""List all available checks."""
print("\n" + "=" * 60)
print("Available Checks")
print("=" * 60)
# Register all checks
register_all_checks()
registry = get_registry()
checks = registry.get_all_checks()
# Sort by category
checks.sort(key=lambda c: c.category.value)
current_category = None
for check in checks:
if check.category != current_category:
current_category = check.category
print(f"\n {current_category.value.upper()}:")
deep_marker = " [deep]" if check.requires_deep else ""
print(f" {check.id:30} {check.description}{deep_marker}")
if __name__ == "__main__":
print("PraisonAI Doctor - Programmatic Usage Examples\n")
# List available checks
list_available_checks()
# Run basic checks
exit_code = run_basic_checks()
print(f"\nBasic checks exit code: {exit_code}")
# Run environment checks only
exit_code = run_environment_checks()
print(f"\nEnvironment checks exit code: {exit_code}")
# Run specific checks
exit_code = run_specific_checks()
print(f"\nSpecific checks exit code: {exit_code}")
# Run with JSON output
exit_code = run_json_output()
print(f"\nJSON output exit code: {exit_code}")
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""
CI Integration Example - Using Doctor in CI/CD pipelines
This example demonstrates how to integrate PraisonAI Doctor
into CI/CD pipelines for automated health checks.
"""
import json
import subprocess
import sys
def run_doctor_ci():
"""Run doctor in CI mode and parse results."""
print("Running PraisonAI Doctor in CI mode...")
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "ci"],
capture_output=True,
text=True,
timeout=120
)
# Parse JSON output
try:
report = json.loads(result.stdout)
except json.JSONDecodeError:
print("Error: Could not parse doctor output")
print(result.stdout)
print(result.stderr)
return 1
# Print summary
summary = report.get("summary", {})
print("\nDoctor CI Results:")
print(f" Total checks: {summary.get('total', 0)}")
print(f" Passed: {summary.get('passed', 0)}")
print(f" Warnings: {summary.get('warnings', 0)}")
print(f" Failed: {summary.get('failed', 0)}")
print(f" Skipped: {summary.get('skipped', 0)}")
print(f" Errors: {summary.get('errors', 0)}")
print(f"\nExit code: {report.get('exit_code', -1)}")
# Check for failures
if summary.get('failed', 0) > 0:
print("\nFailed checks:")
for check in report.get("results", []):
if check.get("status") == "fail":
print(f" - {check.get('title')}: {check.get('message')}")
if check.get("remediation"):
print(f" Fix: {check.get('remediation')}")
return result.returncode
def run_specific_checks_ci():
"""Run specific checks for CI."""
print("\nRunning specific checks for CI...")
# Only run critical checks
critical_checks = [
"python_version",
"praisonai_package",
"openai_api_key",
]
result = subprocess.run(
[
sys.executable, "-m", "praisonai", "doctor",
"--json",
"--only", ",".join(critical_checks)
],
capture_output=True,
text=True,
timeout=60
)
try:
report = json.loads(result.stdout)
print(f"Critical checks: {report['summary']['passed']}/{report['summary']['total']} passed")
return result.returncode
except json.JSONDecodeError:
print("Error parsing output")
return 1
def save_report_to_file():
"""Save doctor report to a file for artifact upload."""
print("\nSaving doctor report to file...")
result = subprocess.run(
[
sys.executable, "-m", "praisonai", "doctor",
"--json",
"--output", "/tmp/doctor-report.json"
],
capture_output=True,
text=True,
timeout=120
)
if result.returncode in [0, 1]:
print("Report saved to /tmp/doctor-report.json")
# Read and display summary
with open("/tmp/doctor-report.json", "r") as f:
report = json.load(f)
print(f"Summary: {report['summary']}")
return result.returncode
if __name__ == "__main__":
print("PraisonAI Doctor - CI Integration Examples\n")
print("=" * 60)
# Run CI mode
exit_code = run_doctor_ci()
# Run specific checks
run_specific_checks_ci()
# Save report
save_report_to_file()
print(f"\nFinal exit code: {exit_code}")
sys.exit(exit_code)
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""
PraisonAI Endpoints Example
This example demonstrates how to:
1. Start the recipe server
2. Use the endpoints CLI to interact with it
3. Invoke recipes via HTTP
Prerequisites:
- pip install praisonai[serve]
- Set OPENAI_API_KEY environment variable
Usage:
# Terminal 1: Start server
praisonai recipe serve --port 8765
# Terminal 2: Run this example
python endpoints_example.py
"""
import json
import os
import subprocess
import sys
import urllib.request
import urllib.error
# Configuration
SERVER_URL = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
def check_server_health():
"""Check if the server is running."""
try:
req = urllib.request.Request(f"{SERVER_URL}/health")
with urllib.request.urlopen(req, timeout=5) as response:
data = json.loads(response.read().decode())
return data.get("status") == "healthy"
except Exception:
return False
def list_endpoints():
"""List available endpoints using CLI."""
print("\n=== Listing Endpoints (CLI) ===")
result = subprocess.run(
["python", "-m", "praisonai.cli.main", "endpoints", "list", "--format", "json"],
capture_output=True,
text=True,
)
if result.returncode == 0:
recipes = json.loads(result.stdout)
print(f"Found {len(recipes)} endpoints:")
for r in recipes:
print(f" - {r['name']} ({r['version']}): {r.get('description', '')[:50]}")
return recipes
else:
print(f"Error: {result.stderr}")
return []
def describe_endpoint(name: str):
"""Describe an endpoint using CLI."""
print(f"\n=== Describing Endpoint: {name} ===")
result = subprocess.run(
["python", "-m", "praisonai.cli.main", "endpoints", "describe", name, "--url", SERVER_URL],
capture_output=True,
text=True,
)
if result.returncode == 0:
info = json.loads(result.stdout)
print(f"Name: {info.get('name')}")
print(f"Version: {info.get('version')}")
print(f"Description: {info.get('description')}")
return info
else:
print(f"Error: {result.stderr}")
return None
def invoke_endpoint_cli(name: str, input_data: dict):
"""Invoke an endpoint using CLI."""
print(f"\n=== Invoking Endpoint (CLI): {name} ===")
result = subprocess.run(
[
"python", "-m", "praisonai.cli.main", "endpoints", "invoke", name,
"--url", SERVER_URL,
"--input-json", json.dumps(input_data),
"--json",
"--dry-run", # Use dry-run for example
],
capture_output=True,
text=True,
)
if result.returncode == 0:
response = json.loads(result.stdout)
print(f"Run ID: {response.get('run_id')}")
print(f"Status: {response.get('status')}")
return response
else:
print(f"Error: {result.stderr}")
return None
def invoke_endpoint_http(name: str, input_data: dict):
"""Invoke an endpoint using HTTP directly."""
print(f"\n=== Invoking Endpoint (HTTP): {name} ===")
body = json.dumps({
"recipe": name,
"input": input_data,
"options": {"dry_run": True},
}).encode()
req = urllib.request.Request(
f"{SERVER_URL}/v1/recipes/run",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as response:
data = json.loads(response.read().decode())
print(f"Run ID: {data.get('run_id')}")
print(f"Status: {data.get('status')}")
print(f"Output: {data.get('output')}")
return data
except urllib.error.HTTPError as e:
print(f"HTTP Error: {e.code} - {e.read().decode()}")
return None
def main():
"""Main example function."""
print("=" * 60)
print("PraisonAI Endpoints Example")
print("=" * 60)
# Check if server is running
print("\n=== Checking Server Health ===")
if not check_server_health():
print(f"Server not running at {SERVER_URL}")
print("\nPlease start the server first:")
print(" praisonai recipe serve --port 8765")
sys.exit(1)
print("✓ Server is healthy")
# List endpoints
recipes = list_endpoints()
if not recipes:
print("No recipes available")
sys.exit(1)
# Use first available recipe
recipe_name = recipes[0]["name"]
# Describe endpoint
describe_endpoint(recipe_name)
# Invoke via CLI
invoke_endpoint_cli(recipe_name, {"query": "Hello from CLI"})
# Invoke via HTTP
invoke_endpoint_http(recipe_name, {"query": "Hello from HTTP"})
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
Example: Lazy Imports & Fast Startup
Demonstrates how PraisonAI Agents uses lazy imports to achieve
fast startup times and reduced memory usage.
"""
import sys
import time
def example_verify_lazy_imports():
"""Verify that heavy dependencies are not loaded at import time."""
print("=" * 60)
print("Lazy Imports Verification")
print("=" * 60)
# Clear any cached imports
heavy_deps = ['litellm', 'chromadb', 'mem0', 'requests']
for dep in heavy_deps:
for key in list(sys.modules.keys()):
if key.startswith(dep):
del sys.modules[key]
# Measure import time
start = time.perf_counter()
import praisonaiagents
elapsed = (time.perf_counter() - start) * 1000
print(f"\nImport time: {elapsed:.1f}ms")
print(f"Target: <200ms")
print(f"Status: {'PASS' if elapsed < 200 else 'FAIL'}")
# Check lazy imports
print("\nLazy Import Check:")
all_lazy = True
for dep in heavy_deps:
is_lazy = dep not in sys.modules
status = "LAZY (good)" if is_lazy else "EAGER (bad)"
print(f" {dep}: {status}")
if not is_lazy:
all_lazy = False
print(f"\nAll lazy: {'PASS' if all_lazy else 'FAIL'}")
return all_lazy
def example_measure_memory():
"""Measure memory usage after import."""
print("\n" + "=" * 60)
print("Memory Usage Measurement")
print("=" * 60)
import tracemalloc
# Clear modules
for key in list(sys.modules.keys()):
if key.startswith('praisonaiagents'):
del sys.modules[key]
tracemalloc.start()
import praisonaiagents
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
current_mb = current / 1024 / 1024
peak_mb = peak / 1024 / 1024
print(f"\nCurrent memory: {current_mb:.1f}MB")
print(f"Peak memory: {peak_mb:.1f}MB")
print(f"Target: <30MB")
print(f"Status: {'PASS' if current_mb < 30 else 'WARN' if current_mb < 45 else 'FAIL'}")
return current_mb < 45
def example_specific_imports():
"""Show how to use specific imports for best performance."""
print("\n" + "=" * 60)
print("Specific Imports Example")
print("=" * 60)
# Good - specific imports are fast
print("\nGood practice - specific imports:")
print(" from praisonaiagents import Agent, Task")
# Avoid - star imports load everything
print("\nAvoid - star imports load more:")
print(" from praisonaiagents import *")
# Example of specific import
from praisonaiagents import Agent
print(f"\nAgent class loaded: {Agent.__name__}")
if __name__ == "__main__":
print("PraisonAI Agents - Lazy Imports Example")
print("=" * 60)
# Run examples
lazy_ok = example_verify_lazy_imports()
memory_ok = example_measure_memory()
example_specific_imports()
print("\n" + "=" * 60)
print("Summary")
print("=" * 60)
print(f"Lazy imports: {'PASS' if lazy_ok else 'FAIL'}")
print(f"Memory usage: {'PASS' if memory_ok else 'FAIL'}")
print(f"Overall: {'PASS' if lazy_ok and memory_ok else 'FAIL'}")
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
Example: Lite Agent (BYO-LLM)
Demonstrates the lightweight praisonaiagents.lite subpackage
that allows you to bring your own LLM client.
"""
import os
def example_custom_llm():
"""Example using a custom LLM function."""
print("=" * 60)
print("Example 1: Custom LLM Function")
print("=" * 60)
from praisonaiagents.lite import LiteAgent
# Define a simple mock LLM
def mock_llm(messages):
"""A mock LLM that echoes the last message."""
last_msg = messages[-1]["content"] if messages else "No message"
return f"Echo: {last_msg}"
agent = LiteAgent(
name="EchoAgent",
llm_fn=mock_llm,
instructions="You are a simple echo agent."
)
response = agent.chat("Hello, world!")
print(f"Response: {response}")
print(f"Chat history length: {len(agent.chat_history)}")
def example_openai_adapter():
"""Example using the built-in OpenAI adapter."""
print("\n" + "=" * 60)
print("Example 2: OpenAI Adapter")
print("=" * 60)
if not os.environ.get("OPENAI_API_KEY"):
print("Skipping: OPENAI_API_KEY not set")
return
from praisonaiagents.lite import LiteAgent, create_openai_llm_fn
# Create LLM function using OpenAI SDK directly
llm_fn = create_openai_llm_fn(model="gpt-4o-mini")
agent = LiteAgent(
name="OpenAIAgent",
llm_fn=llm_fn,
instructions="You are a helpful assistant. Keep responses brief."
)
response = agent.chat("What is 2+2?")
print(f"Response: {response}")
def example_with_tools():
"""Example using tools with LiteAgent."""
print("\n" + "=" * 60)
print("Example 3: Tools with LiteAgent")
print("=" * 60)
from praisonaiagents.lite import LiteAgent, tool
@tool
def add_numbers(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
@tool
def multiply_numbers(a: int, b: int) -> int:
"""Multiply two numbers together."""
return a * b
def mock_llm(messages):
return "The calculation result is ready."
agent = LiteAgent(
name="MathAgent",
llm_fn=mock_llm,
tools=[add_numbers, multiply_numbers]
)
# Execute tools directly
result1 = agent.execute_tool("add_numbers", a=5, b=3)
print(f"5 + 3 = {result1.output}")
print(f"Success: {result1.success}")
result2 = agent.execute_tool("multiply_numbers", a=4, b=7)
print(f"4 * 7 = {result2.output}")
print(f"Success: {result2.success}")
def example_thread_safety():
"""Example demonstrating thread-safe operations."""
print("\n" + "=" * 60)
print("Example 4: Thread Safety")
print("=" * 60)
import threading
from praisonaiagents.lite import LiteAgent
counter = [0]
lock = threading.Lock()
def counting_llm(messages):
with lock:
counter[0] += 1
return f"Response {counter[0]}"
agent = LiteAgent(
name="ThreadSafeAgent",
llm_fn=counting_llm
)
def worker(thread_id):
for i in range(5):
agent.chat(f"Message from thread {thread_id}, iteration {i}")
threads = [
threading.Thread(target=worker, args=(i,))
for i in range(3)
]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Total messages processed: {counter[0]}")
print(f"Chat history length: {len(agent.chat_history)}")
print("Thread safety test: PASS")
if __name__ == "__main__":
print("PraisonAI Agents - Lite Package Examples")
print("=" * 60)
example_custom_llm()
example_openai_adapter()
example_with_tools()
example_thread_safety()
print("\n" + "=" * 60)
print("All examples completed!")
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Runner for all performance examples.
Usage:
# Run without real API (mock only)
python run_all_examples.py
# Run with real API
RUN_REAL_KEY_TESTS=1 OPENAI_API_KEY=your-key python run_all_examples.py
"""
import os
import sys
import subprocess
def run_example(name, script_path):
"""Run an example script and report results."""
print(f"\n{'=' * 60}")
print(f"Running: {name}")
print("=" * 60)
result = subprocess.run(
[sys.executable, script_path],
capture_output=True,
text=True,
env=os.environ.copy()
)
print(result.stdout)
if result.stderr:
print(f"STDERR: {result.stderr}")
return result.returncode == 0
def main():
"""Run all performance examples."""
print("PraisonAI Agents - Performance Examples Runner")
print("=" * 60)
# Check environment
real_tests = os.environ.get("RUN_REAL_KEY_TESTS")
has_openai = bool(os.environ.get("OPENAI_API_KEY"))
print(f"RUN_REAL_KEY_TESTS: {real_tests or 'not set'}")
print(f"OPENAI_API_KEY: {'set' if has_openai else 'not set'}")
# Get script directory
script_dir = os.path.dirname(os.path.abspath(__file__))
# Define examples
examples = [
("Lazy Imports", os.path.join(script_dir, "lazy_imports_example.py")),
("Lite Agent", os.path.join(script_dir, "lite_agent_example.py")),
("Thread Safety", os.path.join(script_dir, "thread_safety_example.py")),
]
# Run examples
results = []
for name, path in examples:
if os.path.exists(path):
success = run_example(name, path)
results.append((name, success))
else:
print(f"Skipping {name}: {path} not found")
results.append((name, None))
# Summary
print("\n" + "=" * 60)
print("Summary")
print("=" * 60)
passed = 0
failed = 0
skipped = 0
for name, success in results:
if success is None:
status = "SKIPPED"
skipped += 1
elif success:
status = "PASS"
passed += 1
else:
status = "FAIL"
failed += 1
print(f" {name}: {status}")
print(f"\nTotal: {passed} passed, {failed} failed, {skipped} skipped")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""
Example: Thread-Safe Agent State
Demonstrates thread-safe chat history and cache management
in PraisonAI Agents.
"""
import os
import threading
import time
def example_lite_agent_thread_safety():
"""Demonstrate thread safety with LiteAgent."""
print("=" * 60)
print("Example 1: LiteAgent Thread Safety")
print("=" * 60)
from praisonaiagents.lite import LiteAgent
# Counter for tracking responses
response_count = [0]
count_lock = threading.Lock()
def counting_llm(messages):
with count_lock:
response_count[0] += 1
return f"Response #{response_count[0]}"
agent = LiteAgent(
name="ThreadSafeAgent",
llm_fn=counting_llm
)
errors = []
def worker(thread_id):
try:
for i in range(10):
agent.chat(f"Thread {thread_id}, message {i}")
except Exception as e:
errors.append(e)
# Create and start threads
threads = [
threading.Thread(target=worker, args=(i,))
for i in range(5)
]
start = time.perf_counter()
for t in threads:
t.start()
for t in threads:
t.join()
elapsed = time.perf_counter() - start
print(f"Threads: 5")
print(f"Messages per thread: 10")
print(f"Total messages: {response_count[0]}")
print(f"Chat history length: {len(agent.chat_history)}")
print(f"Errors: {len(errors)}")
print(f"Time: {elapsed:.2f}s")
print(f"Status: {'PASS' if len(errors) == 0 else 'FAIL'}")
def example_agent_locks():
"""Verify Agent class has proper locks."""
print("\n" + "=" * 60)
print("Example 2: Agent Lock Verification")
print("=" * 60)
from praisonaiagents import Agent
agent = Agent(
name="LockTestAgent",
instructions="Test agent",
verbose=False
)
# Check for locks
has_history_lock = hasattr(agent, '_history_lock')
has_cache_lock = hasattr(agent, '_cache_lock')
print(f"Has _history_lock: {has_history_lock}")
print(f"Has _cache_lock: {has_cache_lock}")
# Verify lock types
if has_history_lock:
lock_type = type(agent._history_lock).__name__
print(f"_history_lock type: {lock_type}")
if has_cache_lock:
lock_type = type(agent._cache_lock).__name__
print(f"_cache_lock type: {lock_type}")
print(f"Status: {'PASS' if has_history_lock and has_cache_lock else 'FAIL'}")
def example_concurrent_real_api():
"""Demonstrate concurrent API calls (requires API key)."""
print("\n" + "=" * 60)
print("Example 3: Concurrent Real API Calls")
print("=" * 60)
if not os.environ.get("OPENAI_API_KEY"):
print("Skipping: OPENAI_API_KEY not set")
return
from praisonaiagents import Agent
agent = Agent(
name="ConcurrentAgent",
instructions="Reply with one word only.",
llm="gpt-4o-mini",
verbose=False
)
results = []
results_lock = threading.Lock()
errors = []
def worker(thread_id):
try:
response = agent.chat(f"Say hello {thread_id}")
with results_lock:
results.append((thread_id, response))
except Exception as e:
errors.append(e)
threads = [
threading.Thread(target=worker, args=(i,))
for i in range(3)
]
start = time.perf_counter()
for t in threads:
t.start()
for t in threads:
t.join()
elapsed = time.perf_counter() - start
print(f"Concurrent requests: 3")
print(f"Successful responses: {len(results)}")
print(f"Errors: {len(errors)}")
print(f"Time: {elapsed:.2f}s")
print(f"Status: {'PASS' if len(errors) == 0 else 'FAIL'}")
if __name__ == "__main__":
print("PraisonAI Agents - Thread Safety Examples")
print("=" * 60)
example_lite_agent_thread_safety()
example_agent_locks()
example_concurrent_real_api()
print("\n" + "=" * 60)
print("All examples completed!")
+80
View File
@@ -0,0 +1,80 @@
# Policy Packs Example
This example demonstrates how to use PraisonAI policy packs for managing tool permissions, data policies, and execution modes.
## Features Demonstrated
- Creating and loading policy packs
- Tool permission checking (allow/deny)
- Policy modes (dev/prod)
- Merging policies
- Data and PII policies
## Quick Start
```bash
python policy_example.py
```
## CLI Commands
```bash
# Show default policy
praisonai recipe policy show
# Show policy from file
praisonai recipe policy show my-policy.yaml
# Create policy template
praisonai recipe policy init -o my-policy.yaml
# Validate policy file
praisonai recipe policy validate my-policy.yaml
```
## Policy File Format
```yaml
name: my-org-policy
version: "1.0"
tools:
allow:
- web.search
- db.query
deny:
- shell.exec
- file.write
network:
allow_domains:
- api.openai.com
deny_domains:
- localhost
pii:
mode: redact # allow, deny, redact
fields:
- email
- phone
data:
retention_days: 30
export_allowed: true
modes:
dev:
allow_interactive_prompts: true
strict_tool_enforcement: false
prod:
allow_interactive_prompts: false
strict_tool_enforcement: true
require_auth: true
```
## Using Policy with Recipe Run
```bash
# Run with policy file
praisonai recipe run my-recipe --policy my-policy.yaml --mode prod
```
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""
PraisonAI Policy Packs Example
This example demonstrates:
1. Creating and loading policy packs
2. Tool permission checking
3. Policy modes (dev/prod)
4. Merging policies
Prerequisites:
- pip install praisonai
Usage:
python policy_example.py
"""
import tempfile
from pathlib import Path
def main():
"""Main example function."""
print("=" * 60)
print("PraisonAI Policy Packs Example")
print("=" * 60)
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
# =====================================================
# Default Policies
# =====================================================
print("\n" + "=" * 40)
print("Default Policies")
print("=" * 40)
from praisonai.recipe.policy import get_default_policy, PolicyPack, PolicyDeniedError
print("\n1. Loading default dev policy...")
dev_policy = get_default_policy("dev")
print(f" Name: {dev_policy.name}")
print(f" Allowed tools: {len(dev_policy.allowed_tools)}")
print(f" Denied tools: {len(dev_policy.denied_tools)}")
print(f" PII mode: {dev_policy.pii_mode}")
print("\n2. Loading default prod policy...")
prod_policy = get_default_policy("prod")
print(f" Name: {prod_policy.name}")
print(f" PII mode: {prod_policy.pii_mode}")
# =====================================================
# Tool Permission Checking
# =====================================================
print("\n" + "=" * 40)
print("Tool Permission Checking")
print("=" * 40)
print("\n3. Checking tool permissions...")
# Check allowed tool
try:
dev_policy.check_tool("web.search")
print(" web.search: ✓ Allowed")
except PolicyDeniedError as e:
print(f" web.search: ✗ Denied - {e}")
# Check denied tool
try:
dev_policy.check_tool("shell.exec")
print(" shell.exec: ✓ Allowed")
except PolicyDeniedError:
print(" shell.exec: ✗ Denied")
# Check another denied tool
try:
dev_policy.check_tool("file.write")
print(" file.write: ✓ Allowed")
except PolicyDeniedError:
print(" file.write: ✗ Denied")
# =====================================================
# Custom Policy Pack
# =====================================================
print("\n" + "=" * 40)
print("Custom Policy Pack")
print("=" * 40)
print("\n4. Creating custom policy pack...")
custom_policy = PolicyPack(
name="my-org-policy",
config={
"tools": {
"allow": ["web.search", "db.query", "file.read"],
"deny": ["shell.exec", "network.unrestricted"],
},
"network": {
"allow_domains": ["api.openai.com", "api.anthropic.com"],
"deny_domains": ["localhost", "127.0.0.1"],
},
"pii": {
"mode": "redact",
"fields": ["email", "phone", "ssn"],
},
"data": {
"retention_days": 30,
"export_allowed": True,
},
"modes": {
"dev": {
"allow_interactive_prompts": True,
"strict_tool_enforcement": False,
},
"prod": {
"allow_interactive_prompts": False,
"strict_tool_enforcement": True,
"require_auth": True,
},
},
},
)
print(f" Name: {custom_policy.name}")
print(f" Allowed tools: {list(custom_policy.allowed_tools)}")
print(f" PII mode: {custom_policy.pii_mode}")
print(f" Retention days: {custom_policy.retention_days}")
# =====================================================
# Saving and Loading Policies
# =====================================================
print("\n" + "=" * 40)
print("Saving and Loading Policies")
print("=" * 40)
print("\n5. Saving policy to file...")
policy_file = tmp_path / "my-policy.yaml"
custom_policy.save(policy_file)
print(f" Saved to: {policy_file}")
print("\n6. Loading policy from file...")
loaded_policy = PolicyPack.load(policy_file)
print(f" Loaded: {loaded_policy.name}")
print(f" PII mode: {loaded_policy.pii_mode}")
# =====================================================
# Merging Policies
# =====================================================
print("\n" + "=" * 40)
print("Merging Policies")
print("=" * 40)
print("\n7. Creating base and override policies...")
base_policy = PolicyPack(
name="base",
config={
"tools": {"allow": ["web.search"]},
"pii": {"mode": "allow"},
},
)
override_policy = PolicyPack(
name="override",
config={
"tools": {"allow": ["db.query"]},
"pii": {"mode": "redact", "fields": ["email"]},
},
)
print("\n8. Merging policies...")
merged = base_policy.merge(override_policy)
print(f" Merged name: {merged.name}")
print(f" Allowed tools: {list(merged.allowed_tools)}")
print(f" PII mode: {merged.pii_mode}")
# =====================================================
# Mode-Specific Configuration
# =====================================================
print("\n" + "=" * 40)
print("Mode-Specific Configuration")
print("=" * 40)
print("\n9. Getting mode-specific config...")
dev_config = custom_policy.get_mode_config("dev")
prod_config = custom_policy.get_mode_config("prod")
print(" Dev mode:")
print(f" - Interactive prompts: {dev_config.get('allow_interactive_prompts')}")
print(f" - Strict enforcement: {dev_config.get('strict_tool_enforcement')}")
print(" Prod mode:")
print(f" - Interactive prompts: {prod_config.get('allow_interactive_prompts')}")
print(f" - Strict enforcement: {prod_config.get('strict_tool_enforcement')}")
print(f" - Require auth: {prod_config.get('require_auth')}")
# =====================================================
# Data Policy
# =====================================================
print("\n" + "=" * 40)
print("Data Policy")
print("=" * 40)
print("\n10. Getting data policy...")
data_policy = custom_policy.get_data_policy()
print(f" PII mode: {data_policy['pii']['mode']}")
print(f" PII fields: {data_policy['pii']['fields']}")
print(f" Retention days: {data_policy['retention_days']}")
print(f" Export allowed: {data_policy['export_allowed']}")
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
Example: LLM-based Recipes
Demonstrates usage of LLM-powered recipes:
- ai-blog-generator
- ai-social-media-generator
- ai-faq-generator
- ai-sentiment-analyzer
"""
import os
import sys
# Ensure API key is set
if not os.environ.get("OPENAI_API_KEY"):
print("Please set OPENAI_API_KEY environment variable")
sys.exit(1)
# Add recipe tools to path
sys.path.insert(0, "/Users/praison/PraisonAI-tools/praisonai_tools/recipe_tools")
from llm_tool import LLMTool, llm_complete
def example_blog_generator():
"""Generate a blog post."""
print("\n=== AI Blog Generator ===")
llm = LLMTool(provider="openai", model="gpt-4o-mini")
topic = "The Future of AI in Healthcare"
response = llm.complete(
f"""Write a short blog post (3 paragraphs) about: {topic}
Include:
- Engaging introduction
- Key points
- Call to action
Format as markdown.""",
system="You are an expert blog writer specializing in technology topics.",
max_tokens=500,
)
print(f"Topic: {topic}")
print(f"\nGenerated Blog Post:\n{response.content}")
print(f"\nTokens used: {response.usage.get('total_tokens', 'N/A')}")
def example_social_media_generator():
"""Generate social media posts."""
print("\n=== AI Social Media Generator ===")
llm = LLMTool(provider="openai", model="gpt-4o-mini")
content = "We just launched our new AI-powered productivity app!"
response = llm.complete(
f"""Create social media posts for the following announcement:
{content}
Generate posts for:
1. Twitter (280 chars max)
2. LinkedIn (professional tone)
3. Instagram (with emoji suggestions)
Format each clearly.""",
system="You are a social media marketing expert.",
max_tokens=400,
)
print(f"Content: {content}")
print(f"\nGenerated Posts:\n{response.content}")
def example_faq_generator():
"""Generate FAQ from content."""
print("\n=== AI FAQ Generator ===")
llm = LLMTool(provider="openai", model="gpt-4o-mini")
documentation = """
Our product is a cloud-based project management tool.
It supports team collaboration, task tracking, and reporting.
Pricing starts at $10/month per user.
We offer a 14-day free trial.
Integration with Slack, GitHub, and Jira is available.
"""
response = llm.complete(
f"""Based on this documentation, generate 5 FAQ questions and answers:
{documentation}
Format as:
Q: [question]
A: [answer]
""",
system="You are a technical writer creating FAQ documentation.",
max_tokens=400,
)
print(f"Generated FAQ:\n{response.content}")
def example_sentiment_analyzer():
"""Analyze sentiment in text."""
print("\n=== AI Sentiment Analyzer ===")
llm = LLMTool(provider="openai", model="gpt-4o-mini")
reviews = [
"This product is amazing! Best purchase ever.",
"Terrible experience. Would not recommend.",
"It's okay, nothing special but does the job.",
]
for review in reviews:
response = llm.complete(
f"""Analyze the sentiment of this text and respond with ONLY one word: POSITIVE, NEGATIVE, or NEUTRAL
Text: {review}""",
max_tokens=10,
)
print(f"Review: {review[:50]}...")
print(f"Sentiment: {response.content.strip()}\n")
if __name__ == "__main__":
print("=" * 60)
print("LLM-Based Recipes Examples")
print("=" * 60)
example_blog_generator()
example_social_media_generator()
example_faq_generator()
example_sentiment_analyzer()
print("\n" + "=" * 60)
print("All examples completed!")
print("=" * 60)
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""
Run All Recipes - Test harness for all Agent-Recipes.
Runs each recipe in dry-run mode to verify CLI parity and run.json generation.
Requires: OPENAI_API_KEY environment variable for integration tests.
"""
import json
import os
import subprocess
import sys
from dataclasses import dataclass
from typing import List
# Recipe definitions
RECIPES = [
"ai-audio-normalizer",
"ai-audio-splitter",
"ai-changelog-generator",
"ai-code-documenter",
"ai-csv-cleaner",
"ai-data-profiler",
"ai-dependency-auditor",
"ai-doc-translator",
"ai-folder-packager",
"ai-image-cataloger",
"ai-image-optimizer",
"ai-image-resizer",
"ai-json-to-csv",
"ai-markdown-to-pdf",
"ai-pdf-summarizer",
"ai-pdf-to-markdown",
"ai-podcast-cleaner",
"ai-repo-readme",
"ai-schema-generator",
"ai-screenshot-ocr",
"ai-sitemap-scraper",
"ai-slide-to-notes",
"ai-url-to-markdown",
"ai-video-editor",
"ai-video-thumbnails",
"ai-video-to-gif",
]
@dataclass
class TestResult:
"""Result of a recipe test."""
recipe: str
status: str # pass, fail, skip
reason: str = ""
duration: float = 0.0
def check_api_keys() -> dict:
"""Check available API keys."""
keys = {
"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 keys
def check_external_deps() -> dict:
"""Check external dependencies."""
import shutil
deps = {
"ffmpeg": shutil.which("ffmpeg") is not None,
"ffprobe": shutil.which("ffprobe") is not None,
"pdftotext": shutil.which("pdftotext") is not None,
"pandoc": shutil.which("pandoc") is not None,
"git": shutil.which("git") is not None,
}
return deps
def run_recipe_dry_run(recipe: str, timeout: int = 30) -> TestResult:
"""Run a recipe in dry-run mode."""
import time
start = time.time()
try:
# Use praison templates run command with dry-run
cmd = ["praison", "templates", "run", recipe, "--dry-run", "dummy_input"]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
)
duration = time.time() - start
if result.returncode == 0:
return TestResult(recipe, "pass", "dry-run completed", duration)
else:
# Check if it's a missing dependency error
stderr = result.stderr.lower()
if "not found" in stderr or "missing" in stderr:
return TestResult(recipe, "skip", f"Missing dependency: {result.stderr[:100]}", duration)
return TestResult(recipe, "fail", f"Exit code {result.returncode}: {result.stderr[:100]}", duration)
except subprocess.TimeoutExpired:
return TestResult(recipe, "fail", "Timeout", timeout)
except FileNotFoundError:
return TestResult(recipe, "skip", "praison CLI not found", 0)
except Exception as e:
return TestResult(recipe, "fail", str(e), 0)
def run_all_recipes(dry_run_only: bool = True) -> List[TestResult]:
"""Run all recipes and collect results."""
results = []
print("=" * 60)
print("RECIPE TEST HARNESS")
print("=" * 60)
# Check prerequisites
keys = check_api_keys()
deps = check_external_deps()
print("\nAPI Keys:")
for key, present in keys.items():
status = "✓" if present else "✗"
print(f" {status} {key}")
print("\nExternal Dependencies:")
for dep, present in deps.items():
status = "✓" if present else "✗"
print(f" {status} {dep}")
print("\n" + "=" * 60)
print(f"Running {len(RECIPES)} recipes (dry-run={dry_run_only})")
print("=" * 60 + "\n")
for i, recipe in enumerate(RECIPES, 1):
print(f"[{i:2d}/{len(RECIPES)}] {recipe}...", end=" ", flush=True)
if dry_run_only:
result = run_recipe_dry_run(recipe)
else:
# Full run would go here
result = run_recipe_dry_run(recipe)
results.append(result)
if result.status == "pass":
print(f"✓ ({result.duration:.1f}s)")
elif result.status == "skip":
print(f"⊘ SKIP: {result.reason}")
else:
print(f"✗ FAIL: {result.reason}")
return results
def print_summary(results: List[TestResult]):
"""Print test summary."""
passed = sum(1 for r in results if r.status == "pass")
failed = sum(1 for r in results if r.status == "fail")
skipped = sum(1 for r in results if r.status == "skip")
print("\n" + "=" * 60)
print("SUMMARY")
print("=" * 60)
print(f" Passed: {passed}")
print(f" Failed: {failed}")
print(f" Skipped: {skipped}")
print(f" Total: {len(results)}")
if failed > 0:
print("\nFailed recipes:")
for r in results:
if r.status == "fail":
print(f" - {r.recipe}: {r.reason}")
return failed == 0
def main():
"""Main entry point."""
import argparse
parser = argparse.ArgumentParser(description="Run all Agent-Recipes tests")
parser.add_argument("--full", action="store_true", help="Run full tests (not just dry-run)")
parser.add_argument("--recipe", help="Run specific recipe only")
parser.add_argument("--json", action="store_true", help="Output JSON results")
args = parser.parse_args()
if args.recipe:
results = [run_recipe_dry_run(args.recipe)]
else:
results = run_all_recipes(dry_run_only=not args.full)
if args.json:
output = [{"recipe": r.recipe, "status": r.status, "reason": r.reason} for r in results]
print(json.dumps(output, indent=2))
else:
success = print_summary(results)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
+48
View File
@@ -0,0 +1,48 @@
# Recipe Registry Example
This example demonstrates how to use the PraisonAI recipe registry for publishing and pulling recipes.
## Features Demonstrated
- Creating a local registry
- Packing recipes into bundles
- Publishing recipes to the registry
- Listing and searching recipes
- Pulling recipes from the registry
## Quick Start
```bash
# Run the example
python registry_example.py
```
## CLI Commands
```bash
# Publish a recipe
praisonai recipe publish ./my-recipe --json
# Pull a recipe
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
praisonai recipe publish ./my-recipe --registry /path/to/registry
# Pull from custom registry
praisonai recipe pull [email protected] --registry /path/to/registry
```
## Default Registry Location
By default, the registry is stored at `~/.praison/registry`.
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
PraisonAI Recipe Registry Example
This example demonstrates how to:
1. Create and publish recipes to a local registry
2. Search and list recipes
3. Pull recipes from the registry
Prerequisites:
- pip install praisonai
Usage:
python registry_example.py
"""
import json
import tarfile
import tempfile
from pathlib import Path
def create_sample_recipe(tmp_dir: Path) -> Path:
"""Create a sample recipe for demonstration."""
recipe_dir = tmp_dir / "hello-world"
recipe_dir.mkdir(parents=True, exist_ok=True)
# Create TEMPLATE.yaml
template_content = """
name: hello-world
version: "1.0.0"
description: A simple hello world recipe
author: example
tags:
- demo
- hello
- example
requires:
env:
- OPENAI_API_KEY
config:
input:
name:
type: string
required: true
description: Name to greet
"""
with open(recipe_dir / "TEMPLATE.yaml", "w") as f:
f.write(template_content.strip())
# Create main.py
main_content = """
def greet(name: str) -> str:
return f"Hello, {name}!"
if __name__ == "__main__":
print(greet("World"))
"""
with open(recipe_dir / "main.py", "w") as f:
f.write(main_content.strip())
return recipe_dir
def pack_recipe(recipe_dir: Path, output_path: Path) -> Path:
"""Pack a recipe into a .praison bundle."""
import hashlib
import io
from datetime import datetime, timezone
with tarfile.open(output_path, "w:gz") as tar:
manifest = {
"name": "hello-world",
"version": "1.0.0",
"description": "A simple hello world recipe",
"tags": ["demo", "hello", "example"],
"created_at": datetime.now(timezone.utc).isoformat(),
"files": [],
}
for file_path in recipe_dir.rglob("*"):
if file_path.is_file() and not file_path.name.startswith("."):
rel_path = file_path.relative_to(recipe_dir)
tar.add(file_path, arcname=str(rel_path))
with open(file_path, "rb") as f:
checksum = hashlib.sha256(f.read()).hexdigest()
manifest["files"].append({
"path": str(rel_path),
"checksum": checksum,
})
manifest_bytes = json.dumps(manifest, indent=2).encode()
manifest_info = tarfile.TarInfo(name="manifest.json")
manifest_info.size = len(manifest_bytes)
tar.addfile(manifest_info, io.BytesIO(manifest_bytes))
return output_path
def main():
"""Main example function."""
print("=" * 60)
print("PraisonAI Recipe Registry Example")
print("=" * 60)
# Use a temporary directory for the example
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
# Create registry in temp directory
registry_path = tmp_path / "registry"
print("\n1. Creating local registry...")
from praisonai.recipe.registry import LocalRegistry
registry = LocalRegistry(registry_path)
print(f" Registry created at: {registry_path}")
# Create and pack a sample recipe
print("\n2. Creating sample recipe...")
recipe_dir = create_sample_recipe(tmp_path)
print(f" Recipe created at: {recipe_dir}")
print("\n3. Packing recipe into bundle...")
bundle_path = tmp_path / "hello-world-1.0.0.praison"
pack_recipe(recipe_dir, bundle_path)
print(f" Bundle created: {bundle_path}")
# Publish to registry
print("\n4. Publishing to registry...")
result = registry.publish(bundle_path)
print(f" Published: {result['name']}@{result['version']}")
print(f" Checksum: {result['checksum'][:16]}...")
# List recipes
print("\n5. Listing recipes in registry...")
recipes = registry.list_recipes()
for r in recipes:
print(f" - {r['name']} ({r['version']}): {r['description']}")
# Search recipes
print("\n6. Searching for 'hello'...")
results = registry.search("hello")
print(f" Found {len(results)} recipe(s)")
# Get versions
print("\n7. Getting versions of 'hello-world'...")
versions = registry.get_versions("hello-world")
print(f" Versions: {versions}")
# Pull recipe
print("\n8. Pulling recipe from registry...")
output_dir = tmp_path / "pulled"
pull_result = registry.pull("hello-world", output_dir=output_dir)
print(f" Pulled to: {pull_result['path']}")
# Verify pulled files
print("\n9. Verifying pulled files...")
pulled_recipe = output_dir / "hello-world"
for f in pulled_recipe.iterdir():
print(f" - {f.name}")
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
# Run History Example
This example demonstrates how to use PraisonAI run history for storing, querying, and exporting recipe runs.
## Features Demonstrated
- Storing run results with input/output data
- Listing and filtering runs
- Querying run details
- Exporting runs for replay/debugging
- Storage statistics and cleanup
## Quick Start
```bash
python run_history_example.py
```
## CLI Commands
```bash
# List recent runs
praisonai recipe runs list
# List runs for a specific recipe
praisonai recipe runs list --recipe support-reply
# Get storage stats
praisonai recipe runs stats
# Export a run
praisonai recipe export run-abc123 -o export.json
# Replay a run
praisonai recipe replay export.json --compare
# Cleanup old runs
praisonai recipe runs cleanup
```
## Default Storage Location
Run history is stored at `~/.praison/runs`.
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
PraisonAI Run History Example
This example demonstrates how to:
1. Store recipe run results in history
2. List and query run history
3. Export runs for replay/debugging
Prerequisites:
- pip install praisonai
Usage:
python run_history_example.py
"""
import tempfile
from pathlib import Path
def main():
"""Main example function."""
print("=" * 60)
print("PraisonAI Run History Example")
print("=" * 60)
# Use a temporary directory for the example
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
# Create history storage in temp directory
history_path = tmp_path / "runs"
print("\n1. Creating run history storage...")
from praisonai.recipe.history import RunHistory
from praisonai.recipe.models import RecipeResult, RecipeStatus
history = RunHistory(history_path)
print(f" Storage created at: {history_path}")
# Create sample run results
print("\n2. Storing sample run results...")
# Run 1: Successful run
result1 = RecipeResult(
run_id="run-abc123",
recipe="support-reply",
version="1.0.0",
status=RecipeStatus.SUCCESS,
output={"reply": "Thank you for contacting us..."},
metrics={"duration_sec": 2.5, "tokens": 150},
trace={"session_id": "session-001", "trace_id": "trace-xyz"},
)
history.store(
result=result1,
input_data={"ticket_id": "T-123", "message": "I need help"},
)
print(f" Stored: {result1.run_id} (success)")
# Run 2: Failed run
result2 = RecipeResult(
run_id="run-def456",
recipe="code-review",
version="2.0.0",
status=RecipeStatus.FAILED,
error="Timeout exceeded",
metrics={"duration_sec": 30.0},
trace={"session_id": "session-002"},
)
history.store(
result=result2,
input_data={"code": "def hello(): pass"},
)
print(f" Stored: {result2.run_id} (failed)")
# Run 3: Another successful run
result3 = RecipeResult(
run_id="run-ghi789",
recipe="support-reply",
version="1.0.0",
status=RecipeStatus.SUCCESS,
output={"reply": "Your issue has been resolved..."},
metrics={"duration_sec": 1.8},
trace={"session_id": "session-001"},
)
history.store(
result=result3,
input_data={"ticket_id": "T-456"},
)
print(f" Stored: {result3.run_id} (success)")
# List all runs
print("\n3. Listing all runs...")
runs = history.list_runs()
for run in runs:
status_icon = "✓" if run["status"] == "success" else "✗"
print(f" {status_icon} {run['run_id']} - {run['recipe']} ({run['status']})")
# Filter by recipe
print("\n4. Filtering runs by recipe 'support-reply'...")
filtered = history.list_runs(recipe="support-reply")
print(f" Found {len(filtered)} run(s)")
# Filter by session
print("\n5. Filtering runs by session 'session-001'...")
filtered = history.list_runs(session_id="session-001")
print(f" Found {len(filtered)} run(s)")
# Get specific run details
print("\n6. Getting details for run-abc123...")
run_data = history.get("run-abc123")
print(f" Recipe: {run_data['recipe']}")
print(f" Status: {run_data['status']}")
print(f" Input: {run_data.get('input', {})}")
print(f" Output: {run_data.get('output', {})}")
# Export a run
print("\n7. Exporting run-abc123...")
export_path = history.export("run-abc123", tmp_path / "export.json")
print(f" Exported to: {export_path}")
# Show export contents
import json
with open(export_path) as f:
export_data = json.load(f)
print(f" Format: {export_data['format']}")
print(f" Exported at: {export_data['exported_at']}")
# Get storage stats
print("\n8. Getting storage statistics...")
stats = history.get_stats()
print(f" Total runs: {stats['total_runs']}")
print(f" Storage size: {stats['total_size_bytes']} bytes")
# Cleanup old runs
print("\n9. Running cleanup...")
deleted = history.cleanup(retention_days=0) # Delete all for demo
print(f" Deleted {deleted} run(s)")
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
+60
View File
@@ -0,0 +1,60 @@
# Security Features Example
This example demonstrates PraisonAI security features including SBOM generation, lockfile validation, dependency auditing, and PII redaction.
## Features Demonstrated
- **SBOM Generation**: Generate Software Bill of Materials in CycloneDX or SPDX format
- **Lockfile Validation**: Validate presence and format of dependency lockfiles
- **Dependency Auditing**: Audit dependencies for vulnerabilities
- **PII Redaction**: Detect and redact personally identifiable information
## Quick Start
```bash
python security_example.py
```
## CLI Commands
### SBOM Generation
```bash
# Generate CycloneDX SBOM
praisonai recipe sbom ./my-recipe --format cyclonedx -o sbom.json
# Generate SPDX SBOM
praisonai recipe sbom ./my-recipe --format spdx -o sbom.json
```
### Dependency Auditing
```bash
# Audit dependencies
praisonai recipe audit ./my-recipe
# Strict audit (fail on issues)
praisonai recipe audit ./my-recipe --strict
```
### Lockfile Validation
```bash
# Validate recipe (includes lockfile check)
praisonai recipe validate ./my-recipe --require-lockfile
```
## PII Redaction Configuration
In your `TEMPLATE.yaml`:
```yaml
data_policy:
pii:
mode: redact # allow, deny, or redact
fields:
- email
- phone
- ssn
- credit_card
```
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
PraisonAI Security Features Example
This example demonstrates:
1. SBOM (Software Bill of Materials) generation
2. Lockfile validation
3. Dependency auditing
4. PII redaction
Prerequisites:
- pip install praisonai
Usage:
python security_example.py
"""
import json
import tempfile
from pathlib import Path
def create_sample_recipe(tmp_dir: Path) -> Path:
"""Create a sample recipe with dependencies."""
recipe_dir = tmp_dir / "secure-recipe"
recipe_dir.mkdir(parents=True, exist_ok=True)
# Create TEMPLATE.yaml
import yaml
template = {
"name": "secure-recipe",
"version": "1.0.0",
"description": "A recipe with security features",
"requires": {
"tools": ["web_search", "file_reader"],
"external": ["ffmpeg"],
"env": ["OPENAI_API_KEY"],
},
}
with open(recipe_dir / "TEMPLATE.yaml", "w") as f:
yaml.dump(template, f)
# Create lock directory with requirements
lock_dir = recipe_dir / "lock"
lock_dir.mkdir()
with open(lock_dir / "requirements.lock", "w") as f:
f.write("openai==1.0.0\n")
f.write("requests==2.31.0\n")
f.write("pydantic==2.0.0\n")
return recipe_dir
def main():
"""Main example function."""
print("=" * 60)
print("PraisonAI Security Features Example")
print("=" * 60)
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
# Create sample recipe
print("\n1. Creating sample recipe with dependencies...")
recipe_dir = create_sample_recipe(tmp_path)
print(f" Recipe created at: {recipe_dir}")
# =====================================================
# SBOM Generation
# =====================================================
print("\n" + "=" * 40)
print("SBOM Generation")
print("=" * 40)
from praisonai.recipe.security import generate_sbom
print("\n2. Generating CycloneDX SBOM...")
sbom = generate_sbom(recipe_dir, format="cyclonedx")
print(f" Format: {sbom['bomFormat']}")
print(f" Spec Version: {sbom['specVersion']}")
print(f" Components: {len(sbom['components'])}")
print("\n Components:")
for comp in sbom["components"][:5]:
print(f" - {comp['name']}@{comp['version']} ({comp['type']})")
# Save SBOM
sbom_path = tmp_path / "sbom.json"
with open(sbom_path, "w") as f:
json.dump(sbom, f, indent=2)
print(f"\n SBOM saved to: {sbom_path}")
# =====================================================
# Lockfile Validation
# =====================================================
print("\n" + "=" * 40)
print("Lockfile Validation")
print("=" * 40)
from praisonai.recipe.security import validate_lockfile
print("\n3. Validating lockfile...")
result = validate_lockfile(recipe_dir)
print(f" Valid: {result['valid']}")
print(f" Lockfile: {result['lockfile']}")
print(f" Type: {result['lockfile_type']}")
# Test strict validation without lockfile
print("\n4. Testing strict validation on recipe without lockfile...")
no_lock_dir = tmp_path / "no-lock-recipe"
no_lock_dir.mkdir()
result = validate_lockfile(no_lock_dir, strict=True)
print(f" Valid: {result['valid']}")
print(f" Errors: {result['errors']}")
# =====================================================
# Dependency Auditing
# =====================================================
print("\n" + "=" * 40)
print("Dependency Auditing")
print("=" * 40)
from praisonai.recipe.security import audit_dependencies
print("\n5. Auditing dependencies...")
report = audit_dependencies(recipe_dir)
print(f" Recipe: {report['recipe']}")
print(f" Lockfile: {report['lockfile']}")
print(f" Dependencies: {len(report['dependencies'])}")
print(f" Vulnerabilities: {len(report['vulnerabilities'])}")
print(f" Warnings: {len(report['warnings'])}")
print(f" Passed: {report['passed']}")
if report["warnings"]:
print("\n Warnings:")
for warn in report["warnings"]:
print(f" - {warn}")
# =====================================================
# PII Redaction
# =====================================================
print("\n" + "=" * 40)
print("PII Redaction")
print("=" * 40)
from praisonai.recipe.security import redact_pii, detect_pii
# Sample data with PII
sample_data = {
"customer": {
"name": "John Doe",
"email": "[email protected]",
"phone": "555-123-4567",
"message": "Please contact me at [email protected]",
},
"ticket_id": "T-12345",
}
print("\n6. Detecting PII in sample data...")
detections = detect_pii(sample_data)
print(f" Found {len(detections)} PII instance(s):")
for d in detections:
print(f" - {d['type']} at {d['path']}")
print("\n7. Redacting PII...")
policy = {
"pii": {
"mode": "redact",
"fields": ["email", "phone"],
}
}
redacted = redact_pii(sample_data, policy)
print(" Original email:", sample_data["customer"]["email"])
print(" Redacted email:", redacted["customer"]["email"])
print(" Original phone:", sample_data["customer"]["phone"])
print(" Redacted phone:", redacted["customer"]["phone"])
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
+142
View File
@@ -0,0 +1,142 @@
# PraisonAI Serve Examples
Examples for all PraisonAI server types and the unified endpoints CLI.
## Examples
| Example | Description | CLI Command |
|---------|-------------|-------------|
| `unified_server.py` | All providers in one server | `praisonai serve unified` |
| `agent_as_api_single.py` | Single agent HTTP API | `praisonai serve agents` |
| `agents_as_api_router.py` | Multi-agent router API | `praisonai serve agents` |
| `a2a_server_client.py` | A2A protocol server | `praisonai serve a2a` |
| `a2u_events_stream.py` | A2U event stream | `praisonai serve a2u` |
| `mcp_http_server.py` | MCP HTTP server | `praisonai serve mcp` |
| `tools_as_mcp_server.py` | Tools as MCP server | `praisonai serve tools` |
| `agent_launch_modes.py` | Agent.launch() API | Python only |
| `endpoints_unified_client.py` | Unified client | `praisonai endpoints` |
| `serve_example.py` | Recipe server client | `praisonai recipe serve` |
## Server Commands
| Command | Description |
|---------|-------------|
| `praisonai serve unified --port 8765` | Start unified server |
| `praisonai serve agents --file agents.yaml --port 8000` | Start agents API |
| `praisonai serve recipe --port 8765` | Start recipe server |
| `praisonai serve mcp --transport http --port 8080` | Start MCP server |
| `praisonai serve tools --port 8081` | Start tools MCP server |
| `praisonai serve a2a --port 8082` | Start A2A server |
| `praisonai serve a2u --port 8083` | Start A2U server |
## Client Commands
| Command | Description |
|---------|-------------|
| `praisonai endpoints list` | List all endpoints |
| `praisonai endpoints list --type agents-api` | Filter by type |
| `praisonai endpoints describe <name>` | Get endpoint details |
| `praisonai endpoints invoke <name> --input-json '{}'` | Invoke endpoint |
| `praisonai endpoints health` | Check server health |
| `praisonai endpoints types` | List provider types |
| `praisonai endpoints discovery` | Show discovery document |
## Quick Start
```bash
# Terminal 1: Start unified server
export OPENAI_API_KEY=your-key
praisonai serve unified --port 8765
# Terminal 2: Use endpoints CLI
praisonai endpoints health
praisonai endpoints list
praisonai endpoints types
```
## Provider Types
| Type | Description |
|------|-------------|
| `recipe` | Recipe runner endpoints |
| `agents-api` | Single/multi-agent HTTP API |
| `mcp` | MCP server (stdio, http, sse) |
| `tools-mcp` | Tools exposed as MCP server |
| `a2a` | Agent-to-agent protocol |
| `a2u` | Agent-to-user event stream |
## Configuration Options
| Option | Description | Default |
|--------|-------------|---------|
| `host` | Server bind address | 127.0.0.1 |
| `port` | Server port | 8765 |
| `auth` | Auth type (none, api-key, jwt) | none |
| `api_key` | API key for auth | - |
| `preload` | Preload recipes on startup | false |
| `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
# Health check
praisonai endpoints health
# List recipes (with auth)
praisonai endpoints list --api-key your-key
# Invoke recipe
praisonai endpoints invoke my-recipe \
--input-json '{"query": "Hello"}' \
--api-key your-key \
--json
```
## Security Notes
- Always use `--auth api-key` when binding to `0.0.0.0`
- Store API keys in environment variables, not config files
- Use HTTPS in production (via reverse proxy)
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
A2A (Agent-to-Agent) Server and Client Example
Demonstrates:
- Launching an agent as A2A server
- Agent card discovery
- Sending A2A messages
- JSON-RPC protocol
Usage:
# Run this example (starts server)
python a2a_server_client.py
# Or use CLI:
praisonai serve a2a --port 8082
# Test with curl:
curl http://localhost:8082/.well-known/agent.json
curl -X POST http://localhost:8082/a2a \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"message/send","id":"1","params":{"message":{"role":"user","parts":[{"type":"text","text":"Hello!"}]}}}'
"""
import os
import sys
import json
import threading
import time
if not os.environ.get("OPENAI_API_KEY"):
print("Error: OPENAI_API_KEY environment variable not set")
sys.exit(1)
def run_server():
"""Run A2A server."""
try:
from praisonaiagents import Agent
from praisonaiagents.ui.a2a import A2A
from fastapi import FastAPI
import uvicorn
except ImportError as e:
print(f"Error: Missing dependency - {e}")
print("Install with: pip install 'praisonaiagents[api]'")
sys.exit(1)
# Create agent
agent = Agent(
name="A2A-Assistant",
role="Helpful AI Assistant",
goal="Help other agents and users with their questions",
backstory="You are a knowledgeable assistant accessible via A2A protocol.",
llm="gpt-4o-mini"
)
# Create A2A interface
a2a = A2A(
agent=agent,
url="http://localhost:8082/a2a",
name="A2A-Assistant",
description="A helpful AI assistant accessible via A2A protocol"
)
# Create FastAPI app
app = FastAPI(title="PraisonAI A2A Server")
app.include_router(a2a.get_router())
# Add discovery endpoint
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": "praisonai-a2a",
"providers": [{"type": "a2a", "name": "A2A Protocol", "capabilities": ["agent-card", "message-send"]}],
"endpoints": [{"name": "a2a", "provider_type": "a2a", "streaming": ["sse"]}]
}
@app.get("/")
async def root():
return {
"message": "PraisonAI A2A Server",
"agent_card": "/.well-known/agent.json",
"a2a_endpoint": "/a2a",
"discovery": "/__praisonai__/discovery"
}
print("Starting A2A server on http://localhost:8082")
print("Agent Card: http://localhost:8082/.well-known/agent.json")
print("A2A Endpoint: http://localhost:8082/a2a")
uvicorn.run(app, host="127.0.0.1", port=8082, log_level="warning")
def run_client():
"""Run A2A client test."""
import urllib.request
import urllib.error
base_url = "http://localhost:8082"
print("\n--- A2A Client Test ---")
# 1. Get agent card
print("\n1. Getting Agent Card...")
try:
req = urllib.request.Request(f"{base_url}/.well-known/agent.json")
with urllib.request.urlopen(req, timeout=5) as resp:
agent_card = json.loads(resp.read().decode())
print(f" Agent: {agent_card.get('name')}")
print(f" URL: {agent_card.get('url')}")
print(f" Capabilities: {agent_card.get('capabilities', {})}")
except Exception as e:
print(f" Error: {e}")
return
# 2. Send A2A message
print("\n2. Sending A2A Message...")
message = {
"jsonrpc": "2.0",
"method": "message/send",
"id": "test-1",
"params": {
"message": {
"role": "user",
"parts": [{"type": "text", "text": "Hello! What can you help me with?"}]
}
}
}
try:
req = urllib.request.Request(
f"{base_url}/a2a",
data=json.dumps(message).encode(),
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=30) as resp:
response = json.loads(resp.read().decode())
if "result" in response:
result_msg = response["result"].get("message", {})
parts = result_msg.get("parts", [])
for part in parts:
if part.get("type") == "text":
print(f" Response: {part.get('text', '')[:200]}...")
elif "error" in response:
print(f" Error: {response['error']}")
except Exception as e:
print(f" Error: {e}")
# 3. Check discovery
print("\n3. Checking Discovery...")
try:
req = urllib.request.Request(f"{base_url}/__praisonai__/discovery")
with urllib.request.urlopen(req, timeout=5) as resp:
discovery = json.loads(resp.read().decode())
print(f" Server: {discovery.get('server_name')}")
print(f" Providers: {[p.get('type') for p in discovery.get('providers', [])]}")
except Exception as e:
print(f" Error: {e}")
print("\n✓ A2A client test completed!")
def main():
print("=" * 60)
print("A2A (Agent-to-Agent) Server and Client Example")
print("=" * 60)
mode = sys.argv[1] if len(sys.argv) > 1 else "server"
if mode == "client":
run_client()
elif mode == "test":
# Start server in background, run client, then stop
print("Starting server in background...")
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
time.sleep(3) # Wait for server to start
run_client()
else:
print("\nStarting A2A server...")
print("Press Ctrl+C to stop")
print("\nTo test, run in another terminal:")
print(" python a2a_server_client.py client")
run_server()
if __name__ == "__main__":
main()
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
A2U (Agent-to-User) Event Stream Example
Demonstrates:
- Launching an A2U event stream server
- Subscribing to agent events
- SSE (Server-Sent Events) streaming
- Event filtering
Usage:
# Run this example (starts server)
python a2u_events_stream.py
# Or use CLI:
praisonai serve a2u --port 8083
# Test with curl:
curl http://localhost:8083/a2u/info
curl -N http://localhost:8083/a2u/events/events
"""
import os
import sys
import json
import threading
import time
if not os.environ.get("OPENAI_API_KEY"):
print("Error: OPENAI_API_KEY environment variable not set")
sys.exit(1)
def run_server():
"""Run A2U server."""
try:
from fastapi import FastAPI
import uvicorn
from praisonai.endpoints.a2u_server import (
create_a2u_routes,
emit_agent_started,
emit_agent_thinking,
emit_agent_response,
emit_agent_completed,
)
except ImportError as e:
print(f"Error: Missing dependency - {e}")
print("Install with: pip install praisonai[serve]")
sys.exit(1)
app = FastAPI(title="PraisonAI A2U Server")
# Add A2U routes
create_a2u_routes(app)
# Add discovery endpoint
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": "praisonai-a2u",
"providers": [{"type": "a2u", "name": "A2U Event Stream", "capabilities": ["subscribe", "stream"]}],
"endpoints": [{"name": "events", "provider_type": "a2u", "streaming": ["sse"]}]
}
@app.get("/")
async def root():
return {
"message": "PraisonAI A2U Server",
"info": "/a2u/info",
"events": "/a2u/events/<stream>",
"discovery": "/__praisonai__/discovery"
}
# Demo endpoint to emit events
@app.post("/demo/emit")
async def demo_emit():
"""Emit demo events for testing."""
agent_id = "demo-agent-1"
emit_agent_started(agent_id, "DemoAgent")
emit_agent_thinking(agent_id, "Processing your request...")
emit_agent_response(agent_id, "Here is my response to your query.")
emit_agent_completed(agent_id, {"status": "success"})
return {"message": "Demo events emitted", "agent_id": agent_id}
print("Starting A2U server on http://localhost:8083")
print("Info: http://localhost:8083/a2u/info")
print("Events: http://localhost:8083/a2u/events/events")
print("Demo: POST http://localhost:8083/demo/emit")
uvicorn.run(app, host="127.0.0.1", port=8083, log_level="warning")
def run_client():
"""Run A2U client test."""
import urllib.request
import urllib.error
base_url = "http://localhost:8083"
print("\n--- A2U Client Test ---")
# 1. Get A2U info
print("\n1. Getting A2U Info...")
try:
req = urllib.request.Request(f"{base_url}/a2u/info")
with urllib.request.urlopen(req, timeout=5) as resp:
info = json.loads(resp.read().decode())
print(f" Name: {info.get('name')}")
print(f" Version: {info.get('version')}")
print(f" Streams: {info.get('streams')}")
print(f" Event Types: {info.get('event_types')}")
except Exception as e:
print(f" Error: {e}")
return
# 2. Subscribe to events
print("\n2. Subscribing to Events...")
try:
req = urllib.request.Request(
f"{base_url}/a2u/subscribe",
data=json.dumps({"stream": "events", "filters": []}).encode(),
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=5) as resp:
sub = json.loads(resp.read().decode())
print(f" Subscription ID: {sub.get('subscription_id')}")
print(f" Stream URL: {sub.get('stream_url')}")
except Exception as e:
print(f" Error: {e}")
# 3. Emit demo events
print("\n3. Emitting Demo Events...")
try:
req = urllib.request.Request(
f"{base_url}/demo/emit",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=5) as resp:
result = json.loads(resp.read().decode())
print(f" {result.get('message')}")
print(f" Agent ID: {result.get('agent_id')}")
except Exception as e:
print(f" Error: {e}")
# 4. Check discovery
print("\n4. Checking Discovery...")
try:
req = urllib.request.Request(f"{base_url}/__praisonai__/discovery")
with urllib.request.urlopen(req, timeout=5) as resp:
discovery = json.loads(resp.read().decode())
print(f" Server: {discovery.get('server_name')}")
print(f" Providers: {[p.get('type') for p in discovery.get('providers', [])]}")
except Exception as e:
print(f" Error: {e}")
print("\n✓ A2U client test completed!")
print("\nTo stream events, run:")
print(f" curl -N {base_url}/a2u/events/events")
def main():
print("=" * 60)
print("A2U (Agent-to-User) Event Stream Example")
print("=" * 60)
mode = sys.argv[1] if len(sys.argv) > 1 else "server"
if mode == "client":
run_client()
elif mode == "test":
print("Starting server in background...")
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
time.sleep(2)
run_client()
else:
print("\nStarting A2U server...")
print("Press Ctrl+C to stop")
print("\nTo test, run in another terminal:")
print(" python a2u_events_stream.py client")
run_server()
if __name__ == "__main__":
main()
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
Single Agent as HTTP API Example
Demonstrates launching a single agent as an HTTP API server with:
- REST endpoint for queries
- Streaming support (SSE)
- Discovery endpoint
- Health check
Usage:
# Run this example
python agent_as_api_single.py
# Or use CLI:
praisonai serve agents --file agents.yaml --port 8000
# Test with curl:
curl -X POST http://localhost:8000/agent \
-H "Content-Type: application/json" \
-d '{"query": "What is AI?"}'
"""
import os
import sys
# Ensure API key is set
if not os.environ.get("OPENAI_API_KEY"):
print("Error: OPENAI_API_KEY environment variable not set")
sys.exit(1)
def main():
print("=" * 60)
print("Single Agent as HTTP API Example")
print("=" * 60)
try:
from praisonaiagents import Agent
except ImportError:
print("Error: praisonaiagents not installed")
print("Install with: pip install praisonaiagents")
sys.exit(1)
# Create a single agent
agent = Agent(
name="Assistant",
role="Helpful AI Assistant",
goal="Help users with their questions accurately and concisely",
backstory="You are a knowledgeable assistant who provides clear, helpful answers.",
llm="gpt-4o-mini",
verbose=True
)
print(f"Agent: {agent.name}")
print(f"Role: {agent.role}")
# Launch as HTTP API
print("\nLaunching agent as HTTP API...")
print("Endpoints:")
print(" POST /agent - Query the agent")
print(" GET /health - Health check")
print(" GET /__praisonai__/discovery - Discovery document")
print("\nPress Ctrl+C to stop")
try:
# Use Agent.launch() to start HTTP server
agent.launch(
port=8000,
host="127.0.0.1",
path="/agent"
)
except KeyboardInterrupt:
print("\nServer stopped")
except Exception as e:
print(f"Error launching server: {e}")
# Fallback: manual FastAPI setup
print("\nFallback: Using manual FastAPI setup...")
launch_manual(agent)
def launch_manual(agent):
"""Manual FastAPI server setup as fallback."""
try:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import uvicorn
except ImportError:
print("Error: FastAPI/uvicorn not installed")
print("Install with: pip install fastapi uvicorn")
return
app = FastAPI(title="PraisonAI Agent API")
@app.post("/agent")
async def query_agent(request: Request):
try:
body = await request.json()
query = body.get("query", "")
if not query:
return JSONResponse({"error": "query required"}, status_code=400)
response = agent.chat(query)
return {"response": response}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/health")
async def health():
return {"status": "healthy", "agent": agent.name}
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": "praisonai-agent",
"providers": [{"type": "agents-api", "name": "Single Agent API"}],
"endpoints": [{"name": "agent", "provider_type": "agents-api"}]
}
@app.get("/")
async def root():
return {
"message": f"PraisonAI Agent: {agent.name}",
"endpoints": ["/agent", "/health", "/__praisonai__/discovery"]
}
uvicorn.run(app, host="127.0.0.1", port=8000)
if __name__ == "__main__":
main()
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""
Agent.launch() and Agents.launch() Example
Demonstrates the high-level Python API for launching servers:
- Agent.launch() for single agent
- Agents.launch() for multi-agent
- Different server modes (HTTP, MCP, A2A)
Usage:
# Run this example
python agent_launch_modes.py
# Test endpoints:
curl http://localhost:8000/agent
curl http://localhost:8000/health
"""
import os
import sys
if not os.environ.get("OPENAI_API_KEY"):
print("Error: OPENAI_API_KEY environment variable not set")
sys.exit(1)
def demo_single_agent_launch():
"""Demo Agent.launch() for single agent."""
print("\n--- Single Agent Launch ---")
try:
from praisonaiagents import Agent
except ImportError:
print("Error: praisonaiagents not installed")
return False
agent = Agent(
name="Assistant",
role="Helpful AI Assistant",
goal="Help users with their questions",
llm="gpt-4o-mini"
)
print(f"Agent: {agent.name}")
print("Launching as HTTP API on port 8000...")
print("\nEndpoints:")
print(" POST /agent - Query the agent")
print(" GET /health - Health check")
print("\nPress Ctrl+C to stop")
try:
agent.launch(port=8000, host="127.0.0.1", path="/agent")
except AttributeError:
print("Agent.launch() not available, using fallback...")
launch_single_fallback(agent)
except KeyboardInterrupt:
print("\nStopped")
return True
def demo_multi_agent_launch():
"""Demo Agents.launch() for multi-agent."""
print("\n--- Multi-Agent Launch ---")
try:
from praisonaiagents import Agent, PraisonAIAgents
except ImportError:
print("Error: praisonaiagents not installed")
return False
researcher = Agent(
name="Researcher",
role="Research Specialist",
goal="Find accurate information",
llm="gpt-4o-mini"
)
writer = Agent(
name="Writer",
role="Content Writer",
goal="Create engaging content",
llm="gpt-4o-mini"
)
agents = PraisonAIAgents(agents=[researcher, writer])
print(f"Agents: {[researcher.name, writer.name]}")
print("Launching as HTTP API on port 8000...")
print("\nEndpoints:")
print(" POST /agents - Route to best agent")
print(" POST /agents/researcher - Query researcher")
print(" POST /agents/writer - Query writer")
print(" GET /agents/list - List agents")
print("\nPress Ctrl+C to stop")
try:
agents.launch(port=8000, host="127.0.0.1")
except AttributeError:
print("Agents.launch() not available, using fallback...")
launch_multi_fallback([researcher, writer])
except KeyboardInterrupt:
print("\nStopped")
return True
def launch_single_fallback(agent):
"""Fallback implementation for Agent.launch()."""
try:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import uvicorn
except ImportError:
print("Error: FastAPI/uvicorn not installed")
return
app = FastAPI(title=f"PraisonAI Agent: {agent.name}")
@app.post("/agent")
async def query(request: Request):
try:
body = await request.json()
query_text = body.get("query", "")
response = agent.chat(query_text)
return {"response": response}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/health")
async def health():
return {"status": "healthy", "agent": agent.name}
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": f"praisonai-{agent.name.lower()}",
"providers": [{"type": "agents-api", "name": "Single Agent API"}],
"endpoints": [{"name": "agent", "provider_type": "agents-api"}]
}
uvicorn.run(app, host="127.0.0.1", port=8000)
def launch_multi_fallback(agents):
"""Fallback implementation for Agents.launch()."""
try:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import uvicorn
except ImportError:
print("Error: FastAPI/uvicorn not installed")
return
app = FastAPI(title="PraisonAI Multi-Agent API")
agent_map = {a.name.lower(): a for a in agents}
@app.post("/agents")
async def route(request: Request):
try:
body = await request.json()
query_text = body.get("query", "")
# Simple routing
agent = agents[0]
response = agent.chat(query_text)
return {"agent": agent.name, "response": response}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.post("/agents/{name}")
async def query_agent(name: str, request: Request):
agent = agent_map.get(name.lower())
if not agent:
return JSONResponse({"error": f"Agent not found: {name}"}, status_code=404)
try:
body = await request.json()
response = agent.chat(body.get("query", ""))
return {"agent": agent.name, "response": response}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/agents/list")
async def list_agents():
return {"agents": [{"name": a.name, "role": a.role} for a in agents]}
@app.get("/health")
async def health():
return {"status": "healthy", "agents": len(agents)}
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": "praisonai-agents",
"providers": [{"type": "agents-api", "name": "Multi-Agent API"}],
"endpoints": [
{"name": "agents", "provider_type": "agents-api"},
*[{"name": f"agents/{a.name.lower()}", "provider_type": "agents-api"} for a in agents]
]
}
uvicorn.run(app, host="127.0.0.1", port=8000)
def main():
print("=" * 60)
print("Agent.launch() and Agents.launch() Example")
print("=" * 60)
mode = sys.argv[1] if len(sys.argv) > 1 else "single"
if mode == "multi":
demo_multi_agent_launch()
else:
demo_single_agent_launch()
if __name__ == "__main__":
main()
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
Multi-Agent Router as HTTP API Example
Demonstrates launching multiple agents as an HTTP API with:
- Router endpoint for automatic agent selection
- Per-agent endpoints
- Streaming support (SSE)
- Discovery endpoint
Usage:
# Run this example
python agents_as_api_router.py
# Or use CLI:
praisonai serve agents --file agents.yaml --port 8000
# Test with curl:
curl -X POST http://localhost:8000/agents \
-H "Content-Type: application/json" \
-d '{"query": "Research AI trends"}'
"""
import os
import sys
if not os.environ.get("OPENAI_API_KEY"):
print("Error: OPENAI_API_KEY environment variable not set")
sys.exit(1)
def main():
print("=" * 60)
print("Multi-Agent Router as HTTP API Example")
print("=" * 60)
try:
from praisonaiagents import Agent, PraisonAIAgents
except ImportError:
print("Error: praisonaiagents not installed")
print("Install with: pip install praisonaiagents")
sys.exit(1)
# Create multiple agents
researcher = Agent(
name="Researcher",
role="Research Specialist",
goal="Find accurate and relevant information",
backstory="Expert at finding and synthesizing information from various sources.",
llm="gpt-4o-mini"
)
writer = Agent(
name="Writer",
role="Content Writer",
goal="Create engaging content from research",
backstory="Skilled writer who transforms research into readable content.",
llm="gpt-4o-mini"
)
analyst = Agent(
name="Analyst",
role="Data Analyst",
goal="Analyze data and provide insights",
backstory="Expert at analyzing data and extracting meaningful insights.",
llm="gpt-4o-mini"
)
agents = [researcher, writer, analyst]
print(f"Agents: {[a.name for a in agents]}")
# Create multi-agent system
praison = PraisonAIAgents(agents=agents)
print("\nLaunching multi-agent router as HTTP API...")
print("Endpoints:")
print(" POST /agents - Route to best agent")
print(" POST /agents/researcher - Query researcher")
print(" POST /agents/writer - Query writer")
print(" POST /agents/analyst - Query analyst")
print(" GET /agents/list - List all agents")
print(" GET /health - Health check")
print(" GET /__praisonai__/discovery - Discovery document")
print("\nPress Ctrl+C to stop")
try:
# Use Agents.launch() to start HTTP server
praison.launch(
port=8000,
host="127.0.0.1"
)
except KeyboardInterrupt:
print("\nServer stopped")
except Exception as e:
print(f"Error launching server: {e}")
print("\nFallback: Using manual FastAPI setup...")
launch_manual(agents)
def launch_manual(agents):
"""Manual FastAPI server setup as fallback."""
try:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import uvicorn
except ImportError:
print("Error: FastAPI/uvicorn not installed")
print("Install with: pip install fastapi uvicorn")
return
app = FastAPI(title="PraisonAI Multi-Agent API")
# Create agent lookup
agent_map = {a.name.lower(): a for a in agents}
@app.post("/agents")
async def route_query(request: Request):
"""Route query to best agent."""
try:
body = await request.json()
query = body.get("query", "")
if not query:
return JSONResponse({"error": "query required"}, status_code=400)
# Simple routing based on keywords
query_lower = query.lower()
if any(w in query_lower for w in ["research", "find", "search"]):
agent = agent_map.get("researcher", agents[0])
elif any(w in query_lower for w in ["write", "create", "draft"]):
agent = agent_map.get("writer", agents[0])
elif any(w in query_lower for w in ["analyze", "data", "insight"]):
agent = agent_map.get("analyst", agents[0])
else:
agent = agents[0]
response = agent.chat(query)
return {"agent": agent.name, "response": response}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.post("/agents/{agent_name}")
async def query_agent(agent_name: str, request: Request):
"""Query specific agent."""
agent = agent_map.get(agent_name.lower())
if not agent:
return JSONResponse({"error": f"Agent not found: {agent_name}"}, status_code=404)
try:
body = await request.json()
query = body.get("query", "")
response = agent.chat(query)
return {"agent": agent.name, "response": response}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/agents/list")
async def list_agents():
return {"agents": [{"name": a.name, "role": a.role} for a in agents]}
@app.get("/health")
async def health():
return {"status": "healthy", "agents": len(agents)}
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": "praisonai-agents-router",
"providers": [{"type": "agents-api", "name": "Multi-Agent Router"}],
"endpoints": [
{"name": "agents", "provider_type": "agents-api"},
*[{"name": f"agents/{a.name.lower()}", "provider_type": "agents-api"} for a in agents]
]
}
@app.get("/")
async def root():
return {
"message": "PraisonAI Multi-Agent Router",
"agents": [a.name for a in agents],
"endpoints": ["/agents", "/agents/list", "/health"]
}
uvicorn.run(app, host="127.0.0.1", port=8000)
if __name__ == "__main__":
main()
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
Unified Endpoints Client Example
Demonstrates using the praisonai endpoints CLI to interact with any PraisonAI server.
Works with recipe, agents-api, mcp, a2a, a2u, and tools-mcp servers.
Usage:
# Start any server first (e.g., unified server)
praisonai serve unified --port 8765
# Then run this example
python endpoints_unified_client.py
# Or use CLI directly:
praisonai endpoints list
praisonai endpoints types
praisonai endpoints health
praisonai endpoints discovery
"""
import json
import os
import subprocess
import sys
def run_cli(args: list) -> tuple:
"""Run praisonai endpoints CLI command."""
cmd = ["python3", "-m", "praisonai.cli.main", "endpoints"] + args
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode, result.stdout, result.stderr
def main():
print("=" * 60)
print("PraisonAI Unified Endpoints Client Example")
print("=" * 60)
server_url = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
print(f"Server URL: {server_url}")
# 1. List provider types
print("\n--- Provider Types ---")
code, out, err = run_cli(["types", "--format", "json"])
if code == 0:
types = json.loads(out)
print(f"Supported types: {[t['type'] for t in types]}")
else:
print(f"Error: {err}")
# 2. Health check
print("\n--- Health Check ---")
code, out, err = run_cli(["health", "--url", server_url])
if code == 0:
print("✓ Server is healthy")
print(out.strip())
elif code == 8:
print("✗ Connection error - server not running")
print("\nStart a server first:")
print(" praisonai serve unified --port 8765")
print(" praisonai serve agents --file agents.yaml --port 8765")
print(" praisonai serve a2a --port 8765")
return 1
else:
print(f"✗ Health check failed: {err}")
return 1
# 3. Discovery document
print("\n--- Discovery Document ---")
code, out, err = run_cli(["discovery", "--url", server_url])
if code == 0:
try:
discovery = json.loads(out)
print(f"Server: {discovery.get('server_name', 'unknown')}")
print(f"Schema version: {discovery.get('schema_version', 'unknown')}")
providers = discovery.get('providers', [])
print(f"Providers: {[p.get('type') for p in providers]}")
endpoints = discovery.get('endpoints', [])
print(f"Endpoints: {[e.get('name') for e in endpoints]}")
except json.JSONDecodeError:
print(out.strip())
else:
print(f"Discovery not available: {err}")
# 4. List endpoints
print("\n--- List Endpoints ---")
code, out, err = run_cli(["list", "--url", server_url, "--format", "json"])
if code == 0:
try:
endpoints = json.loads(out)
if isinstance(endpoints, list):
for ep in endpoints[:5]: # Show first 5
print(f" - {ep.get('name')}: {ep.get('description', '')[:50]}")
else:
print(out.strip()[:200])
except json.JSONDecodeError:
print(out.strip()[:200])
else:
print(f"List failed: {err}")
# 5. Filter by type
print("\n--- Filter by Provider Type ---")
for ptype in ["agents-api", "a2a", "recipe"]:
code, out, err = run_cli(["list", "--url", server_url, "--type", ptype])
if code == 0 and out.strip():
print(f" {ptype}: {len(out.strip().split(chr(10)))} endpoints")
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
return 0
if __name__ == "__main__":
sys.exit(main())
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
MCP HTTP Server Example
Demonstrates launching an MCP server with HTTP transport:
- HTTP-based MCP protocol
- Tool registration
- Discovery endpoint
Usage:
# Run this example
python mcp_http_server.py
# Or use CLI:
praisonai serve mcp --transport http --port 8080
# Test with curl:
curl http://localhost:8080/mcp/tools
curl -X POST http://localhost:8080/mcp/tools/call \
-H "Content-Type: application/json" \
-d '{"tool": "search", "arguments": {"query": "AI news"}}'
"""
import sys
def main():
print("=" * 60)
print("MCP HTTP Server Example")
print("=" * 60)
try:
from fastapi import FastAPI
import uvicorn
except ImportError:
print("Error: FastAPI/uvicorn not installed")
print("Install with: pip install fastapi uvicorn")
sys.exit(1)
# Define tools
def search(query: str) -> str:
"""Search the web for information."""
return f"Search results for: {query}"
def calculate(expression: str) -> str:
"""Calculate a math expression safely."""
try:
# Safe eval for basic math
allowed = set("0123456789+-*/.() ")
if all(c in allowed for c in expression):
return str(eval(expression))
return "Error: Invalid expression"
except Exception as e:
return f"Error: {e}"
def get_weather(city: str) -> str:
"""Get weather for a city (mock)."""
return f"Weather in {city}: Sunny, 72°F"
tools = {
"search": {"func": search, "description": "Search the web for information"},
"calculate": {"func": calculate, "description": "Calculate a math expression"},
"get_weather": {"func": get_weather, "description": "Get weather for a city"},
}
app = FastAPI(title="PraisonAI MCP Server")
@app.get("/mcp/tools")
async def list_tools():
"""List available MCP tools."""
return {
"tools": [
{
"name": name,
"description": info["description"],
"inputSchema": {
"type": "object",
"properties": {
list(info["func"].__code__.co_varnames)[0]: {"type": "string"}
},
"required": [list(info["func"].__code__.co_varnames)[0]]
}
}
for name, info in tools.items()
]
}
@app.post("/mcp/tools/call")
async def call_tool(request: dict):
"""Call an MCP tool."""
tool_name = request.get("tool")
arguments = request.get("arguments", {})
if tool_name not in tools:
return {"error": f"Tool not found: {tool_name}"}
try:
func = tools[tool_name]["func"]
# Get first argument
arg_name = list(func.__code__.co_varnames)[0]
arg_value = arguments.get(arg_name, "")
result = func(arg_value)
return {"result": result, "tool": tool_name}
except Exception as e:
return {"error": str(e)}
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": "praisonai-mcp",
"providers": [{"type": "mcp", "name": "MCP Server", "capabilities": ["list-tools", "call-tool"]}],
"endpoints": [{"name": "mcp", "provider_type": "mcp"}]
}
@app.get("/health")
async def health():
return {"status": "healthy", "tools": len(tools)}
@app.get("/")
async def root():
return {
"message": "PraisonAI MCP Server (HTTP)",
"tools": list(tools.keys()),
"endpoints": ["/mcp/tools", "/mcp/tools/call", "/health"]
}
print(f"Tools: {list(tools.keys())}")
print("\nStarting MCP HTTP server on http://localhost:8080")
print("List tools: GET http://localhost:8080/mcp/tools")
print("Call tool: POST http://localhost:8080/mcp/tools/call")
print("\nPress Ctrl+C to stop")
uvicorn.run(app, host="127.0.0.1", port=8080)
if __name__ == "__main__":
main()
+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()
+29
View File
@@ -0,0 +1,29 @@
# PraisonAI Recipe Server Configuration
# This file configures the recipe HTTP server
# Server binding
host: 127.0.0.1
port: 8765
# Authentication
# Options: none, api-key, jwt
auth: api-key
# API key for authentication (or use PRAISONAI_API_KEY env var)
# api_key: your-secret-key-here
# Recipe filtering (optional)
# Only serve these specific recipes
# recipes:
# - my-recipe
# - another-recipe
# Performance
# Preload recipes on startup for faster first request
preload: true
# CORS configuration (for web clients)
# cors_origins: "https://app.example.com,https://admin.example.com"
# Logging level
# log_level: info
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
PraisonAI Recipe Server Example
This example demonstrates how to:
1. Start the recipe server with configuration
2. Use API key authentication
3. Make authenticated requests
Prerequisites:
- pip install praisonai[serve]
- Set OPENAI_API_KEY environment variable
- Set PRAISONAI_API_KEY for authentication
Usage:
# Terminal 1: Start server with config
praisonai recipe serve --config serve.yaml
# Terminal 2: Run this example
python serve_example.py
"""
import json
import os
import sys
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) -> dict:
"""Make authenticated HTTP request to server."""
url = f"{SERVER_URL}{path}"
headers = {
"Content-Type": "application/json",
"X-API-Key": API_KEY,
}
body = json.dumps(data).encode() if data else None
try:
req = urllib.request.Request(url, data=body, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=30) as response:
return {"status": response.status, "data": json.loads(response.read().decode())}
except urllib.error.HTTPError as e:
body = e.read().decode() if e.fp else ""
return {"status": e.code, "error": body}
except urllib.error.URLError as e:
return {"status": 0, "error": str(e.reason)}
def check_health():
"""Check server health."""
print("\n=== Health Check ===")
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 list_recipes():
"""List available recipes."""
print("\n=== List Recipes ===")
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:
print(f" - {r['name']} ({r['version']})")
return recipes
elif result.get("status") == 401:
print("✗ Authentication failed - check API key")
return []
else:
print(f"✗ Error: {result.get('error')}")
return []
def describe_recipe(name: str):
"""Get recipe details."""
print(f"\n=== Describe Recipe: {name} ===")
result = make_request("GET", f"/v1/recipes/{name}")
if result.get("status") == 200:
info = result["data"]
print(f"Name: {info.get('name')}")
print(f"Version: {info.get('version')}")
print(f"Description: {info.get('description')}")
return info
else:
print(f"✗ Error: {result.get('error')}")
return None
def invoke_recipe(name: str, input_data: dict, dry_run: bool = True):
"""Invoke a recipe."""
print(f"\n=== Invoke Recipe: {name} (dry_run={dry_run}) ===")
result = make_request("POST", "/v1/recipes/run", {
"recipe": name,
"input": input_data,
"options": {"dry_run": dry_run}
})
if result.get("status") == 200:
data = result["data"]
print("✓ Success")
print(f" Run ID: {data.get('run_id')}")
print(f" Status: {data.get('status')}")
if data.get("output"):
print(f" Output: {json.dumps(data.get('output'), indent=2)[:200]}...")
return data
elif result.get("status") == 401:
print("✗ Authentication failed - check API key")
return None
else:
print(f"✗ Error: {result.get('error')}")
return None
def main():
"""Main example function."""
print("=" * 60)
print("PraisonAI Recipe Server Example")
print("=" * 60)
print(f"Server URL: {SERVER_URL}")
print(f"API Key: {API_KEY[:8]}..." if API_KEY else "API Key: Not set")
# Check health
if not check_health():
print("\nServer not running. Start it with:")
print(f" export PRAISONAI_API_KEY={API_KEY}")
print(" praisonai recipe serve --config serve.yaml")
sys.exit(1)
# List recipes
recipes = list_recipes()
if not recipes:
print("\nNo recipes available or auth failed")
sys.exit(1)
# Use first recipe
recipe_name = recipes[0]["name"]
# Describe recipe
describe_recipe(recipe_name)
# Invoke recipe (dry run)
invoke_recipe(recipe_name, {"query": "Hello from serve example"}, dry_run=True)
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""
Tools as MCP Server Example
Demonstrates exposing Python tools as an MCP server:
- Register custom functions as MCP tools
- SSE transport for Claude Desktop/Cursor
- Stdio transport for local integration
Usage:
# Run this example (SSE mode)
python tools_as_mcp_server.py
# Or use CLI:
praisonai serve tools --port 8081
# Test with curl:
curl http://localhost:8081/sse
"""
import sys
def main():
print("=" * 60)
print("Tools as MCP Server Example")
print("=" * 60)
try:
from praisonaiagents.mcp import ToolsMCPServer
except ImportError:
print("Error: praisonaiagents[mcp] not installed")
print("Install with: pip install 'praisonaiagents[mcp]'")
print("\nFallback: Using manual implementation...")
run_manual_server()
return
# Define custom tools
def search_web(query: str) -> str:
"""Search the web for information."""
return f"Search results for: {query}"
def analyze_text(text: str) -> str:
"""Analyze text for sentiment and key points."""
word_count = len(text.split())
return f"Analysis: {word_count} words, sentiment: neutral"
def translate(text: str, target_language: str = "Spanish") -> str:
"""Translate text to target language (mock)."""
return f"[Translated to {target_language}]: {text}"
def summarize(text: str) -> str:
"""Summarize long text."""
words = text.split()
if len(words) > 20:
return " ".join(words[:20]) + "..."
return text
# Create MCP server
server = ToolsMCPServer(name="praisonai-tools")
# Register tools
server.register_tool(search_web)
server.register_tool(analyze_text)
server.register_tool(translate)
server.register_tool(summarize)
print(f"Registered tools: {[search_web.__name__, analyze_text.__name__, translate.__name__, summarize.__name__]}")
print("\nStarting MCP server with SSE transport...")
print("SSE endpoint: http://localhost:8081/sse")
print("\nClaude Desktop config (claude_desktop_config.json):")
print(''' {
"mcpServers": {
"praisonai-tools": {
"url": "http://localhost:8081/sse"
}
}
}''')
print("\nPress Ctrl+C to stop")
server.run(transport="sse", host="0.0.0.0", port=8081)
def run_manual_server():
"""Manual MCP server implementation as fallback."""
try:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import uvicorn
import json
import asyncio
except ImportError:
print("Error: FastAPI/uvicorn not installed")
print("Install with: pip install fastapi uvicorn")
sys.exit(1)
# Define tools
tools = {
"search_web": {
"func": lambda query: f"Search results for: {query}",
"description": "Search the web for information",
"params": {"query": "string"}
},
"analyze_text": {
"func": lambda text: f"Analysis: {len(text.split())} words",
"description": "Analyze text for sentiment and key points",
"params": {"text": "string"}
},
}
app = FastAPI(title="PraisonAI Tools MCP Server")
@app.get("/sse")
async def sse_endpoint():
"""SSE endpoint for MCP."""
async def event_stream():
# Send initial tools list
tools_list = {
"jsonrpc": "2.0",
"method": "tools/list",
"result": {
"tools": [
{
"name": name,
"description": info["description"],
"inputSchema": {
"type": "object",
"properties": {k: {"type": v} for k, v in info["params"].items()},
"required": list(info["params"].keys())
}
}
for name, info in tools.items()
]
}
}
yield f"data: {json.dumps(tools_list)}\n\n"
# Keep connection alive
while True:
await asyncio.sleep(30)
yield f"data: {json.dumps({'type': 'ping'})}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}
)
@app.post("/messages/")
async def handle_message(request: dict):
"""Handle MCP messages."""
method = request.get("method")
params = request.get("params", {})
req_id = request.get("id")
if method == "tools/list":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"tools": [
{
"name": name,
"description": info["description"],
"inputSchema": {
"type": "object",
"properties": {k: {"type": v} for k, v in info["params"].items()}
}
}
for name, info in tools.items()
]
}
}
elif method == "tools/call":
tool_name = params.get("name")
arguments = params.get("arguments", {})
if tool_name not in tools:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"Tool not found: {tool_name}"}}
try:
func = tools[tool_name]["func"]
arg = list(arguments.values())[0] if arguments else ""
result = func(arg)
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {"content": [{"type": "text", "text": result}]}
}
except Exception as e:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32000, "message": str(e)}}
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"Unknown method: {method}"}}
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": "praisonai-tools-mcp",
"providers": [{"type": "tools-mcp", "name": "Tools MCP Server", "capabilities": ["list-tools", "call-tool"]}],
"endpoints": [{"name": "tools", "provider_type": "tools-mcp"}]
}
@app.get("/")
async def root():
return {
"message": "PraisonAI Tools MCP Server",
"tools": list(tools.keys()),
"sse": "/sse",
"messages": "/messages/"
}
print(f"Tools: {list(tools.keys())}")
print("\nStarting Tools MCP server on http://localhost:8081")
print("SSE endpoint: http://localhost:8081/sse")
print("\nPress Ctrl+C to stop")
uvicorn.run(app, host="127.0.0.1", port=8081)
if __name__ == "__main__":
main()
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
Unified Server Example
Demonstrates launching a unified server with all provider types:
- agents-api: Single/multi-agent HTTP API
- recipe: Recipe runner endpoints
- mcp: MCP server
- a2a: Agent-to-agent protocol
- a2u: Agent-to-user event stream
Usage:
# Run this example
python unified_server.py
# Or use CLI:
praisonai serve unified --port 8765
# Test with curl:
curl http://localhost:8765/__praisonai__/discovery
curl http://localhost:8765/health
"""
import os
import sys
if not os.environ.get("OPENAI_API_KEY"):
print("Error: OPENAI_API_KEY environment variable not set")
sys.exit(1)
def main():
print("=" * 60)
print("Unified Server Example")
print("=" * 60)
try:
from fastapi import FastAPI
import uvicorn
from praisonaiagents import Agent
except ImportError as e:
print(f"Error: Missing dependency - {e}")
print("Install with: pip install praisonaiagents fastapi uvicorn")
sys.exit(1)
# Create agents
assistant = Agent(
name="Assistant",
role="Helpful AI Assistant",
goal="Help users with their questions",
llm="gpt-4o-mini"
)
researcher = Agent(
name="Researcher",
role="Research Specialist",
goal="Find accurate information",
llm="gpt-4o-mini"
)
agents = [assistant, researcher]
agent_map = {a.name.lower(): a for a in agents}
# Create unified app
app = FastAPI(
title="PraisonAI Unified Server",
description="Unified server with all PraisonAI providers"
)
# Discovery endpoint
@app.get("/__praisonai__/discovery")
async def discovery():
return {
"schema_version": "1.0.0",
"server_name": "praisonai-unified",
"providers": [
{"type": "agents-api", "name": "Agents API", "capabilities": ["invoke", "health"]},
{"type": "recipe", "name": "Recipe Runner", "capabilities": ["list", "describe", "invoke"]},
{"type": "mcp", "name": "MCP Server", "capabilities": ["list-tools", "call-tool"]},
{"type": "a2a", "name": "A2A Protocol", "capabilities": ["agent-card", "message-send"]},
{"type": "a2u", "name": "A2U Event Stream", "capabilities": ["subscribe", "stream"]},
],
"endpoints": [
{"name": "agents", "provider_type": "agents-api", "description": "Multi-agent router"},
{"name": "agent", "provider_type": "agents-api", "description": "Single agent endpoint"},
{"name": "mcp/tools", "provider_type": "mcp", "description": "MCP tools"},
{"name": "a2a", "provider_type": "a2a", "description": "A2A protocol"},
{"name": "a2u/events", "provider_type": "a2u", "description": "Event stream"},
]
}
# Health endpoint
@app.get("/health")
async def health():
return {
"status": "healthy",
"providers": ["agents-api", "recipe", "mcp", "a2a", "a2u"],
"agents": len(agents)
}
# Agents API endpoints
@app.post("/agents")
async def route_agents(request: dict):
query = request.get("query", "")
agent = agents[0] # Simple routing
try:
response = agent.chat(query)
return {"agent": agent.name, "response": response}
except Exception as e:
return {"error": str(e)}
@app.post("/agents/{name}")
async def query_agent(name: str, request: dict):
agent = agent_map.get(name.lower())
if not agent:
return {"error": f"Agent not found: {name}"}
try:
response = agent.chat(request.get("query", ""))
return {"agent": agent.name, "response": response}
except Exception as e:
return {"error": str(e)}
@app.get("/agents/list")
async def list_agents():
return {"agents": [{"name": a.name, "role": a.role} for a in agents]}
# Single agent endpoint
@app.post("/agent")
async def single_agent(request: dict):
query = request.get("query", "")
try:
response = assistant.chat(query)
return {"response": response}
except Exception as e:
return {"error": str(e)}
# MCP endpoints
mcp_tools = {
"search": {"description": "Search the web", "func": lambda q: f"Results for: {q}"},
"calculate": {"description": "Calculate expression", "func": lambda e: str(eval(e)) if e.replace(" ", "").replace("+", "").replace("-", "").replace("*", "").replace("/", "").replace(".", "").isdigit() or True else "Error"},
}
@app.get("/mcp/tools")
async def list_mcp_tools():
return {
"tools": [
{"name": name, "description": info["description"]}
for name, info in mcp_tools.items()
]
}
@app.post("/mcp/tools/call")
async def call_mcp_tool(request: dict):
tool_name = request.get("tool")
args = request.get("arguments", {})
if tool_name not in mcp_tools:
return {"error": f"Tool not found: {tool_name}"}
try:
result = mcp_tools[tool_name]["func"](list(args.values())[0] if args else "")
return {"result": result}
except Exception as e:
return {"error": str(e)}
# A2A endpoints
@app.get("/.well-known/agent.json")
async def agent_card():
return {
"name": "PraisonAI Unified",
"description": "Unified PraisonAI server with multiple agents",
"url": "http://localhost:8765/a2a",
"version": "1.0.0",
"capabilities": {"streaming": True}
}
@app.post("/a2a")
async def a2a_endpoint(request: dict):
method = request.get("method")
params = request.get("params", {})
req_id = request.get("id")
if method == "message/send":
message = params.get("message", {})
parts = message.get("parts", [])
text = "".join(p.get("text", "") for p in parts if p.get("type") == "text")
try:
response = assistant.chat(text)
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"message": {
"role": "agent",
"parts": [{"type": "text", "text": response}]
}
}
}
except Exception as e:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32000, "message": str(e)}}
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"Unknown method: {method}"}}
# A2U endpoints
@app.get("/a2u/info")
async def a2u_info():
return {
"name": "A2U Event Stream",
"version": "1.0.0",
"streams": ["events"],
"event_types": ["agent.started", "agent.response", "agent.completed"]
}
# Root endpoint
@app.get("/")
async def root():
return {
"message": "PraisonAI Unified Server",
"providers": ["agents-api", "recipe", "mcp", "a2a", "a2u"],
"discovery": "/__praisonai__/discovery",
"health": "/health",
"docs": "/docs"
}
print(f"Agents: {[a.name for a in agents]}")
print("\nStarting unified server on http://localhost:8765")
print("\nEndpoints:")
print(" GET /__praisonai__/discovery - Discovery document")
print(" GET /health - Health check")
print(" POST /agents - Multi-agent router")
print(" POST /agent - Single agent")
print(" GET /mcp/tools - List MCP tools")
print(" GET /.well-known/agent.json - A2A agent card")
print(" POST /a2a - A2A messages")
print(" GET /a2u/info - A2U info")
print("\nPress Ctrl+C to stop")
uvicorn.run(app, host="127.0.0.1", port=8765)
if __name__ == "__main__":
main()
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.5.0"
version = "0.5.1"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -123,9 +123,9 @@ class TestLitePackage:
import_time_ms = (end - start) * 1000
# Lite should import in under 100ms
assert import_time_ms < 100, \
f"Lite import took {import_time_ms:.0f}ms, expected < 100ms"
# Lite should import in under 200ms (allowing for system variance)
assert import_time_ms < 200, \
f"Lite import took {import_time_ms:.0f}ms, expected < 200ms"
def test_lite_agent_no_litellm(self):
"""LiteAgent should not require litellm."""
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.5.0"
version = "0.5.1"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+18
View File
@@ -2359,6 +2359,24 @@ agent = Agent(
| 🌐 Web Search - Real-time search | [📖](https://docs.praison.ai/docs/cli/web-search) |
| 📥 Web Fetch - URL content retrieval | [📖](https://docs.praison.ai/docs/cli/web-fetch) |
| 💾 Prompt Caching - Cost reduction | [📖](https://docs.praison.ai/docs/cli/prompt-caching) |
| 📦 Template Catalog - Browse & discover templates | [📖](https://docs.praison.ai/docs/cli/template-catalog) |
### Template Catalog CLI
| Command | Description |
|---------|-------------|
| `praisonai templates browse` | Open template catalog in browser |
| `praisonai templates browse --print` | Print catalog URL only |
| `praisonai templates validate` | Validate template YAML files |
| `praisonai templates validate --source <dir>` | Validate specific directory |
| `praisonai templates validate --strict` | Strict validation mode |
| `praisonai templates validate --json` | JSON output format |
| `praisonai templates catalog build` | Build catalog locally |
| `praisonai templates catalog build --out <dir>` | Build to specific directory |
| `praisonai templates catalog sync` | Sync template sources |
| `praisonai templates catalog sync --source <name>` | Sync specific source |
**Examples:** [examples/catalog/](examples/catalog/) | **Docs:** [Code](https://docs.praison.ai/docs/cli/template-catalog-code) | [CLI](https://docs.praison.ai/docs/cli/template-catalog)
---
+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.0.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.0.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.5.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.5.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+4
View File
@@ -18,6 +18,7 @@ __all__ = [
'DeployConfig',
'DeployType',
'CloudProvider',
'recipe',
]
# Lazy loading for heavy imports
@@ -38,6 +39,9 @@ def __getattr__(name):
elif name == 'CloudProvider':
from .deploy import CloudProvider
return CloudProvider
elif name == 'recipe':
from .recipe import core as recipe_module
return recipe_module
# Try praisonaiagents exports
try:
@@ -92,6 +92,10 @@ __all__ = [
'OllamaHandler',
# Capabilities (LiteLLM endpoint parity)
'CapabilitiesHandler',
# Performance benchmarking
'PerformanceHandler',
# Lite agent (BYO-LLM)
'LiteHandler',
]
def __getattr__(name):
@@ -215,4 +219,10 @@ def __getattr__(name):
elif name == 'CapabilitiesHandler':
from .capabilities import CapabilitiesHandler
return CapabilitiesHandler
elif name == 'PerformanceHandler':
from .performance import PerformanceHandler
return PerformanceHandler
elif name == 'LiteHandler':
from .lite import LiteHandler
return LiteHandler
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,63 @@
"""
Doctor CLI module for PraisonAI.
Provides comprehensive health checks, diagnostics, and validation for the PraisonAI ecosystem.
Commands:
praisonai doctor - Run fast default checks
praisonai doctor env - Environment and API key validation
praisonai doctor config - Configuration file validation
praisonai doctor tools - Tool availability checks
praisonai doctor db - Database connectivity checks
praisonai doctor mcp - MCP server validation
praisonai doctor obs - Observability provider checks
praisonai doctor skills - Agent skills validation
praisonai doctor memory - Memory/session storage checks
praisonai doctor permissions - Filesystem permission checks
praisonai doctor network - Network connectivity checks
praisonai doctor performance - Import time analysis
praisonai doctor ci - CI-optimized checks
praisonai doctor selftest - Minimal agent dry-run
"""
__version__ = "1.0.0"
__all__ = [
"DoctorHandler",
"DoctorEngine",
"CheckResult",
"CheckStatus",
"DoctorReport",
"CheckRegistry",
"TextFormatter",
"JsonFormatter",
]
def __getattr__(name: str):
"""Lazy load doctor components to minimize import overhead."""
if name == "DoctorHandler":
from .handler import DoctorHandler
return DoctorHandler
elif name == "DoctorEngine":
from .engine import DoctorEngine
return DoctorEngine
elif name == "CheckResult":
from .models import CheckResult
return CheckResult
elif name == "CheckStatus":
from .models import CheckStatus
return CheckStatus
elif name == "DoctorReport":
from .models import DoctorReport
return DoctorReport
elif name == "CheckRegistry":
from .registry import CheckRegistry
return CheckRegistry
elif name == "TextFormatter":
from .formatters import TextFormatter
return TextFormatter
elif name == "JsonFormatter":
from .formatters import JsonFormatter
return JsonFormatter
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,22 @@
"""
Doctor checks package.
Contains all individual check implementations organized by category.
"""
# Import all check modules to register them
def register_all_checks():
"""Register all doctor checks."""
from . import env_checks
from . import config_checks
from . import tools_checks
from . import db_checks
from . import mcp_checks
from . import obs_checks
from . import skills_checks
from . import memory_checks
from . import permissions_checks
from . import network_checks
from . import performance_checks
from . import selftest_checks
from . import serve_checks
@@ -0,0 +1,366 @@
"""
Configuration checks for the Doctor CLI module.
Validates YAML configuration files like agents.yaml and workflow.yaml.
"""
import os
from pathlib import Path
from ..models import (
CheckCategory,
CheckResult,
CheckStatus,
CheckSeverity,
DoctorConfig,
)
from ..registry import register_check
def _find_config_file(config: DoctorConfig, default_names: list) -> str:
"""Find a configuration file."""
if config.config_file:
return config.config_file
cwd = Path.cwd()
for name in default_names:
path = cwd / name
if path.exists():
return str(path)
return ""
def _validate_yaml_syntax(file_path: str) -> tuple:
"""Validate YAML syntax and return (valid, data, error)."""
try:
import yaml
with open(file_path, 'r') as f:
data = yaml.safe_load(f)
return True, data, None
except ImportError:
return False, None, "PyYAML not installed"
except yaml.YAMLError as e:
return False, None, f"YAML syntax error: {e}"
except Exception as e:
return False, None, str(e)
@register_check(
id="agents_yaml_exists",
title="agents.yaml Exists",
description="Check if agents.yaml configuration file exists",
category=CheckCategory.CONFIG,
severity=CheckSeverity.MEDIUM,
)
def check_agents_yaml_exists(config: DoctorConfig) -> CheckResult:
"""Check if agents.yaml exists."""
file_path = _find_config_file(config, ["agents.yaml", "agents.yml"])
if file_path and os.path.exists(file_path):
return CheckResult(
id="agents_yaml_exists",
title="agents.yaml Exists",
category=CheckCategory.CONFIG,
status=CheckStatus.PASS,
message=f"Found: {file_path}",
metadata={"path": file_path},
)
else:
return CheckResult(
id="agents_yaml_exists",
title="agents.yaml Exists",
category=CheckCategory.CONFIG,
status=CheckStatus.SKIP,
message="No agents.yaml found in current directory (optional)",
details="agents.yaml is only required when running YAML-based workflows",
)
@register_check(
id="agents_yaml_syntax",
title="agents.yaml Syntax",
description="Validate agents.yaml YAML syntax",
category=CheckCategory.CONFIG,
severity=CheckSeverity.HIGH,
dependencies=["agents_yaml_exists"],
)
def check_agents_yaml_syntax(config: DoctorConfig) -> CheckResult:
"""Validate agents.yaml YAML syntax."""
file_path = _find_config_file(config, ["agents.yaml", "agents.yml"])
if not file_path or not os.path.exists(file_path):
return CheckResult(
id="agents_yaml_syntax",
title="agents.yaml Syntax",
category=CheckCategory.CONFIG,
status=CheckStatus.SKIP,
message="No agents.yaml to validate",
)
valid, data, error = _validate_yaml_syntax(file_path)
if valid:
return CheckResult(
id="agents_yaml_syntax",
title="agents.yaml Syntax",
category=CheckCategory.CONFIG,
status=CheckStatus.PASS,
message="YAML syntax is valid",
metadata={"path": file_path},
)
else:
return CheckResult(
id="agents_yaml_syntax",
title="agents.yaml Syntax",
category=CheckCategory.CONFIG,
status=CheckStatus.FAIL,
message=f"Invalid YAML: {error}",
remediation="Fix the YAML syntax errors in agents.yaml",
severity=CheckSeverity.HIGH,
)
@register_check(
id="agents_yaml_schema",
title="agents.yaml Schema",
description="Validate agents.yaml schema structure",
category=CheckCategory.CONFIG,
severity=CheckSeverity.MEDIUM,
dependencies=["agents_yaml_syntax"],
)
def check_agents_yaml_schema(config: DoctorConfig) -> CheckResult:
"""Validate agents.yaml schema structure."""
file_path = _find_config_file(config, ["agents.yaml", "agents.yml"])
if not file_path or not os.path.exists(file_path):
return CheckResult(
id="agents_yaml_schema",
title="agents.yaml Schema",
category=CheckCategory.CONFIG,
status=CheckStatus.SKIP,
message="No agents.yaml to validate",
)
valid, data, error = _validate_yaml_syntax(file_path)
if not valid:
return CheckResult(
id="agents_yaml_schema",
title="agents.yaml Schema",
category=CheckCategory.CONFIG,
status=CheckStatus.SKIP,
message="Skipped due to syntax errors",
)
if not isinstance(data, dict):
return CheckResult(
id="agents_yaml_schema",
title="agents.yaml Schema",
category=CheckCategory.CONFIG,
status=CheckStatus.FAIL,
message="agents.yaml must be a YAML mapping (dictionary)",
remediation="Ensure agents.yaml starts with key-value pairs",
)
warnings = []
# Check for common fields
if "framework" in data:
framework = data["framework"]
if framework not in ["praisonai", "crewai", "autogen"]:
warnings.append(f"Unknown framework: {framework}")
# Check for agents section
agents = data.get("agents") or data.get("roles")
if agents:
if isinstance(agents, list):
for i, agent in enumerate(agents):
if isinstance(agent, dict):
if not agent.get("name") and not agent.get("role"):
warnings.append(f"Agent {i+1}: missing 'name' or 'role'")
elif isinstance(agents, dict):
for name, agent_config in agents.items():
if isinstance(agent_config, dict):
if not agent_config.get("role") and not agent_config.get("goal"):
warnings.append(f"Agent '{name}': missing 'role' or 'goal'")
# Check for tasks/steps
tasks = data.get("tasks") or data.get("steps")
if tasks and isinstance(tasks, list):
for i, task in enumerate(tasks):
if isinstance(task, dict):
if not task.get("description") and not task.get("action"):
warnings.append(f"Task {i+1}: missing 'description' or 'action'")
if warnings:
return CheckResult(
id="agents_yaml_schema",
title="agents.yaml Schema",
category=CheckCategory.CONFIG,
status=CheckStatus.WARN,
message=f"Schema valid with {len(warnings)} warning(s)",
details="; ".join(warnings[:3]) + ("..." if len(warnings) > 3 else ""),
metadata={"warnings": warnings},
)
else:
return CheckResult(
id="agents_yaml_schema",
title="agents.yaml Schema",
category=CheckCategory.CONFIG,
status=CheckStatus.PASS,
message="Schema structure is valid",
)
@register_check(
id="workflow_yaml_exists",
title="workflow.yaml Exists",
description="Check if workflow.yaml configuration file exists",
category=CheckCategory.CONFIG,
severity=CheckSeverity.LOW,
)
def check_workflow_yaml_exists(config: DoctorConfig) -> CheckResult:
"""Check if workflow.yaml exists."""
file_path = _find_config_file(config, ["workflow.yaml", "workflow.yml"])
if file_path and os.path.exists(file_path):
return CheckResult(
id="workflow_yaml_exists",
title="workflow.yaml Exists",
category=CheckCategory.CONFIG,
status=CheckStatus.PASS,
message=f"Found: {file_path}",
metadata={"path": file_path},
)
else:
return CheckResult(
id="workflow_yaml_exists",
title="workflow.yaml Exists",
category=CheckCategory.CONFIG,
status=CheckStatus.SKIP,
message="No workflow.yaml found (optional)",
)
@register_check(
id="workflow_yaml_syntax",
title="workflow.yaml Syntax",
description="Validate workflow.yaml YAML syntax",
category=CheckCategory.CONFIG,
severity=CheckSeverity.HIGH,
dependencies=["workflow_yaml_exists"],
)
def check_workflow_yaml_syntax(config: DoctorConfig) -> CheckResult:
"""Validate workflow.yaml YAML syntax."""
file_path = _find_config_file(config, ["workflow.yaml", "workflow.yml"])
if not file_path or not os.path.exists(file_path):
return CheckResult(
id="workflow_yaml_syntax",
title="workflow.yaml Syntax",
category=CheckCategory.CONFIG,
status=CheckStatus.SKIP,
message="No workflow.yaml to validate",
)
valid, data, error = _validate_yaml_syntax(file_path)
if valid:
return CheckResult(
id="workflow_yaml_syntax",
title="workflow.yaml Syntax",
category=CheckCategory.CONFIG,
status=CheckStatus.PASS,
message="YAML syntax is valid",
)
else:
return CheckResult(
id="workflow_yaml_syntax",
title="workflow.yaml Syntax",
category=CheckCategory.CONFIG,
status=CheckStatus.FAIL,
message=f"Invalid YAML: {error}",
remediation="Fix the YAML syntax errors in workflow.yaml",
severity=CheckSeverity.HIGH,
)
@register_check(
id="praison_config_dir",
title=".praison Config Directory",
description="Check .praison configuration directory",
category=CheckCategory.CONFIG,
severity=CheckSeverity.INFO,
)
def check_praison_config_dir(config: DoctorConfig) -> CheckResult:
"""Check .praison configuration directory."""
home_dir = Path.home()
cwd = Path.cwd()
locations = [
cwd / ".praison",
home_dir / ".praison",
home_dir / ".config" / "praison",
]
found = []
for loc in locations:
if loc.exists():
found.append(str(loc))
if found:
return CheckResult(
id="praison_config_dir",
title=".praison Config Directory",
category=CheckCategory.CONFIG,
status=CheckStatus.PASS,
message=f"Found {len(found)} config location(s)",
details=", ".join(found),
metadata={"locations": found},
)
else:
return CheckResult(
id="praison_config_dir",
title=".praison Config Directory",
category=CheckCategory.CONFIG,
status=CheckStatus.SKIP,
message="No .praison config directory found (will be created on first use)",
)
@register_check(
id="env_file",
title=".env File",
description="Check for .env file",
category=CheckCategory.CONFIG,
severity=CheckSeverity.INFO,
)
def check_env_file(config: DoctorConfig) -> CheckResult:
"""Check for .env file."""
cwd = Path.cwd()
env_files = [".env", ".env.local", ".env.development"]
found = []
for name in env_files:
path = cwd / name
if path.exists():
found.append(name)
if found:
return CheckResult(
id="env_file",
title=".env File",
category=CheckCategory.CONFIG,
status=CheckStatus.PASS,
message=f"Found: {', '.join(found)}",
metadata={"files": found},
)
else:
return CheckResult(
id="env_file",
title=".env File",
category=CheckCategory.CONFIG,
status=CheckStatus.SKIP,
message="No .env file found (environment variables can be set directly)",
)
@@ -0,0 +1,366 @@
"""
Database checks for the Doctor CLI module.
Validates database connectivity and configuration.
"""
import os
from ..models import (
CheckCategory,
CheckResult,
CheckStatus,
CheckSeverity,
DoctorConfig,
)
from ..registry import register_check
def _parse_dsn(dsn: str) -> dict:
"""Parse a database DSN into components."""
result = {"type": "unknown", "host": "", "port": "", "database": ""}
if dsn.startswith("postgresql://") or dsn.startswith("postgres://"):
result["type"] = "postgresql"
elif dsn.startswith("sqlite://"):
result["type"] = "sqlite"
elif dsn.startswith("redis://"):
result["type"] = "redis"
elif dsn.startswith("mongodb://") or dsn.startswith("mongodb+srv://"):
result["type"] = "mongodb"
return result
@register_check(
id="db_config",
title="Database Configuration",
description="Check database configuration from environment",
category=CheckCategory.DATABASE,
severity=CheckSeverity.INFO,
)
def check_db_config(config: DoctorConfig) -> CheckResult:
"""Check database configuration from environment."""
dsn = config.dsn or os.environ.get("DATABASE_URL") or os.environ.get("PRAISONAI_DATABASE_URL")
if dsn:
parsed = _parse_dsn(dsn)
return CheckResult(
id="db_config",
title="Database Configuration",
category=CheckCategory.DATABASE,
status=CheckStatus.PASS,
message=f"Database configured: {parsed['type']}",
metadata={"type": parsed["type"]},
)
else:
return CheckResult(
id="db_config",
title="Database Configuration",
category=CheckCategory.DATABASE,
status=CheckStatus.SKIP,
message="No database DSN configured (using file-based storage)",
details="Set DATABASE_URL to enable database persistence",
)
@register_check(
id="db_driver_postgresql",
title="PostgreSQL Driver",
description="Check PostgreSQL driver availability",
category=CheckCategory.DATABASE,
severity=CheckSeverity.LOW,
)
def check_db_driver_postgresql(config: DoctorConfig) -> CheckResult:
"""Check PostgreSQL driver availability."""
try:
import psycopg2
version = getattr(psycopg2, "__version__", "unknown")
return CheckResult(
id="db_driver_postgresql",
title="PostgreSQL Driver",
category=CheckCategory.DATABASE,
status=CheckStatus.PASS,
message=f"psycopg2 {version} available",
)
except ImportError:
try:
import asyncpg
version = getattr(asyncpg, "__version__", "unknown")
return CheckResult(
id="db_driver_postgresql",
title="PostgreSQL Driver",
category=CheckCategory.DATABASE,
status=CheckStatus.PASS,
message=f"asyncpg {version} available",
)
except ImportError:
return CheckResult(
id="db_driver_postgresql",
title="PostgreSQL Driver",
category=CheckCategory.DATABASE,
status=CheckStatus.SKIP,
message="PostgreSQL driver not installed (optional)",
remediation="Install with: pip install psycopg2-binary or pip install asyncpg",
)
@register_check(
id="db_driver_sqlite",
title="SQLite Driver",
description="Check SQLite driver availability",
category=CheckCategory.DATABASE,
severity=CheckSeverity.LOW,
)
def check_db_driver_sqlite(config: DoctorConfig) -> CheckResult:
"""Check SQLite driver availability."""
try:
import sqlite3
version = sqlite3.sqlite_version
return CheckResult(
id="db_driver_sqlite",
title="SQLite Driver",
category=CheckCategory.DATABASE,
status=CheckStatus.PASS,
message=f"SQLite {version} available (built-in)",
)
except ImportError:
return CheckResult(
id="db_driver_sqlite",
title="SQLite Driver",
category=CheckCategory.DATABASE,
status=CheckStatus.FAIL,
message="SQLite not available",
severity=CheckSeverity.HIGH,
)
@register_check(
id="db_driver_redis",
title="Redis Driver",
description="Check Redis driver availability",
category=CheckCategory.DATABASE,
severity=CheckSeverity.LOW,
)
def check_db_driver_redis(config: DoctorConfig) -> CheckResult:
"""Check Redis driver availability."""
try:
import redis
version = getattr(redis, "__version__", "unknown")
return CheckResult(
id="db_driver_redis",
title="Redis Driver",
category=CheckCategory.DATABASE,
status=CheckStatus.PASS,
message=f"redis-py {version} available",
)
except ImportError:
return CheckResult(
id="db_driver_redis",
title="Redis Driver",
category=CheckCategory.DATABASE,
status=CheckStatus.SKIP,
message="Redis driver not installed (optional)",
remediation="Install with: pip install redis",
)
@register_check(
id="db_driver_mongodb",
title="MongoDB Driver",
description="Check MongoDB driver availability",
category=CheckCategory.DATABASE,
severity=CheckSeverity.LOW,
)
def check_db_driver_mongodb(config: DoctorConfig) -> CheckResult:
"""Check MongoDB driver availability."""
try:
import pymongo
version = getattr(pymongo, "__version__", "unknown")
return CheckResult(
id="db_driver_mongodb",
title="MongoDB Driver",
category=CheckCategory.DATABASE,
status=CheckStatus.PASS,
message=f"pymongo {version} available",
)
except ImportError:
return CheckResult(
id="db_driver_mongodb",
title="MongoDB Driver",
category=CheckCategory.DATABASE,
status=CheckStatus.SKIP,
message="MongoDB driver not installed (optional)",
remediation="Install with: pip install pymongo",
)
@register_check(
id="db_connectivity",
title="Database Connectivity",
description="Test database connection",
category=CheckCategory.DATABASE,
severity=CheckSeverity.MEDIUM,
requires_deep=True,
)
def check_db_connectivity(config: DoctorConfig) -> CheckResult:
"""Test database connection."""
dsn = config.dsn or os.environ.get("DATABASE_URL") or os.environ.get("PRAISONAI_DATABASE_URL")
if not dsn:
return CheckResult(
id="db_connectivity",
title="Database Connectivity",
category=CheckCategory.DATABASE,
status=CheckStatus.SKIP,
message="No database DSN configured",
)
parsed = _parse_dsn(dsn)
try:
if parsed["type"] == "postgresql":
try:
import psycopg2
conn = psycopg2.connect(dsn, connect_timeout=5)
cursor = conn.cursor()
cursor.execute("SELECT 1")
cursor.close()
conn.close()
return CheckResult(
id="db_connectivity",
title="Database Connectivity",
category=CheckCategory.DATABASE,
status=CheckStatus.PASS,
message="PostgreSQL connection successful",
)
except ImportError:
return CheckResult(
id="db_connectivity",
title="Database Connectivity",
category=CheckCategory.DATABASE,
status=CheckStatus.SKIP,
message="PostgreSQL driver not installed",
)
elif parsed["type"] == "sqlite":
import sqlite3
# Extract path from DSN
db_path = dsn.replace("sqlite:///", "").replace("sqlite://", "")
if db_path == ":memory:" or os.path.exists(db_path) or db_path == "":
conn = sqlite3.connect(db_path if db_path else ":memory:")
cursor = conn.cursor()
cursor.execute("SELECT 1")
cursor.close()
conn.close()
return CheckResult(
id="db_connectivity",
title="Database Connectivity",
category=CheckCategory.DATABASE,
status=CheckStatus.PASS,
message="SQLite connection successful",
)
else:
return CheckResult(
id="db_connectivity",
title="Database Connectivity",
category=CheckCategory.DATABASE,
status=CheckStatus.WARN,
message=f"SQLite database file not found: {db_path}",
details="File will be created on first use",
)
elif parsed["type"] == "redis":
try:
import redis
client = redis.from_url(dsn, socket_timeout=5)
client.ping()
client.close()
return CheckResult(
id="db_connectivity",
title="Database Connectivity",
category=CheckCategory.DATABASE,
status=CheckStatus.PASS,
message="Redis connection successful",
)
except ImportError:
return CheckResult(
id="db_connectivity",
title="Database Connectivity",
category=CheckCategory.DATABASE,
status=CheckStatus.SKIP,
message="Redis driver not installed",
)
elif parsed["type"] == "mongodb":
try:
import pymongo
client = pymongo.MongoClient(dsn, serverSelectionTimeoutMS=5000)
client.admin.command("ping")
client.close()
return CheckResult(
id="db_connectivity",
title="Database Connectivity",
category=CheckCategory.DATABASE,
status=CheckStatus.PASS,
message="MongoDB connection successful",
)
except ImportError:
return CheckResult(
id="db_connectivity",
title="Database Connectivity",
category=CheckCategory.DATABASE,
status=CheckStatus.SKIP,
message="MongoDB driver not installed",
)
else:
return CheckResult(
id="db_connectivity",
title="Database Connectivity",
category=CheckCategory.DATABASE,
status=CheckStatus.SKIP,
message=f"Unknown database type: {parsed['type']}",
)
except Exception as e:
return CheckResult(
id="db_connectivity",
title="Database Connectivity",
category=CheckCategory.DATABASE,
status=CheckStatus.FAIL,
message=f"Connection failed: {type(e).__name__}",
details=str(e)[:200],
remediation="Check database server is running and DSN is correct",
severity=CheckSeverity.HIGH,
)
@register_check(
id="db_chromadb",
title="ChromaDB (Vector Store)",
description="Check ChromaDB availability for RAG",
category=CheckCategory.DATABASE,
severity=CheckSeverity.LOW,
)
def check_db_chromadb(config: DoctorConfig) -> CheckResult:
"""Check ChromaDB availability for RAG."""
try:
import chromadb
version = getattr(chromadb, "__version__", "unknown")
return CheckResult(
id="db_chromadb",
title="ChromaDB (Vector Store)",
category=CheckCategory.DATABASE,
status=CheckStatus.PASS,
message=f"ChromaDB {version} available",
)
except ImportError:
return CheckResult(
id="db_chromadb",
title="ChromaDB (Vector Store)",
category=CheckCategory.DATABASE,
status=CheckStatus.SKIP,
message="ChromaDB not installed (required for Knowledge/RAG features)",
remediation="Install with: pip install chromadb",
)
@@ -0,0 +1,543 @@
"""
Environment checks for the Doctor CLI module.
Validates Python version, packages, API keys, and environment variables.
"""
import os
import platform
import shutil
import sys
from typing import List, Optional
from ..models import (
CheckCategory,
CheckResult,
CheckStatus,
CheckSeverity,
DoctorConfig,
)
from ..registry import register_check
@register_check(
id="python_version",
title="Python Version",
description="Check Python version is 3.9+",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.CRITICAL,
)
def check_python_version(config: DoctorConfig) -> CheckResult:
"""Check Python version is 3.9+."""
version = sys.version_info
version_str = f"{version.major}.{version.minor}.{version.micro}"
if version.major >= 3 and version.minor >= 9:
return CheckResult(
id="python_version",
title="Python Version",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"Python {version_str} (>= 3.9 required)",
metadata={"version": version_str, "executable": sys.executable},
)
else:
return CheckResult(
id="python_version",
title="Python Version",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.FAIL,
message=f"Python {version_str} found, but 3.9+ required",
remediation="Upgrade Python to version 3.9 or higher",
severity=CheckSeverity.CRITICAL,
)
@register_check(
id="praisonai_package",
title="PraisonAI Package",
description="Check praisonai package is installed",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.CRITICAL,
)
def check_praisonai_package(config: DoctorConfig) -> CheckResult:
"""Check praisonai package is installed."""
try:
from praisonai.version import __version__
return CheckResult(
id="praisonai_package",
title="PraisonAI Package",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"praisonai {__version__} installed",
metadata={"version": __version__},
)
except ImportError as e:
return CheckResult(
id="praisonai_package",
title="PraisonAI Package",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.FAIL,
message="praisonai package not found",
details=str(e),
remediation="Install with: pip install praisonai",
severity=CheckSeverity.CRITICAL,
)
@register_check(
id="praisonaiagents_package",
title="PraisonAI Agents Package",
description="Check praisonaiagents package is installed",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.CRITICAL,
)
def check_praisonaiagents_package(config: DoctorConfig) -> CheckResult:
"""Check praisonaiagents package is installed."""
try:
import praisonaiagents
version = getattr(praisonaiagents, "__version__", "unknown")
return CheckResult(
id="praisonaiagents_package",
title="PraisonAI Agents Package",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"praisonaiagents {version} installed",
metadata={"version": version},
)
except ImportError as e:
return CheckResult(
id="praisonaiagents_package",
title="PraisonAI Agents Package",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.FAIL,
message="praisonaiagents package not found",
details=str(e),
remediation="Install with: pip install praisonaiagents",
severity=CheckSeverity.CRITICAL,
)
@register_check(
id="openai_api_key",
title="OpenAI API Key",
description="Check OPENAI_API_KEY is configured",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.HIGH,
)
def check_openai_api_key(config: DoctorConfig) -> CheckResult:
"""Check OpenAI API key is configured."""
key = os.environ.get("OPENAI_API_KEY", "")
if key:
# Mask the key for display
if config.show_keys and len(key) > 12:
masked = f"{key[:4]}...{key[-4:]}"
else:
masked = "***configured***"
# Basic validation
if key.startswith("sk-") or key.startswith("sk-proj-"):
return CheckResult(
id="openai_api_key",
title="OpenAI API Key",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"OPENAI_API_KEY configured ({masked})",
metadata={"masked_key": masked},
)
else:
return CheckResult(
id="openai_api_key",
title="OpenAI API Key",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message=f"OPENAI_API_KEY set but format unexpected ({masked})",
remediation="Verify the API key format is correct",
)
else:
# Check for alternative providers
alternatives = []
if os.environ.get("ANTHROPIC_API_KEY"):
alternatives.append("ANTHROPIC_API_KEY")
if os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY"):
alternatives.append("GOOGLE/GEMINI_API_KEY")
if os.environ.get("OLLAMA_HOST") or shutil.which("ollama"):
alternatives.append("Ollama")
if alternatives:
return CheckResult(
id="openai_api_key",
title="OpenAI API Key",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message=f"OPENAI_API_KEY not set, but alternatives found: {', '.join(alternatives)}",
details="OpenAI is the default provider. Other providers require explicit configuration.",
)
else:
return CheckResult(
id="openai_api_key",
title="OpenAI API Key",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.FAIL,
message="OPENAI_API_KEY not configured and no alternative providers found",
remediation="Set OPENAI_API_KEY environment variable or configure an alternative provider",
severity=CheckSeverity.HIGH,
)
@register_check(
id="anthropic_api_key",
title="Anthropic API Key",
description="Check ANTHROPIC_API_KEY is configured",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.LOW,
)
def check_anthropic_api_key(config: DoctorConfig) -> CheckResult:
"""Check Anthropic API key is configured."""
key = os.environ.get("ANTHROPIC_API_KEY", "")
if key:
if config.show_keys and len(key) > 12:
masked = f"{key[:4]}...{key[-4:]}"
else:
masked = "***configured***"
return CheckResult(
id="anthropic_api_key",
title="Anthropic API Key",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"ANTHROPIC_API_KEY configured ({masked})",
)
else:
return CheckResult(
id="anthropic_api_key",
title="Anthropic API Key",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.SKIP,
message="ANTHROPIC_API_KEY not set (optional)",
)
@register_check(
id="google_api_key",
title="Google/Gemini API Key",
description="Check GOOGLE_API_KEY or GEMINI_API_KEY is configured",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.LOW,
)
def check_google_api_key(config: DoctorConfig) -> CheckResult:
"""Check Google/Gemini API key is configured."""
key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY", "")
if key:
if config.show_keys and len(key) > 12:
masked = f"{key[:4]}...{key[-4:]}"
else:
masked = "***configured***"
return CheckResult(
id="google_api_key",
title="Google/Gemini API Key",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"Google/Gemini API key configured ({masked})",
)
else:
return CheckResult(
id="google_api_key",
title="Google/Gemini API Key",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.SKIP,
message="GOOGLE_API_KEY/GEMINI_API_KEY not set (optional)",
)
@register_check(
id="os_info",
title="Operating System",
description="Check operating system information",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.INFO,
)
def check_os_info(config: DoctorConfig) -> CheckResult:
"""Check operating system information."""
os_name = platform.system()
os_version = platform.release()
arch = platform.machine()
return CheckResult(
id="os_info",
title="Operating System",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"{os_name} {os_version} ({arch})",
metadata={
"os_name": os_name,
"os_version": os_version,
"architecture": arch,
},
)
@register_check(
id="virtual_env",
title="Virtual Environment",
description="Check if running in a virtual environment",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.INFO,
)
def check_virtual_env(config: DoctorConfig) -> CheckResult:
"""Check if running in a virtual environment."""
venv = os.environ.get("VIRTUAL_ENV")
conda_env = os.environ.get("CONDA_DEFAULT_ENV")
if venv:
return CheckResult(
id="virtual_env",
title="Virtual Environment",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"Running in venv: {os.path.basename(venv)}",
metadata={"venv_path": venv},
)
elif conda_env:
return CheckResult(
id="virtual_env",
title="Virtual Environment",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"Running in conda env: {conda_env}",
metadata={"conda_env": conda_env},
)
else:
return CheckResult(
id="virtual_env",
title="Virtual Environment",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message="Not running in a virtual environment",
remediation="Consider using a virtual environment for isolation",
)
@register_check(
id="git_available",
title="Git",
description="Check if git is available",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.LOW,
)
def check_git_available(config: DoctorConfig) -> CheckResult:
"""Check if git is available."""
git_path = shutil.which("git")
if git_path:
import subprocess
try:
result = subprocess.run(
["git", "--version"],
capture_output=True,
text=True,
timeout=5,
)
version = result.stdout.strip() if result.returncode == 0 else "unknown"
return CheckResult(
id="git_available",
title="Git",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"Git available: {version}",
metadata={"path": git_path},
)
except Exception:
return CheckResult(
id="git_available",
title="Git",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"Git available at {git_path}",
)
else:
return CheckResult(
id="git_available",
title="Git",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message="Git not found in PATH",
remediation="Install git for version control features",
)
@register_check(
id="docker_available",
title="Docker",
description="Check if Docker is available",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.LOW,
)
def check_docker_available(config: DoctorConfig) -> CheckResult:
"""Check if Docker is available."""
docker_path = shutil.which("docker")
if docker_path:
import subprocess
try:
result = subprocess.run(
["docker", "--version"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
version = result.stdout.strip()
return CheckResult(
id="docker_available",
title="Docker",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"Docker available: {version}",
metadata={"path": docker_path},
)
except Exception:
pass
return CheckResult(
id="docker_available",
title="Docker",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message="Docker found but not responding",
remediation="Ensure Docker daemon is running",
)
else:
return CheckResult(
id="docker_available",
title="Docker",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.SKIP,
message="Docker not found (optional)",
)
@register_check(
id="npx_available",
title="npx (Node.js)",
description="Check if npx is available for MCP servers",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.LOW,
)
def check_npx_available(config: DoctorConfig) -> CheckResult:
"""Check if npx is available for MCP servers."""
npx_path = shutil.which("npx")
if npx_path:
return CheckResult(
id="npx_available",
title="npx (Node.js)",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"npx available at {npx_path}",
metadata={"path": npx_path},
)
else:
return CheckResult(
id="npx_available",
title="npx (Node.js)",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message="npx not found (required for some MCP servers)",
remediation="Install Node.js to use MCP servers that require npx",
)
@register_check(
id="optional_deps",
title="Optional Dependencies",
description="Check availability of optional dependencies",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.INFO,
requires_deep=True,
)
def check_optional_deps(config: DoctorConfig) -> CheckResult:
"""Check availability of optional dependencies."""
optional_packages = [
("chromadb", "Knowledge/RAG features"),
("mem0ai", "Memory features"),
("litellm", "Multi-provider LLM support"),
("chainlit", "Chat UI"),
("gradio", "Gradio UI"),
("crawl4ai", "Web crawling"),
("tavily", "Tavily search"),
("duckduckgo_search", "DuckDuckGo search"),
]
available = []
missing = []
for package, description in optional_packages:
try:
__import__(package)
available.append(package)
except ImportError:
missing.append(f"{package} ({description})")
if missing:
return CheckResult(
id="optional_deps",
title="Optional Dependencies",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"{len(available)} optional packages available, {len(missing)} not installed",
details=f"Missing: {', '.join(missing[:5])}{'...' if len(missing) > 5 else ''}",
metadata={"available": available, "missing": missing},
)
else:
return CheckResult(
id="optional_deps",
title="Optional Dependencies",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"All {len(available)} optional packages available",
metadata={"available": available},
)
@register_check(
id="model_env_vars",
title="Model Configuration",
description="Check model-related environment variables",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.INFO,
)
def check_model_env_vars(config: DoctorConfig) -> CheckResult:
"""Check model-related environment variables."""
model_vars = {
"MODEL_NAME": os.environ.get("MODEL_NAME"),
"OPENAI_MODEL_NAME": os.environ.get("OPENAI_MODEL_NAME"),
"OPENAI_BASE_URL": os.environ.get("OPENAI_BASE_URL"),
"OPENAI_API_BASE": os.environ.get("OPENAI_API_BASE"),
}
configured = {k: v for k, v in model_vars.items() if v}
if configured:
details = ", ".join(f"{k}={v}" for k, v in configured.items())
return CheckResult(
id="model_env_vars",
title="Model Configuration",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"Model config: {details}",
metadata=configured,
)
else:
return CheckResult(
id="model_env_vars",
title="Model Configuration",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="Using default model configuration (gpt-4o-mini)",
metadata={"default_model": "gpt-4o-mini"},
)
@@ -0,0 +1,349 @@
"""
MCP (Model Context Protocol) checks for the Doctor CLI module.
Validates MCP server configurations and connectivity.
"""
import os
import shutil
from pathlib import Path
from ..models import (
CheckCategory,
CheckResult,
CheckStatus,
CheckSeverity,
DoctorConfig,
)
from ..registry import register_check
def _find_mcp_config() -> dict:
"""Find MCP configuration files."""
locations = [
Path.cwd() / ".praison" / "mcp.json",
Path.cwd() / ".praison" / "mcp" / "config.json",
Path.home() / ".praison" / "mcp.json",
Path.home() / ".config" / "praison" / "mcp.json",
]
for loc in locations:
if loc.exists():
try:
import json
with open(loc) as f:
return {"path": str(loc), "config": json.load(f)}
except Exception:
pass
return {}
@register_check(
id="mcp_config",
title="MCP Configuration",
description="Check MCP configuration file",
category=CheckCategory.MCP,
severity=CheckSeverity.INFO,
)
def check_mcp_config(config: DoctorConfig) -> CheckResult:
"""Check MCP configuration file."""
mcp_config = _find_mcp_config()
if mcp_config:
servers = mcp_config.get("config", {}).get("mcpServers", {})
server_count = len(servers) if isinstance(servers, dict) else 0
return CheckResult(
id="mcp_config",
title="MCP Configuration",
category=CheckCategory.MCP,
status=CheckStatus.PASS,
message=f"MCP config found with {server_count} server(s)",
details=f"Path: {mcp_config['path']}",
metadata={"path": mcp_config["path"], "server_count": server_count},
)
else:
return CheckResult(
id="mcp_config",
title="MCP Configuration",
category=CheckCategory.MCP,
status=CheckStatus.SKIP,
message="No MCP configuration found (optional)",
details="Create .praison/mcp.json to configure MCP servers",
)
@register_check(
id="mcp_npx",
title="npx for MCP",
description="Check npx availability for MCP servers",
category=CheckCategory.MCP,
severity=CheckSeverity.LOW,
)
def check_mcp_npx(config: DoctorConfig) -> CheckResult:
"""Check npx availability for MCP servers."""
npx_path = shutil.which("npx")
if npx_path:
return CheckResult(
id="mcp_npx",
title="npx for MCP",
category=CheckCategory.MCP,
status=CheckStatus.PASS,
message=f"npx available at {npx_path}",
)
else:
return CheckResult(
id="mcp_npx",
title="npx for MCP",
category=CheckCategory.MCP,
status=CheckStatus.WARN,
message="npx not found (required for most MCP servers)",
remediation="Install Node.js to use MCP servers",
)
@register_check(
id="mcp_python",
title="Python MCP Support",
description="Check Python MCP package availability",
category=CheckCategory.MCP,
severity=CheckSeverity.LOW,
)
def check_mcp_python(config: DoctorConfig) -> CheckResult:
"""Check Python MCP package availability."""
try:
import mcp
version = getattr(mcp, "__version__", "unknown")
return CheckResult(
id="mcp_python",
title="Python MCP Support",
category=CheckCategory.MCP,
status=CheckStatus.PASS,
message=f"mcp package {version} available",
)
except ImportError:
return CheckResult(
id="mcp_python",
title="Python MCP Support",
category=CheckCategory.MCP,
status=CheckStatus.SKIP,
message="mcp package not installed (optional)",
remediation="Install with: pip install mcp",
)
@register_check(
id="mcp_servers_valid",
title="MCP Server Configs Valid",
description="Validate MCP server configurations",
category=CheckCategory.MCP,
severity=CheckSeverity.MEDIUM,
dependencies=["mcp_config"],
)
def check_mcp_servers_valid(config: DoctorConfig) -> CheckResult:
"""Validate MCP server configurations."""
mcp_config = _find_mcp_config()
if not mcp_config:
return CheckResult(
id="mcp_servers_valid",
title="MCP Server Configs Valid",
category=CheckCategory.MCP,
status=CheckStatus.SKIP,
message="No MCP configuration to validate",
)
servers = mcp_config.get("config", {}).get("mcpServers", {})
if not servers:
return CheckResult(
id="mcp_servers_valid",
title="MCP Server Configs Valid",
category=CheckCategory.MCP,
status=CheckStatus.SKIP,
message="No MCP servers configured",
)
valid = []
invalid = []
for name, server_config in servers.items():
if not isinstance(server_config, dict):
invalid.append(f"{name}: invalid config format")
continue
command = server_config.get("command")
if not command:
invalid.append(f"{name}: missing 'command'")
continue
# Check if command executable exists
cmd_parts = command.split() if isinstance(command, str) else [command]
cmd_exe = cmd_parts[0] if cmd_parts else ""
if cmd_exe and shutil.which(cmd_exe):
valid.append(name)
else:
invalid.append(f"{name}: command '{cmd_exe}' not found")
if invalid:
return CheckResult(
id="mcp_servers_valid",
title="MCP Server Configs Valid",
category=CheckCategory.MCP,
status=CheckStatus.WARN,
message=f"{len(valid)} valid, {len(invalid)} invalid server(s)",
details="; ".join(invalid[:3]) + ("..." if len(invalid) > 3 else ""),
metadata={"valid": valid, "invalid": invalid},
)
else:
return CheckResult(
id="mcp_servers_valid",
title="MCP Server Configs Valid",
category=CheckCategory.MCP,
status=CheckStatus.PASS,
message=f"All {len(valid)} MCP server config(s) valid",
metadata={"valid": valid},
)
@register_check(
id="mcp_server_spawn",
title="MCP Server Spawn Test",
description="Test spawning MCP servers",
category=CheckCategory.MCP,
severity=CheckSeverity.LOW,
requires_deep=True,
)
def check_mcp_server_spawn(config: DoctorConfig) -> CheckResult:
"""Test spawning MCP servers (deep mode only)."""
mcp_config = _find_mcp_config()
if not mcp_config:
return CheckResult(
id="mcp_server_spawn",
title="MCP Server Spawn Test",
category=CheckCategory.MCP,
status=CheckStatus.SKIP,
message="No MCP configuration to test",
)
servers = mcp_config.get("config", {}).get("mcpServers", {})
if not servers:
return CheckResult(
id="mcp_server_spawn",
title="MCP Server Spawn Test",
category=CheckCategory.MCP,
status=CheckStatus.SKIP,
message="No MCP servers to test",
)
# Only test first server to avoid long delays
test_server = config.name if config.name else list(servers.keys())[0]
server_config = servers.get(test_server)
if not server_config:
return CheckResult(
id="mcp_server_spawn",
title="MCP Server Spawn Test",
category=CheckCategory.MCP,
status=CheckStatus.SKIP,
message=f"Server '{test_server}' not found",
)
try:
import subprocess
command = server_config.get("command", "")
args = server_config.get("args", [])
if isinstance(command, str):
cmd = command.split() + args
else:
cmd = [command] + args
# Try to start the server with a short timeout
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={**os.environ, **server_config.get("env", {})},
)
# Wait briefly to see if it starts
import time
time.sleep(1)
if proc.poll() is None:
# Process is still running - success
proc.terminate()
proc.wait(timeout=2)
return CheckResult(
id="mcp_server_spawn",
title="MCP Server Spawn Test",
category=CheckCategory.MCP,
status=CheckStatus.PASS,
message=f"Server '{test_server}' started successfully",
)
else:
# Process exited
stderr = proc.stderr.read().decode()[:200] if proc.stderr else ""
return CheckResult(
id="mcp_server_spawn",
title="MCP Server Spawn Test",
category=CheckCategory.MCP,
status=CheckStatus.WARN,
message=f"Server '{test_server}' exited immediately",
details=stderr if stderr else "No error output",
)
except FileNotFoundError as e:
return CheckResult(
id="mcp_server_spawn",
title="MCP Server Spawn Test",
category=CheckCategory.MCP,
status=CheckStatus.FAIL,
message=f"Command not found for '{test_server}'",
details=str(e),
remediation="Install the required MCP server package",
)
except Exception as e:
return CheckResult(
id="mcp_server_spawn",
title="MCP Server Spawn Test",
category=CheckCategory.MCP,
status=CheckStatus.WARN,
message=f"Could not test server '{test_server}'",
details=str(e)[:200],
)
@register_check(
id="mcp_praisonai_integration",
title="PraisonAI MCP Integration",
description="Check PraisonAI MCP integration",
category=CheckCategory.MCP,
severity=CheckSeverity.LOW,
)
def check_mcp_praisonai_integration(config: DoctorConfig) -> CheckResult:
"""Check PraisonAI MCP integration."""
try:
from praisonaiagents.mcp import MCP
return CheckResult(
id="mcp_praisonai_integration",
title="PraisonAI MCP Integration",
category=CheckCategory.MCP,
status=CheckStatus.PASS,
message="PraisonAI MCP integration available",
)
except ImportError as e:
return CheckResult(
id="mcp_praisonai_integration",
title="PraisonAI MCP Integration",
category=CheckCategory.MCP,
status=CheckStatus.SKIP,
message="MCP integration not available",
details=str(e),
remediation="Install with: pip install praisonaiagents[mcp]",
)
@@ -0,0 +1,268 @@
"""
Memory checks for the Doctor CLI module.
Validates memory/session storage and integrity.
"""
import os
from pathlib import Path
from ..models import (
CheckCategory,
CheckResult,
CheckStatus,
CheckSeverity,
DoctorConfig,
)
from ..registry import register_check
def _find_memory_dirs() -> list:
"""Find memory storage directories."""
locations = [
Path.cwd() / ".praison" / "memory",
Path.cwd() / ".praison" / "sessions",
Path.home() / ".praison" / "memory",
Path.home() / ".praison" / "sessions",
Path.home() / ".config" / "praison" / "memory",
]
found = []
for loc in locations:
if loc.exists() and loc.is_dir():
found.append(str(loc))
return found
@register_check(
id="memory_dirs",
title="Memory Directories",
description="Check memory storage directories",
category=CheckCategory.MEMORY,
severity=CheckSeverity.INFO,
)
def check_memory_dirs(config: DoctorConfig) -> CheckResult:
"""Check memory storage directories."""
dirs = _find_memory_dirs()
if dirs:
return CheckResult(
id="memory_dirs",
title="Memory Directories",
category=CheckCategory.MEMORY,
status=CheckStatus.PASS,
message=f"Found {len(dirs)} memory directory(ies)",
details=", ".join(dirs),
metadata={"directories": dirs},
)
else:
return CheckResult(
id="memory_dirs",
title="Memory Directories",
category=CheckCategory.MEMORY,
status=CheckStatus.SKIP,
message="No memory directories found (will be created on first use)",
)
@register_check(
id="memory_files",
title="Memory Files",
description="Check memory file integrity",
category=CheckCategory.MEMORY,
severity=CheckSeverity.MEDIUM,
dependencies=["memory_dirs"],
)
def check_memory_files(config: DoctorConfig) -> CheckResult:
"""Check memory file integrity."""
dirs = _find_memory_dirs()
if not dirs:
return CheckResult(
id="memory_files",
title="Memory Files",
category=CheckCategory.MEMORY,
status=CheckStatus.SKIP,
message="No memory directories to check",
)
valid_files = 0
corrupt_files = []
total_size = 0
for dir_path in dirs:
memory_dir = Path(dir_path)
for file_path in memory_dir.rglob("*.json"):
try:
import json
with open(file_path) as f:
json.load(f)
valid_files += 1
total_size += file_path.stat().st_size
except json.JSONDecodeError:
corrupt_files.append(str(file_path))
except Exception:
pass
if corrupt_files:
return CheckResult(
id="memory_files",
title="Memory Files",
category=CheckCategory.MEMORY,
status=CheckStatus.WARN,
message=f"{len(corrupt_files)} corrupt file(s) found",
details="; ".join(corrupt_files[:3]) + ("..." if len(corrupt_files) > 3 else ""),
remediation="Remove or repair corrupt memory files",
metadata={"valid": valid_files, "corrupt": corrupt_files},
)
elif valid_files > 0:
size_kb = total_size / 1024
return CheckResult(
id="memory_files",
title="Memory Files",
category=CheckCategory.MEMORY,
status=CheckStatus.PASS,
message=f"{valid_files} valid memory file(s) ({size_kb:.1f} KB)",
metadata={"valid_count": valid_files, "total_size_bytes": total_size},
)
else:
return CheckResult(
id="memory_files",
title="Memory Files",
category=CheckCategory.MEMORY,
status=CheckStatus.SKIP,
message="No memory files found",
)
@register_check(
id="memory_sessions",
title="Session Storage",
description="Check session storage",
category=CheckCategory.MEMORY,
severity=CheckSeverity.INFO,
)
def check_memory_sessions(config: DoctorConfig) -> CheckResult:
"""Check session storage."""
session_dirs = [
Path.cwd() / ".praison" / "sessions",
Path.home() / ".praison" / "sessions",
]
sessions = []
for dir_path in session_dirs:
if dir_path.exists():
for item in dir_path.iterdir():
if item.is_dir() or item.suffix == ".json":
sessions.append(item.name)
if sessions:
return CheckResult(
id="memory_sessions",
title="Session Storage",
category=CheckCategory.MEMORY,
status=CheckStatus.PASS,
message=f"Found {len(sessions)} saved session(s)",
metadata={"sessions": sessions[:10]},
)
else:
return CheckResult(
id="memory_sessions",
title="Session Storage",
category=CheckCategory.MEMORY,
status=CheckStatus.SKIP,
message="No saved sessions found",
)
@register_check(
id="memory_chromadb",
title="ChromaDB Memory",
description="Check ChromaDB vector memory",
category=CheckCategory.MEMORY,
severity=CheckSeverity.LOW,
)
def check_memory_chromadb(config: DoctorConfig) -> CheckResult:
"""Check ChromaDB vector memory."""
chroma_dirs = [
Path.cwd() / ".praison" / "chroma",
Path.cwd() / "chroma_db",
Path.home() / ".praison" / "chroma",
]
found = None
for dir_path in chroma_dirs:
if dir_path.exists():
found = dir_path
break
if found:
try:
import chromadb
# Try to open the database
client = chromadb.PersistentClient(path=str(found))
collections = client.list_collections()
return CheckResult(
id="memory_chromadb",
title="ChromaDB Memory",
category=CheckCategory.MEMORY,
status=CheckStatus.PASS,
message=f"ChromaDB found with {len(collections)} collection(s)",
metadata={"path": str(found), "collections": len(collections)},
)
except ImportError:
return CheckResult(
id="memory_chromadb",
title="ChromaDB Memory",
category=CheckCategory.MEMORY,
status=CheckStatus.WARN,
message="ChromaDB directory found but chromadb not installed",
remediation="Install with: pip install chromadb",
)
except Exception as e:
return CheckResult(
id="memory_chromadb",
title="ChromaDB Memory",
category=CheckCategory.MEMORY,
status=CheckStatus.WARN,
message=f"ChromaDB found but cannot open: {type(e).__name__}",
details=str(e)[:200],
)
else:
return CheckResult(
id="memory_chromadb",
title="ChromaDB Memory",
category=CheckCategory.MEMORY,
status=CheckStatus.SKIP,
message="No ChromaDB storage found (optional)",
)
@register_check(
id="memory_praisonai_module",
title="PraisonAI Memory Module",
description="Check PraisonAI memory module",
category=CheckCategory.MEMORY,
severity=CheckSeverity.LOW,
)
def check_memory_praisonai_module(config: DoctorConfig) -> CheckResult:
"""Check PraisonAI memory module."""
try:
from praisonaiagents.memory import FileMemory, Memory
return CheckResult(
id="memory_praisonai_module",
title="PraisonAI Memory Module",
category=CheckCategory.MEMORY,
status=CheckStatus.PASS,
message="Memory modules available (FileMemory, Memory)",
)
except ImportError as e:
return CheckResult(
id="memory_praisonai_module",
title="PraisonAI Memory Module",
category=CheckCategory.MEMORY,
status=CheckStatus.WARN,
message="Memory module import failed",
details=str(e),
)
@@ -0,0 +1,251 @@
"""
Network checks for the Doctor CLI module.
Validates network connectivity and configuration.
"""
import os
import socket
from ..models import (
CheckCategory,
CheckResult,
CheckStatus,
CheckSeverity,
DoctorConfig,
)
from ..registry import register_check
@register_check(
id="network_dns",
title="DNS Resolution",
description="Check DNS resolution",
category=CheckCategory.NETWORK,
severity=CheckSeverity.MEDIUM,
requires_deep=True,
)
def check_network_dns(config: DoctorConfig) -> CheckResult:
"""Check DNS resolution."""
test_hosts = [
"api.openai.com",
"api.anthropic.com",
"google.com",
]
resolved = []
failed = []
for host in test_hosts:
try:
socket.gethostbyname(host)
resolved.append(host)
except socket.gaierror:
failed.append(host)
if failed:
return CheckResult(
id="network_dns",
title="DNS Resolution",
category=CheckCategory.NETWORK,
status=CheckStatus.WARN,
message=f"DNS resolution failed for {len(failed)} host(s)",
details=f"Failed: {', '.join(failed)}",
remediation="Check DNS configuration and network connectivity",
)
else:
return CheckResult(
id="network_dns",
title="DNS Resolution",
category=CheckCategory.NETWORK,
status=CheckStatus.PASS,
message=f"DNS resolution working ({len(resolved)} hosts tested)",
)
@register_check(
id="network_https",
title="HTTPS Connectivity",
description="Check HTTPS connectivity to key endpoints",
category=CheckCategory.NETWORK,
severity=CheckSeverity.MEDIUM,
requires_deep=True,
)
def check_network_https(config: DoctorConfig) -> CheckResult:
"""Check HTTPS connectivity to key endpoints."""
import urllib.request
import urllib.error
endpoints = [
("https://api.openai.com/v1/models", "OpenAI"),
("https://api.anthropic.com/v1/messages", "Anthropic"),
]
reachable = []
unreachable = []
for url, name in endpoints:
try:
req = urllib.request.Request(url, method="HEAD")
req.add_header("User-Agent", "PraisonAI-Doctor/1.0")
urllib.request.urlopen(req, timeout=5)
reachable.append(name)
except urllib.error.HTTPError as e:
# HTTP errors (401, 403, etc.) mean we reached the server
if e.code in (401, 403, 404, 405):
reachable.append(name)
else:
unreachable.append(f"{name} ({e.code})")
except Exception as e:
unreachable.append(f"{name} ({type(e).__name__})")
if unreachable:
return CheckResult(
id="network_https",
title="HTTPS Connectivity",
category=CheckCategory.NETWORK,
status=CheckStatus.WARN,
message=f"Cannot reach {len(unreachable)} endpoint(s)",
details=f"Unreachable: {', '.join(unreachable)}",
remediation="Check network connectivity and firewall settings",
)
else:
return CheckResult(
id="network_https",
title="HTTPS Connectivity",
category=CheckCategory.NETWORK,
status=CheckStatus.PASS,
message=f"HTTPS connectivity verified ({len(reachable)} endpoints)",
)
@register_check(
id="network_proxy",
title="Proxy Configuration",
description="Check proxy environment variables",
category=CheckCategory.NETWORK,
severity=CheckSeverity.INFO,
)
def check_network_proxy(config: DoctorConfig) -> CheckResult:
"""Check proxy environment variables."""
proxy_vars = {
"HTTP_PROXY": os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy"),
"HTTPS_PROXY": os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy"),
"NO_PROXY": os.environ.get("NO_PROXY") or os.environ.get("no_proxy"),
}
configured = {k: v for k, v in proxy_vars.items() if v}
if configured:
# Mask proxy URLs that might contain credentials
masked = {}
for k, v in configured.items():
if "@" in v:
# Contains credentials, mask them
parts = v.split("@")
masked[k] = f"***@{parts[-1]}"
else:
masked[k] = v
return CheckResult(
id="network_proxy",
title="Proxy Configuration",
category=CheckCategory.NETWORK,
status=CheckStatus.PASS,
message=f"Proxy configured: {', '.join(configured.keys())}",
details="; ".join(f"{k}={v}" for k, v in masked.items()),
)
else:
return CheckResult(
id="network_proxy",
title="Proxy Configuration",
category=CheckCategory.NETWORK,
status=CheckStatus.SKIP,
message="No proxy configured (direct connection)",
)
@register_check(
id="network_ssl",
title="SSL Configuration",
description="Check SSL/TLS configuration",
category=CheckCategory.NETWORK,
severity=CheckSeverity.INFO,
)
def check_network_ssl(config: DoctorConfig) -> CheckResult:
"""Check SSL/TLS configuration."""
ssl_vars = {
"SSL_CERT_FILE": os.environ.get("SSL_CERT_FILE"),
"SSL_CERT_DIR": os.environ.get("SSL_CERT_DIR"),
"REQUESTS_CA_BUNDLE": os.environ.get("REQUESTS_CA_BUNDLE"),
"CURL_CA_BUNDLE": os.environ.get("CURL_CA_BUNDLE"),
}
configured = {k: v for k, v in ssl_vars.items() if v}
# Check for SSL verification disable (security concern)
ssl_verify = os.environ.get("PYTHONHTTPSVERIFY", "1")
requests_verify = os.environ.get("REQUESTS_SSL_VERIFY", "1")
warnings = []
if ssl_verify == "0":
warnings.append("PYTHONHTTPSVERIFY=0 (SSL verification disabled)")
if requests_verify.lower() in ("0", "false"):
warnings.append("REQUESTS_SSL_VERIFY disabled")
if warnings:
return CheckResult(
id="network_ssl",
title="SSL Configuration",
category=CheckCategory.NETWORK,
status=CheckStatus.WARN,
message="SSL verification disabled",
details="; ".join(warnings),
remediation="Enable SSL verification for security",
)
elif configured:
return CheckResult(
id="network_ssl",
title="SSL Configuration",
category=CheckCategory.NETWORK,
status=CheckStatus.PASS,
message=f"Custom SSL config: {', '.join(configured.keys())}",
)
else:
return CheckResult(
id="network_ssl",
title="SSL Configuration",
category=CheckCategory.NETWORK,
status=CheckStatus.PASS,
message="Using default SSL configuration",
)
@register_check(
id="network_openai_base_url",
title="OpenAI Base URL",
description="Check OpenAI base URL configuration",
category=CheckCategory.NETWORK,
severity=CheckSeverity.INFO,
)
def check_network_openai_base_url(config: DoctorConfig) -> CheckResult:
"""Check OpenAI base URL configuration."""
base_url = os.environ.get("OPENAI_BASE_URL") or os.environ.get("OPENAI_API_BASE")
if base_url:
return CheckResult(
id="network_openai_base_url",
title="OpenAI Base URL",
category=CheckCategory.NETWORK,
status=CheckStatus.PASS,
message=f"Custom OpenAI base URL: {base_url}",
details="Using custom endpoint (e.g., Azure, local proxy)",
)
else:
return CheckResult(
id="network_openai_base_url",
title="OpenAI Base URL",
category=CheckCategory.NETWORK,
status=CheckStatus.PASS,
message="Using default OpenAI endpoint",
)
@@ -0,0 +1,328 @@
"""
Observability checks for the Doctor CLI module.
Validates observability provider configurations.
"""
import os
from ..models import (
CheckCategory,
CheckResult,
CheckStatus,
CheckSeverity,
DoctorConfig,
)
from ..registry import register_check
# Observability providers and their required env vars
OBS_PROVIDERS = {
"langfuse": {
"env_vars": ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"],
"optional_vars": ["LANGFUSE_HOST"],
"package": "langfuse",
},
"langsmith": {
"env_vars": ["LANGSMITH_API_KEY"],
"optional_vars": ["LANGSMITH_PROJECT"],
"package": "langsmith",
},
"agentops": {
"env_vars": ["AGENTOPS_API_KEY"],
"optional_vars": [],
"package": "agentops",
},
"arize": {
"env_vars": ["ARIZE_API_KEY"],
"optional_vars": ["ARIZE_SPACE_KEY"],
"package": "arize",
},
"datadog": {
"env_vars": ["DATADOG_API_KEY"],
"optional_vars": ["DD_SITE"],
"package": "ddtrace",
},
"mlflow": {
"env_vars": [],
"optional_vars": ["MLFLOW_TRACKING_URI"],
"package": "mlflow",
},
"wandb": {
"env_vars": ["WANDB_API_KEY"],
"optional_vars": ["WANDB_PROJECT"],
"package": "wandb",
},
"braintrust": {
"env_vars": ["BRAINTRUST_API_KEY"],
"optional_vars": [],
"package": "braintrust",
},
}
@register_check(
id="obs_config",
title="Observability Configuration",
description="Check observability provider configuration",
category=CheckCategory.OBSERVABILITY,
severity=CheckSeverity.INFO,
)
def check_obs_config(config: DoctorConfig) -> CheckResult:
"""Check observability provider configuration."""
configured = []
for provider, info in OBS_PROVIDERS.items():
env_vars = info["env_vars"]
if env_vars:
if all(os.environ.get(var) for var in env_vars):
configured.append(provider)
else:
# Check optional vars for providers without required vars
if any(os.environ.get(var) for var in info.get("optional_vars", [])):
configured.append(provider)
if configured:
return CheckResult(
id="obs_config",
title="Observability Configuration",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.PASS,
message=f"Configured provider(s): {', '.join(configured)}",
metadata={"providers": configured},
)
else:
return CheckResult(
id="obs_config",
title="Observability Configuration",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.SKIP,
message="No observability providers configured (optional)",
details="Set provider API keys to enable observability",
)
@register_check(
id="obs_langfuse",
title="Langfuse",
description="Check Langfuse observability provider",
category=CheckCategory.OBSERVABILITY,
severity=CheckSeverity.LOW,
)
def check_obs_langfuse(config: DoctorConfig) -> CheckResult:
"""Check Langfuse observability provider."""
public_key = os.environ.get("LANGFUSE_PUBLIC_KEY")
secret_key = os.environ.get("LANGFUSE_SECRET_KEY")
if public_key and secret_key:
try:
import langfuse
version = getattr(langfuse, "__version__", "unknown")
return CheckResult(
id="obs_langfuse",
title="Langfuse",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.PASS,
message=f"Langfuse configured (SDK {version})",
)
except ImportError:
return CheckResult(
id="obs_langfuse",
title="Langfuse",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.WARN,
message="Langfuse keys set but SDK not installed",
remediation="Install with: pip install langfuse",
)
else:
return CheckResult(
id="obs_langfuse",
title="Langfuse",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.SKIP,
message="Langfuse not configured (optional)",
)
@register_check(
id="obs_langsmith",
title="LangSmith",
description="Check LangSmith observability provider",
category=CheckCategory.OBSERVABILITY,
severity=CheckSeverity.LOW,
)
def check_obs_langsmith(config: DoctorConfig) -> CheckResult:
"""Check LangSmith observability provider."""
api_key = os.environ.get("LANGSMITH_API_KEY")
if api_key:
try:
import langsmith
version = getattr(langsmith, "__version__", "unknown")
return CheckResult(
id="obs_langsmith",
title="LangSmith",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.PASS,
message=f"LangSmith configured (SDK {version})",
)
except ImportError:
return CheckResult(
id="obs_langsmith",
title="LangSmith",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.WARN,
message="LangSmith key set but SDK not installed",
remediation="Install with: pip install langsmith",
)
else:
return CheckResult(
id="obs_langsmith",
title="LangSmith",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.SKIP,
message="LangSmith not configured (optional)",
)
@register_check(
id="obs_agentops",
title="AgentOps",
description="Check AgentOps observability provider",
category=CheckCategory.OBSERVABILITY,
severity=CheckSeverity.LOW,
)
def check_obs_agentops(config: DoctorConfig) -> CheckResult:
"""Check AgentOps observability provider."""
api_key = os.environ.get("AGENTOPS_API_KEY")
if api_key:
try:
import agentops
version = getattr(agentops, "__version__", "unknown")
return CheckResult(
id="obs_agentops",
title="AgentOps",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.PASS,
message=f"AgentOps configured (SDK {version})",
)
except ImportError:
return CheckResult(
id="obs_agentops",
title="AgentOps",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.WARN,
message="AgentOps key set but SDK not installed",
remediation="Install with: pip install agentops",
)
else:
return CheckResult(
id="obs_agentops",
title="AgentOps",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.SKIP,
message="AgentOps not configured (optional)",
)
@register_check(
id="obs_praisonai_telemetry",
title="PraisonAI Telemetry",
description="Check PraisonAI built-in telemetry",
category=CheckCategory.OBSERVABILITY,
severity=CheckSeverity.INFO,
)
def check_obs_praisonai_telemetry(config: DoctorConfig) -> CheckResult:
"""Check PraisonAI built-in telemetry."""
telemetry_enabled = os.environ.get("PRAISONAI_TELEMETRY_ENABLED", "").lower() in ("true", "1", "yes")
try:
from praisonaiagents.telemetry import get_telemetry
if telemetry_enabled:
return CheckResult(
id="obs_praisonai_telemetry",
title="PraisonAI Telemetry",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.PASS,
message="PraisonAI telemetry enabled",
)
else:
return CheckResult(
id="obs_praisonai_telemetry",
title="PraisonAI Telemetry",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.SKIP,
message="PraisonAI telemetry available but not enabled",
details="Set PRAISONAI_TELEMETRY_ENABLED=true to enable",
)
except ImportError:
return CheckResult(
id="obs_praisonai_telemetry",
title="PraisonAI Telemetry",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.SKIP,
message="PraisonAI telemetry module not available",
)
@register_check(
id="obs_connectivity",
title="Observability Connectivity",
description="Test connectivity to observability providers",
category=CheckCategory.OBSERVABILITY,
severity=CheckSeverity.LOW,
requires_deep=True,
)
def check_obs_connectivity(config: DoctorConfig) -> CheckResult:
"""Test connectivity to observability providers (deep mode)."""
# Only test if a specific provider is requested or if we have configured providers
provider = config.provider
if provider == "langfuse":
public_key = os.environ.get("LANGFUSE_PUBLIC_KEY")
secret_key = os.environ.get("LANGFUSE_SECRET_KEY")
if not (public_key and secret_key):
return CheckResult(
id="obs_connectivity",
title="Observability Connectivity",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.SKIP,
message="Langfuse not configured",
)
try:
from langfuse import Langfuse
client = Langfuse(
public_key=public_key,
secret_key=secret_key,
)
# Try to authenticate
client.auth_check()
return CheckResult(
id="obs_connectivity",
title="Observability Connectivity",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.PASS,
message="Langfuse connectivity verified",
)
except Exception as e:
return CheckResult(
id="obs_connectivity",
title="Observability Connectivity",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.FAIL,
message=f"Langfuse connection failed: {type(e).__name__}",
details=str(e)[:200],
)
# Default: skip if no specific provider requested
return CheckResult(
id="obs_connectivity",
title="Observability Connectivity",
category=CheckCategory.OBSERVABILITY,
status=CheckStatus.SKIP,
message="Use --provider to test specific provider connectivity",
)
@@ -0,0 +1,235 @@
"""
Performance checks for the Doctor CLI module.
Measures import times and identifies slow imports.
"""
import sys
import time
from ..models import (
CheckCategory,
CheckResult,
CheckStatus,
CheckSeverity,
DoctorConfig,
)
from ..registry import register_check
def _measure_import_time(module_name: str) -> tuple:
"""Measure import time for a module."""
# Remove from cache if present
modules_before = set(sys.modules.keys())
start = time.perf_counter()
try:
__import__(module_name)
elapsed = (time.perf_counter() - start) * 1000 # ms
return True, elapsed, None
except ImportError as e:
elapsed = (time.perf_counter() - start) * 1000
return False, elapsed, str(e)
finally:
# Clean up imported modules (optional, for accurate re-measurement)
pass
@register_check(
id="performance_praisonai_import",
title="PraisonAI Import Time",
description="Measure praisonai package import time",
category=CheckCategory.PERFORMANCE,
severity=CheckSeverity.INFO,
)
def check_performance_praisonai_import(config: DoctorConfig) -> CheckResult:
"""Measure praisonai package import time."""
budget_ms = config.budget_ms or 2000 # 2 second default budget
# praisonai is already imported, measure a fresh import of a submodule
start = time.perf_counter()
try:
# Import version module (lightweight)
from praisonai import version
elapsed = (time.perf_counter() - start) * 1000
if elapsed > budget_ms:
return CheckResult(
id="performance_praisonai_import",
title="PraisonAI Import Time",
category=CheckCategory.PERFORMANCE,
status=CheckStatus.WARN,
message=f"Import time {elapsed:.0f}ms exceeds budget {budget_ms}ms",
remediation="Check for slow dependencies or disk I/O issues",
)
else:
return CheckResult(
id="performance_praisonai_import",
title="PraisonAI Import Time",
category=CheckCategory.PERFORMANCE,
status=CheckStatus.PASS,
message=f"Import time: {elapsed:.0f}ms (budget: {budget_ms}ms)",
metadata={"import_time_ms": elapsed, "budget_ms": budget_ms},
)
except ImportError as e:
return CheckResult(
id="performance_praisonai_import",
title="PraisonAI Import Time",
category=CheckCategory.PERFORMANCE,
status=CheckStatus.FAIL,
message=f"Import failed: {e}",
)
@register_check(
id="performance_praisonaiagents_import",
title="PraisonAI Agents Import Time",
description="Measure praisonaiagents package import time",
category=CheckCategory.PERFORMANCE,
severity=CheckSeverity.INFO,
)
def check_performance_praisonaiagents_import(config: DoctorConfig) -> CheckResult:
"""Measure praisonaiagents package import time."""
budget_ms = config.budget_ms or 3000 # 3 second default budget
start = time.perf_counter()
try:
import praisonaiagents
elapsed = (time.perf_counter() - start) * 1000
if elapsed > budget_ms:
return CheckResult(
id="performance_praisonaiagents_import",
title="PraisonAI Agents Import Time",
category=CheckCategory.PERFORMANCE,
status=CheckStatus.WARN,
message=f"Import time {elapsed:.0f}ms exceeds budget {budget_ms}ms",
remediation="Check for slow dependencies",
)
else:
return CheckResult(
id="performance_praisonaiagents_import",
title="PraisonAI Agents Import Time",
category=CheckCategory.PERFORMANCE,
status=CheckStatus.PASS,
message=f"Import time: {elapsed:.0f}ms (budget: {budget_ms}ms)",
metadata={"import_time_ms": elapsed, "budget_ms": budget_ms},
)
except ImportError as e:
return CheckResult(
id="performance_praisonaiagents_import",
title="PraisonAI Agents Import Time",
category=CheckCategory.PERFORMANCE,
status=CheckStatus.FAIL,
message=f"Import failed: {e}",
)
@register_check(
id="performance_slow_imports",
title="Slow Import Detection",
description="Detect slow-loading optional dependencies",
category=CheckCategory.PERFORMANCE,
severity=CheckSeverity.INFO,
requires_deep=True,
)
def check_performance_slow_imports(config: DoctorConfig) -> CheckResult:
"""Detect slow-loading optional dependencies."""
modules_to_check = [
"litellm",
"chromadb",
"langchain",
"torch",
"transformers",
"pandas",
"numpy",
]
top_n = config.top_n or 5
budget_ms = config.budget_ms or 1000
import_times = []
for module in modules_to_check:
if module in sys.modules:
# Already imported, skip
continue
success, elapsed, error = _measure_import_time(module)
if success:
import_times.append((module, elapsed))
# Sort by time, descending
import_times.sort(key=lambda x: x[1], reverse=True)
slow_imports = [(m, t) for m, t in import_times if t > budget_ms]
top_imports = import_times[:top_n]
if slow_imports:
slow_str = ", ".join(f"{m}({t:.0f}ms)" for m, t in slow_imports[:3])
return CheckResult(
id="performance_slow_imports",
title="Slow Import Detection",
category=CheckCategory.PERFORMANCE,
status=CheckStatus.WARN,
message=f"{len(slow_imports)} slow import(s) detected",
details=f"Slow: {slow_str}",
metadata={"slow_imports": slow_imports, "top_imports": top_imports},
)
elif top_imports:
top_str = ", ".join(f"{m}({t:.0f}ms)" for m, t in top_imports[:3])
return CheckResult(
id="performance_slow_imports",
title="Slow Import Detection",
category=CheckCategory.PERFORMANCE,
status=CheckStatus.PASS,
message=f"No slow imports (top: {top_str})",
metadata={"top_imports": top_imports},
)
else:
return CheckResult(
id="performance_slow_imports",
title="Slow Import Detection",
category=CheckCategory.PERFORMANCE,
status=CheckStatus.SKIP,
message="No optional modules to measure",
)
@register_check(
id="performance_loaded_modules",
title="Loaded Modules Count",
description="Count currently loaded Python modules",
category=CheckCategory.PERFORMANCE,
severity=CheckSeverity.INFO,
)
def check_performance_loaded_modules(config: DoctorConfig) -> CheckResult:
"""Count currently loaded Python modules."""
module_count = len(sys.modules)
# Categorize modules
stdlib_count = 0
third_party_count = 0
praison_count = 0
for name in sys.modules:
if name.startswith("praisonai"):
praison_count += 1
elif "site-packages" in str(getattr(sys.modules[name], "__file__", "")):
third_party_count += 1
else:
stdlib_count += 1
return CheckResult(
id="performance_loaded_modules",
title="Loaded Modules Count",
category=CheckCategory.PERFORMANCE,
status=CheckStatus.PASS,
message=f"{module_count} modules loaded (praison: {praison_count})",
metadata={
"total": module_count,
"praison": praison_count,
"third_party": third_party_count,
"stdlib": stdlib_count,
},
)
@@ -0,0 +1,259 @@
"""
Permissions checks for the Doctor CLI module.
Validates filesystem permissions for PraisonAI directories.
"""
import os
import tempfile
from pathlib import Path
from ..models import (
CheckCategory,
CheckResult,
CheckStatus,
CheckSeverity,
DoctorConfig,
)
from ..registry import register_check
def _check_dir_writable(path: Path) -> tuple:
"""Check if a directory is writable."""
try:
if not path.exists():
return False, "does not exist"
if not path.is_dir():
return False, "not a directory"
# Try to create a temp file
test_file = path / ".praison_write_test"
try:
test_file.write_text("test")
test_file.unlink()
return True, "writable"
except PermissionError:
return False, "permission denied"
except Exception as e:
return False, str(e)
except Exception as e:
return False, str(e)
@register_check(
id="permissions_home_praison",
title="~/.praison Directory",
description="Check ~/.praison directory permissions",
category=CheckCategory.PERMISSIONS,
severity=CheckSeverity.MEDIUM,
)
def check_permissions_home_praison(config: DoctorConfig) -> CheckResult:
"""Check ~/.praison directory permissions."""
praison_dir = Path.home() / ".praison"
if not praison_dir.exists():
# Try to create it
try:
praison_dir.mkdir(parents=True, exist_ok=True)
return CheckResult(
id="permissions_home_praison",
title="~/.praison Directory",
category=CheckCategory.PERMISSIONS,
status=CheckStatus.PASS,
message="~/.praison created successfully",
metadata={"path": str(praison_dir)},
)
except PermissionError:
return CheckResult(
id="permissions_home_praison",
title="~/.praison Directory",
category=CheckCategory.PERMISSIONS,
status=CheckStatus.FAIL,
message="Cannot create ~/.praison directory",
remediation="Check home directory permissions",
severity=CheckSeverity.HIGH,
)
writable, reason = _check_dir_writable(praison_dir)
if writable:
return CheckResult(
id="permissions_home_praison",
title="~/.praison Directory",
category=CheckCategory.PERMISSIONS,
status=CheckStatus.PASS,
message=f"~/.praison is writable",
metadata={"path": str(praison_dir)},
)
else:
return CheckResult(
id="permissions_home_praison",
title="~/.praison Directory",
category=CheckCategory.PERMISSIONS,
status=CheckStatus.FAIL,
message=f"~/.praison is not writable: {reason}",
remediation="Fix permissions: chmod 755 ~/.praison",
severity=CheckSeverity.HIGH,
)
@register_check(
id="permissions_project_praison",
title=".praison Directory (Project)",
description="Check project .praison directory permissions",
category=CheckCategory.PERMISSIONS,
severity=CheckSeverity.LOW,
)
def check_permissions_project_praison(config: DoctorConfig) -> CheckResult:
"""Check project .praison directory permissions."""
praison_dir = Path.cwd() / ".praison"
if not praison_dir.exists():
return CheckResult(
id="permissions_project_praison",
title=".praison Directory (Project)",
category=CheckCategory.PERMISSIONS,
status=CheckStatus.SKIP,
message="No .praison directory in current project",
)
writable, reason = _check_dir_writable(praison_dir)
if writable:
return CheckResult(
id="permissions_project_praison",
title=".praison Directory (Project)",
category=CheckCategory.PERMISSIONS,
status=CheckStatus.PASS,
message=".praison is writable",
metadata={"path": str(praison_dir)},
)
else:
return CheckResult(
id="permissions_project_praison",
title=".praison Directory (Project)",
category=CheckCategory.PERMISSIONS,
status=CheckStatus.WARN,
message=f".praison is not writable: {reason}",
remediation="Fix permissions or use ~/.praison instead",
)
@register_check(
id="permissions_temp_dir",
title="Temp Directory",
description="Check temp directory permissions",
category=CheckCategory.PERMISSIONS,
severity=CheckSeverity.MEDIUM,
)
def check_permissions_temp_dir(config: DoctorConfig) -> CheckResult:
"""Check temp directory permissions."""
temp_dir = Path(tempfile.gettempdir())
try:
# Try to create a temp file
with tempfile.NamedTemporaryFile(delete=True) as f:
f.write(b"test")
return CheckResult(
id="permissions_temp_dir",
title="Temp Directory",
category=CheckCategory.PERMISSIONS,
status=CheckStatus.PASS,
message=f"Temp directory writable: {temp_dir}",
metadata={"path": str(temp_dir)},
)
except Exception as e:
return CheckResult(
id="permissions_temp_dir",
title="Temp Directory",
category=CheckCategory.PERMISSIONS,
status=CheckStatus.FAIL,
message=f"Temp directory not writable: {e}",
remediation="Check TMPDIR environment variable and permissions",
severity=CheckSeverity.HIGH,
)
@register_check(
id="permissions_cwd",
title="Current Working Directory",
description="Check current directory permissions",
category=CheckCategory.PERMISSIONS,
severity=CheckSeverity.INFO,
)
def check_permissions_cwd(config: DoctorConfig) -> CheckResult:
"""Check current directory permissions."""
cwd = Path.cwd()
writable, reason = _check_dir_writable(cwd)
if writable:
return CheckResult(
id="permissions_cwd",
title="Current Working Directory",
category=CheckCategory.PERMISSIONS,
status=CheckStatus.PASS,
message=f"Current directory writable: {cwd}",
)
else:
return CheckResult(
id="permissions_cwd",
title="Current Working Directory",
category=CheckCategory.PERMISSIONS,
status=CheckStatus.WARN,
message=f"Current directory not writable: {reason}",
details="Some features may not work without write access",
)
@register_check(
id="permissions_config_dir",
title="Config Directory",
description="Check ~/.config/praison directory",
category=CheckCategory.PERMISSIONS,
severity=CheckSeverity.LOW,
)
def check_permissions_config_dir(config: DoctorConfig) -> CheckResult:
"""Check ~/.config/praison directory."""
config_dir = Path.home() / ".config" / "praison"
if not config_dir.exists():
# Check if we can create it
try:
config_dir.mkdir(parents=True, exist_ok=True)
return CheckResult(
id="permissions_config_dir",
title="Config Directory",
category=CheckCategory.PERMISSIONS,
status=CheckStatus.PASS,
message="~/.config/praison created successfully",
)
except Exception:
return CheckResult(
id="permissions_config_dir",
title="Config Directory",
category=CheckCategory.PERMISSIONS,
status=CheckStatus.SKIP,
message="~/.config/praison does not exist (will use ~/.praison)",
)
writable, reason = _check_dir_writable(config_dir)
if writable:
return CheckResult(
id="permissions_config_dir",
title="Config Directory",
category=CheckCategory.PERMISSIONS,
status=CheckStatus.PASS,
message="~/.config/praison is writable",
)
else:
return CheckResult(
id="permissions_config_dir",
title="Config Directory",
category=CheckCategory.PERMISSIONS,
status=CheckStatus.WARN,
message=f"~/.config/praison not writable: {reason}",
)
@@ -0,0 +1,322 @@
"""
Selftest checks for the Doctor CLI module.
Performs minimal agent dry-run to validate the system.
"""
import os
from ..models import (
CheckCategory,
CheckResult,
CheckStatus,
CheckSeverity,
DoctorConfig,
)
from ..registry import register_check
@register_check(
id="selftest_agent_import",
title="Agent Import",
description="Check Agent class can be imported",
category=CheckCategory.SELFTEST,
severity=CheckSeverity.HIGH,
)
def check_selftest_agent_import(config: DoctorConfig) -> CheckResult:
"""Check Agent class can be imported."""
try:
from praisonaiagents import Agent
return CheckResult(
id="selftest_agent_import",
title="Agent Import",
category=CheckCategory.SELFTEST,
status=CheckStatus.PASS,
message="Agent class imported successfully",
)
except ImportError as e:
return CheckResult(
id="selftest_agent_import",
title="Agent Import",
category=CheckCategory.SELFTEST,
status=CheckStatus.FAIL,
message=f"Cannot import Agent: {e}",
remediation="Ensure praisonaiagents is properly installed",
severity=CheckSeverity.CRITICAL,
)
@register_check(
id="selftest_agent_create",
title="Agent Creation",
description="Check Agent can be instantiated",
category=CheckCategory.SELFTEST,
severity=CheckSeverity.HIGH,
dependencies=["selftest_agent_import"],
)
def check_selftest_agent_create(config: DoctorConfig) -> CheckResult:
"""Check Agent can be instantiated."""
try:
from praisonaiagents import Agent
# Create a minimal agent without making API calls
agent = Agent(
name="DoctorTestAgent",
instructions="You are a test agent.",
llm="gpt-4o-mini", # Default model
verbose=False,
)
return CheckResult(
id="selftest_agent_create",
title="Agent Creation",
category=CheckCategory.SELFTEST,
status=CheckStatus.PASS,
message="Agent instantiated successfully",
metadata={"agent_name": agent.name},
)
except Exception as e:
return CheckResult(
id="selftest_agent_create",
title="Agent Creation",
category=CheckCategory.SELFTEST,
status=CheckStatus.FAIL,
message=f"Agent creation failed: {type(e).__name__}",
details=str(e)[:200],
remediation="Check configuration and dependencies",
severity=CheckSeverity.HIGH,
)
@register_check(
id="selftest_llm_config",
title="LLM Configuration",
description="Check LLM configuration is valid",
category=CheckCategory.SELFTEST,
severity=CheckSeverity.MEDIUM,
)
def check_selftest_llm_config(config: DoctorConfig) -> CheckResult:
"""Check LLM configuration is valid."""
# Check for API key
openai_key = os.environ.get("OPENAI_API_KEY")
anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
google_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
ollama_host = os.environ.get("OLLAMA_HOST")
providers = []
if openai_key:
providers.append("OpenAI")
if anthropic_key:
providers.append("Anthropic")
if google_key:
providers.append("Google")
if ollama_host:
providers.append("Ollama")
if providers:
return CheckResult(
id="selftest_llm_config",
title="LLM Configuration",
category=CheckCategory.SELFTEST,
status=CheckStatus.PASS,
message=f"LLM provider(s) configured: {', '.join(providers)}",
metadata={"providers": providers},
)
else:
return CheckResult(
id="selftest_llm_config",
title="LLM Configuration",
category=CheckCategory.SELFTEST,
status=CheckStatus.FAIL,
message="No LLM provider configured",
remediation="Set OPENAI_API_KEY or another provider's API key",
severity=CheckSeverity.HIGH,
)
@register_check(
id="selftest_mock_chat",
title="Mock Chat Test",
description="Test agent chat with mock response",
category=CheckCategory.SELFTEST,
severity=CheckSeverity.MEDIUM,
requires_deep=True,
)
def check_selftest_mock_chat(config: DoctorConfig) -> CheckResult:
"""Test agent chat with mock response (no API call)."""
if not config.mock and not config.live:
# Default to mock in deep mode
config.mock = True
if config.live:
return CheckResult(
id="selftest_mock_chat",
title="Mock Chat Test",
category=CheckCategory.SELFTEST,
status=CheckStatus.SKIP,
message="Skipped (--live mode uses live API)",
)
try:
# Test basic agent functionality without making API calls
from praisonaiagents import Agent
agent = Agent(
name="MockTestAgent",
instructions="You are a test agent. Always respond with 'OK'.",
llm="gpt-4o-mini",
verbose=False,
)
# Verify agent is properly configured
if hasattr(agent, 'name') and hasattr(agent, 'instructions'):
return CheckResult(
id="selftest_mock_chat",
title="Mock Chat Test",
category=CheckCategory.SELFTEST,
status=CheckStatus.PASS,
message="Agent mock test passed",
)
else:
return CheckResult(
id="selftest_mock_chat",
title="Mock Chat Test",
category=CheckCategory.SELFTEST,
status=CheckStatus.FAIL,
message="Agent missing expected attributes",
)
except Exception as e:
return CheckResult(
id="selftest_mock_chat",
title="Mock Chat Test",
category=CheckCategory.SELFTEST,
status=CheckStatus.FAIL,
message=f"Mock test failed: {type(e).__name__}",
details=str(e)[:200],
)
@register_check(
id="selftest_live_chat",
title="Live Chat Test",
description="Test agent chat with live API call",
category=CheckCategory.SELFTEST,
severity=CheckSeverity.LOW,
requires_deep=True,
)
def check_selftest_live_chat(config: DoctorConfig) -> CheckResult:
"""Test agent chat with live API call."""
if not config.live:
return CheckResult(
id="selftest_live_chat",
title="Live Chat Test",
category=CheckCategory.SELFTEST,
status=CheckStatus.SKIP,
message="Skipped (use --live to enable)",
)
# Check for API key first
if not os.environ.get("OPENAI_API_KEY"):
return CheckResult(
id="selftest_live_chat",
title="Live Chat Test",
category=CheckCategory.SELFTEST,
status=CheckStatus.SKIP,
message="Skipped (OPENAI_API_KEY not set)",
)
try:
from praisonaiagents import Agent
model = config.model or "gpt-4o-mini"
agent = Agent(
name="LiveTestAgent",
instructions="You are a test agent. Respond with exactly: 'Doctor test OK'",
llm=model,
verbose=False,
)
# Make a simple API call
response = agent.chat("Say hello")
if response:
return CheckResult(
id="selftest_live_chat",
title="Live Chat Test",
category=CheckCategory.SELFTEST,
status=CheckStatus.PASS,
message=f"Live API call successful (model: {model})",
metadata={"model": model, "response_length": len(str(response))},
)
else:
return CheckResult(
id="selftest_live_chat",
title="Live Chat Test",
category=CheckCategory.SELFTEST,
status=CheckStatus.FAIL,
message="Live API call returned empty response",
)
except Exception as e:
return CheckResult(
id="selftest_live_chat",
title="Live Chat Test",
category=CheckCategory.SELFTEST,
status=CheckStatus.FAIL,
message=f"Live API call failed: {type(e).__name__}",
details=str(e)[:200],
remediation="Check API key and network connectivity",
)
@register_check(
id="selftest_tools_wiring",
title="Tools Wiring",
description="Check tools can be wired to agent",
category=CheckCategory.SELFTEST,
severity=CheckSeverity.LOW,
)
def check_selftest_tools_wiring(config: DoctorConfig) -> CheckResult:
"""Check tools can be wired to agent."""
try:
from praisonaiagents import Agent
# Define a simple test tool
def test_tool(x: str) -> str:
"""A test tool that echoes input."""
return f"Echo: {x}"
agent = Agent(
name="ToolTestAgent",
instructions="You are a test agent with tools.",
tools=[test_tool],
llm="gpt-4o-mini",
verbose=False,
)
# Check tools are registered
if hasattr(agent, 'tools') or hasattr(agent, '_tools'):
return CheckResult(
id="selftest_tools_wiring",
title="Tools Wiring",
category=CheckCategory.SELFTEST,
status=CheckStatus.PASS,
message="Tools wired to agent successfully",
)
else:
return CheckResult(
id="selftest_tools_wiring",
title="Tools Wiring",
category=CheckCategory.SELFTEST,
status=CheckStatus.WARN,
message="Tools wiring could not be verified",
)
except Exception as e:
return CheckResult(
id="selftest_tools_wiring",
title="Tools Wiring",
category=CheckCategory.SELFTEST,
status=CheckStatus.FAIL,
message=f"Tools wiring failed: {type(e).__name__}",
details=str(e)[:200],
)
@@ -0,0 +1,426 @@
"""
Doctor checks for serve and endpoints functionality.
Checks for:
- Serve module availability
- Endpoints module availability
- Server connectivity
- Discovery endpoint
- Provider types
"""
import os
from ..models import CheckResult, CheckStatus, CheckCategory, CheckSeverity, DoctorConfig
from ..registry import register_check
@register_check(
id="serve_module",
title="Serve Module",
description="Check if serve module is available",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.MEDIUM,
tags=["serve", "deploy"],
)
def check_serve_module(config: DoctorConfig) -> CheckResult:
"""Check if serve module is available."""
try:
from praisonai.cli.features.serve import ServeHandler
ServeHandler() # Test instantiation
return CheckResult(
id="serve_module",
title="Serve Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="Serve module available",
metadata={"handler": "ServeHandler"},
)
except ImportError as e:
return CheckResult(
id="serve_module",
title="Serve Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message=f"Serve module not available: {e}",
remediation="pip install praisonai[serve]",
)
except Exception as e:
return CheckResult(
id="serve_module",
title="Serve Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.ERROR,
message=f"Error checking serve module: {e}",
)
@register_check(
id="endpoints_module",
title="Endpoints Module",
description="Check if endpoints module is available",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.MEDIUM,
tags=["endpoints", "deploy"],
)
def check_endpoints_module(config: DoctorConfig) -> CheckResult:
"""Check if endpoints module is available."""
try:
from praisonai.endpoints import (
DiscoveryDocument,
EndpointInfo,
ProviderInfo,
list_provider_types,
)
types = list_provider_types()
return CheckResult(
id="endpoints_module",
title="Endpoints Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"Endpoints module available ({len(types)} provider types)",
metadata={"provider_types": types},
)
except ImportError as e:
return CheckResult(
id="endpoints_module",
title="Endpoints Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message=f"Endpoints module not available: {e}",
remediation="pip install praisonai[serve]",
)
except Exception as e:
return CheckResult(
id="endpoints_module",
title="Endpoints Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.ERROR,
message=f"Error checking endpoints module: {e}",
)
@register_check(
id="endpoints_cli",
title="Endpoints CLI",
description="Check if endpoints CLI handler is available",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.LOW,
tags=["endpoints", "cli"],
)
def check_endpoints_cli(config: DoctorConfig) -> CheckResult:
"""Check if endpoints CLI handler is available."""
try:
from praisonai.cli.features.endpoints import EndpointsHandler
handler = EndpointsHandler()
return CheckResult(
id="endpoints_cli",
title="Endpoints CLI",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="Endpoints CLI handler available",
metadata={"default_url": handler.DEFAULT_URL},
)
except ImportError as e:
return CheckResult(
id="endpoints_cli",
title="Endpoints CLI",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message=f"Endpoints CLI not available: {e}",
remediation="pip install praisonai",
)
except Exception as e:
return CheckResult(
id="endpoints_cli",
title="Endpoints CLI",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.ERROR,
message=f"Error checking endpoints CLI: {e}",
)
@register_check(
id="server_connectivity",
title="Server Connectivity",
description="Check if a PraisonAI server is reachable",
category=CheckCategory.NETWORK,
severity=CheckSeverity.LOW,
requires_deep=True,
tags=["serve", "network"],
)
def check_server_connectivity(config: DoctorConfig) -> CheckResult:
"""Check if a PraisonAI server is reachable."""
import urllib.request
import urllib.error
url = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
health_url = f"{url}/health"
try:
req = urllib.request.Request(health_url, method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
if resp.status == 200:
return CheckResult(
id="server_connectivity",
title="Server Connectivity",
category=CheckCategory.NETWORK,
status=CheckStatus.PASS,
message=f"Server reachable at {url}",
metadata={"url": url, "status": resp.status},
)
else:
return CheckResult(
id="server_connectivity",
title="Server Connectivity",
category=CheckCategory.NETWORK,
status=CheckStatus.WARN,
message=f"Server returned status {resp.status}",
metadata={"url": url, "status": resp.status},
)
except urllib.error.URLError as e:
return CheckResult(
id="server_connectivity",
title="Server Connectivity",
category=CheckCategory.NETWORK,
status=CheckStatus.SKIP,
message=f"No server running at {url}",
details=str(e.reason),
remediation="Start a server: praisonai serve unified --port 8765",
)
except Exception as e:
return CheckResult(
id="server_connectivity",
title="Server Connectivity",
category=CheckCategory.NETWORK,
status=CheckStatus.ERROR,
message=f"Error checking server: {e}",
)
@register_check(
id="discovery_endpoint",
title="Discovery Endpoint",
description="Check if discovery endpoint is available",
category=CheckCategory.NETWORK,
severity=CheckSeverity.LOW,
requires_deep=True,
dependencies=["server_connectivity"],
tags=["serve", "discovery"],
)
def check_discovery_endpoint(config: DoctorConfig) -> CheckResult:
"""Check if discovery endpoint is available."""
import urllib.request
import urllib.error
import json
url = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
discovery_url = f"{url}/__praisonai__/discovery"
try:
req = urllib.request.Request(discovery_url, method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
if resp.status == 200:
data = json.loads(resp.read().decode())
providers = data.get("providers", [])
endpoints = data.get("endpoints", [])
return CheckResult(
id="discovery_endpoint",
title="Discovery Endpoint",
category=CheckCategory.NETWORK,
status=CheckStatus.PASS,
message=f"Discovery available: {len(providers)} providers, {len(endpoints)} endpoints",
metadata={
"url": discovery_url,
"schema_version": data.get("schema_version"),
"server_name": data.get("server_name"),
"providers": [p.get("type") for p in providers],
},
)
else:
return CheckResult(
id="discovery_endpoint",
title="Discovery Endpoint",
category=CheckCategory.NETWORK,
status=CheckStatus.WARN,
message=f"Discovery returned status {resp.status}",
)
except urllib.error.URLError:
return CheckResult(
id="discovery_endpoint",
title="Discovery Endpoint",
category=CheckCategory.NETWORK,
status=CheckStatus.SKIP,
message="No server running (skipped)",
)
except Exception as e:
return CheckResult(
id="discovery_endpoint",
title="Discovery Endpoint",
category=CheckCategory.NETWORK,
status=CheckStatus.ERROR,
message=f"Error checking discovery: {e}",
)
@register_check(
id="a2u_module",
title="A2U Module",
description="Check if A2U server module is available",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.LOW,
tags=["serve", "a2u"],
)
def check_a2u_module(config: DoctorConfig) -> CheckResult:
"""Check if A2U server module is available."""
try:
from praisonai.endpoints.a2u_server import (
A2UEvent,
A2USubscription,
A2UEventBus,
create_a2u_routes,
)
return CheckResult(
id="a2u_module",
title="A2U Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="A2U server module available",
)
except ImportError as e:
return CheckResult(
id="a2u_module",
title="A2U Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message=f"A2U module not available: {e}",
remediation="pip install praisonai[serve]",
)
except Exception as e:
return CheckResult(
id="a2u_module",
title="A2U Module",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.ERROR,
message=f"Error checking A2U module: {e}",
)
@register_check(
id="provider_adapters",
title="Provider Adapters",
description="Check if all provider adapters are available",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.LOW,
tags=["serve", "providers"],
)
def check_provider_adapters(config: DoctorConfig) -> CheckResult:
"""Check if all provider adapters are available."""
try:
from praisonai.endpoints.providers import (
BaseProvider,
RecipeProvider,
AgentsAPIProvider,
MCPProvider,
ToolsMCPProvider,
A2AProvider,
A2UProvider,
)
providers = [
"BaseProvider",
"RecipeProvider",
"AgentsAPIProvider",
"MCPProvider",
"ToolsMCPProvider",
"A2AProvider",
"A2UProvider",
]
return CheckResult(
id="provider_adapters",
title="Provider Adapters",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"All {len(providers)} provider adapters available",
metadata={"providers": providers},
)
except ImportError as e:
return CheckResult(
id="provider_adapters",
title="Provider Adapters",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message=f"Some provider adapters not available: {e}",
remediation="pip install praisonai[serve]",
)
except Exception as e:
return CheckResult(
id="provider_adapters",
title="Provider Adapters",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.ERROR,
message=f"Error checking provider adapters: {e}",
)
@register_check(
id="fastapi_available",
title="FastAPI Available",
description="Check if FastAPI is available for serving",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.MEDIUM,
tags=["serve", "dependencies"],
)
def check_fastapi_available(config: DoctorConfig) -> CheckResult:
"""Check if FastAPI is available."""
try:
import fastapi
version = getattr(fastapi, "__version__", "unknown")
return CheckResult(
id="fastapi_available",
title="FastAPI Available",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"FastAPI {version} available",
metadata={"version": version},
)
except ImportError:
return CheckResult(
id="fastapi_available",
title="FastAPI Available",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message="FastAPI not installed",
remediation="pip install fastapi uvicorn",
)
@register_check(
id="uvicorn_available",
title="Uvicorn Available",
description="Check if Uvicorn is available for serving",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.MEDIUM,
tags=["serve", "dependencies"],
)
def check_uvicorn_available(config: DoctorConfig) -> CheckResult:
"""Check if Uvicorn is available."""
try:
import uvicorn
version = getattr(uvicorn, "__version__", "unknown")
return CheckResult(
id="uvicorn_available",
title="Uvicorn Available",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message=f"Uvicorn {version} available",
metadata={"version": version},
)
except ImportError:
return CheckResult(
id="uvicorn_available",
title="Uvicorn Available",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN,
message="Uvicorn not installed",
remediation="pip install uvicorn",
)
@@ -0,0 +1,231 @@
"""
Skills checks for the Doctor CLI module.
Validates Agent Skills configurations and manifests.
"""
from pathlib import Path
from ..models import (
CheckCategory,
CheckResult,
CheckStatus,
CheckSeverity,
DoctorConfig,
)
from ..registry import register_check
def _find_skills_dirs() -> list:
"""Find skills directories."""
locations = [
Path.cwd() / ".praison" / "skills",
Path.cwd() / ".claude" / "skills",
Path.home() / ".praison" / "skills",
Path.home() / ".config" / "praison" / "skills",
]
found = []
for loc in locations:
if loc.exists() and loc.is_dir():
found.append(str(loc))
return found
def _validate_skill_dir(skill_path: Path) -> dict:
"""Validate a single skill directory."""
result = {"valid": False, "errors": [], "warnings": []}
skill_md = skill_path / "SKILL.md"
if not skill_md.exists():
result["errors"].append("Missing SKILL.md")
return result
try:
content = skill_md.read_text()
# Check for YAML frontmatter
if not content.startswith("---"):
result["errors"].append("SKILL.md missing YAML frontmatter")
return result
# Parse frontmatter
parts = content.split("---", 2)
if len(parts) < 3:
result["errors"].append("Invalid YAML frontmatter format")
return result
try:
import yaml
frontmatter = yaml.safe_load(parts[1])
# Check required fields
if not frontmatter.get("name"):
result["errors"].append("Missing 'name' in frontmatter")
if not frontmatter.get("description"):
result["errors"].append("Missing 'description' in frontmatter")
# Check name format
name = frontmatter.get("name", "")
if name and not name.replace("-", "").replace("_", "").isalnum():
result["warnings"].append(f"Name '{name}' should be alphanumeric with hyphens")
if not result["errors"]:
result["valid"] = True
result["name"] = name
result["description"] = frontmatter.get("description", "")[:100]
except Exception as e:
result["errors"].append(f"YAML parse error: {e}")
except Exception as e:
result["errors"].append(f"Cannot read SKILL.md: {e}")
return result
@register_check(
id="skills_dirs",
title="Skills Directories",
description="Check for skills directories",
category=CheckCategory.SKILLS,
severity=CheckSeverity.INFO,
)
def check_skills_dirs(config: DoctorConfig) -> CheckResult:
"""Check for skills directories."""
dirs = _find_skills_dirs()
if config.path:
# Check specific path
path = Path(config.path)
if path.exists():
dirs = [str(path)]
else:
return CheckResult(
id="skills_dirs",
title="Skills Directories",
category=CheckCategory.SKILLS,
status=CheckStatus.FAIL,
message=f"Specified path not found: {config.path}",
)
if dirs:
return CheckResult(
id="skills_dirs",
title="Skills Directories",
category=CheckCategory.SKILLS,
status=CheckStatus.PASS,
message=f"Found {len(dirs)} skills directory(ies)",
details=", ".join(dirs),
metadata={"directories": dirs},
)
else:
return CheckResult(
id="skills_dirs",
title="Skills Directories",
category=CheckCategory.SKILLS,
status=CheckStatus.SKIP,
message="No skills directories found (optional)",
details="Create .praison/skills/ to add agent skills",
)
@register_check(
id="skills_valid",
title="Skills Validation",
description="Validate installed skills",
category=CheckCategory.SKILLS,
severity=CheckSeverity.MEDIUM,
dependencies=["skills_dirs"],
)
def check_skills_valid(config: DoctorConfig) -> CheckResult:
"""Validate installed skills."""
dirs = _find_skills_dirs()
if config.path:
path = Path(config.path)
if path.exists():
dirs = [str(path)]
if not dirs:
return CheckResult(
id="skills_valid",
title="Skills Validation",
category=CheckCategory.SKILLS,
status=CheckStatus.SKIP,
message="No skills to validate",
)
valid_skills = []
invalid_skills = []
for dir_path in dirs:
skills_dir = Path(dir_path)
for skill_path in skills_dir.iterdir():
if skill_path.is_dir() and not skill_path.name.startswith("."):
result = _validate_skill_dir(skill_path)
if result["valid"]:
valid_skills.append(result.get("name", skill_path.name))
else:
invalid_skills.append(f"{skill_path.name}: {'; '.join(result['errors'])}")
total = len(valid_skills) + len(invalid_skills)
if total == 0:
return CheckResult(
id="skills_valid",
title="Skills Validation",
category=CheckCategory.SKILLS,
status=CheckStatus.SKIP,
message="No skills found in directories",
)
if invalid_skills:
return CheckResult(
id="skills_valid",
title="Skills Validation",
category=CheckCategory.SKILLS,
status=CheckStatus.WARN,
message=f"{len(valid_skills)}/{total} skills valid",
details="; ".join(invalid_skills[:3]) + ("..." if len(invalid_skills) > 3 else ""),
metadata={"valid": valid_skills, "invalid": invalid_skills},
)
else:
return CheckResult(
id="skills_valid",
title="Skills Validation",
category=CheckCategory.SKILLS,
status=CheckStatus.PASS,
message=f"All {total} skill(s) valid",
metadata={"valid": valid_skills},
)
@register_check(
id="skills_praisonai_integration",
title="PraisonAI Skills Integration",
description="Check PraisonAI skills module",
category=CheckCategory.SKILLS,
severity=CheckSeverity.LOW,
)
def check_skills_praisonai_integration(config: DoctorConfig) -> CheckResult:
"""Check PraisonAI skills module."""
try:
from praisonaiagents.skills import SkillManager
return CheckResult(
id="skills_praisonai_integration",
title="PraisonAI Skills Integration",
category=CheckCategory.SKILLS,
status=CheckStatus.PASS,
message="PraisonAI skills module available",
)
except ImportError as e:
return CheckResult(
id="skills_praisonai_integration",
title="PraisonAI Skills Integration",
category=CheckCategory.SKILLS,
status=CheckStatus.SKIP,
message="Skills module not available",
details=str(e),
)
@@ -0,0 +1,371 @@
"""
Tools checks for the Doctor CLI module.
Validates tool availability, dependencies, and API keys.
"""
import os
from ..models import (
CheckCategory,
CheckResult,
CheckStatus,
CheckSeverity,
DoctorConfig,
)
from ..registry import register_check
# Tool categories and their requirements
TOOL_CATEGORIES = {
"web_search": {
"tools": ["internet_search", "duckduckgo", "tavily_search", "exa_search", "searxng_search"],
"description": "Web search tools",
},
"file_ops": {
"tools": ["read_file", "write_file", "list_files", "file_tools"],
"description": "File operation tools",
},
"data": {
"tools": ["csv_tools", "json_tools", "excel_tools", "xml_tools", "yaml_tools", "pandas_tools"],
"description": "Data processing tools",
},
"database": {
"tools": ["duckdb_tools", "mongodb_tools"],
"description": "Database tools",
},
"code": {
"tools": ["python_tools", "shell_tools", "execute_code"],
"description": "Code execution tools",
},
"web_crawl": {
"tools": ["crawl4ai", "spider_tools", "newspaper_tools"],
"description": "Web crawling tools",
},
"knowledge": {
"tools": ["wiki_search", "arxiv_tools", "wikipedia_tools"],
"description": "Knowledge/research tools",
},
"finance": {
"tools": ["yfinance", "get_stock_price", "get_stock_info"],
"description": "Financial data tools",
},
}
# Tools that require API keys
TOOLS_REQUIRING_API_KEYS = {
"tavily_search": "TAVILY_API_KEY",
"tavily": "TAVILY_API_KEY",
"exa_search": "EXA_API_KEY",
"exa": "EXA_API_KEY",
"ydc_search": "YDC_API_KEY",
"ydc": "YDC_API_KEY",
}
# Tools that require optional dependencies
TOOLS_REQUIRING_DEPS = {
"crawl4ai": "crawl4ai",
"tavily_search": "tavily",
"tavily": "tavily",
"pandas_tools": "pandas",
"excel_tools": "openpyxl",
"yfinance": "yfinance",
"duckdb_tools": "duckdb",
"mongodb_tools": "pymongo",
"newspaper_tools": "newspaper3k",
"arxiv_tools": "arxiv",
}
@register_check(
id="tools_registry",
title="Tools Registry",
description="Check tool registry is accessible",
category=CheckCategory.TOOLS,
severity=CheckSeverity.MEDIUM,
)
def check_tools_registry(config: DoctorConfig) -> CheckResult:
"""Check tool registry is accessible."""
try:
from praisonaiagents.tools import TOOL_MAPPINGS
tool_count = len(TOOL_MAPPINGS)
return CheckResult(
id="tools_registry",
title="Tools Registry",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message=f"Tool registry accessible with {tool_count} tools",
metadata={"tool_count": tool_count},
)
except ImportError as e:
return CheckResult(
id="tools_registry",
title="Tools Registry",
category=CheckCategory.TOOLS,
status=CheckStatus.FAIL,
message="Cannot access tool registry",
details=str(e),
remediation="Ensure praisonaiagents is properly installed",
severity=CheckSeverity.HIGH,
)
@register_check(
id="tools_web_search",
title="Web Search Tools",
description="Check web search tool availability",
category=CheckCategory.TOOLS,
severity=CheckSeverity.MEDIUM,
)
def check_tools_web_search(config: DoctorConfig) -> CheckResult:
"""Check web search tool availability."""
available = []
missing = []
# Check DuckDuckGo (no API key required)
try:
from praisonaiagents.tools import internet_search
available.append("internet_search (DuckDuckGo)")
except ImportError:
missing.append("internet_search")
# Check Tavily
if os.environ.get("TAVILY_API_KEY"):
try:
from praisonaiagents.tools import tavily_search
available.append("tavily_search")
except ImportError:
missing.append("tavily_search (missing tavily package)")
else:
missing.append("tavily_search (TAVILY_API_KEY not set)")
# Check Exa
if os.environ.get("EXA_API_KEY"):
try:
from praisonaiagents.tools import exa_search
available.append("exa_search")
except ImportError:
missing.append("exa_search (missing exa package)")
else:
missing.append("exa_search (EXA_API_KEY not set)")
if available:
return CheckResult(
id="tools_web_search",
title="Web Search Tools",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message=f"{len(available)} web search tool(s) available",
details=", ".join(available),
metadata={"available": available, "missing": missing},
)
else:
return CheckResult(
id="tools_web_search",
title="Web Search Tools",
category=CheckCategory.TOOLS,
status=CheckStatus.WARN,
message="No web search tools available",
details=", ".join(missing),
remediation="Install duckduckgo-search or set TAVILY_API_KEY",
)
@register_check(
id="tools_file_ops",
title="File Operation Tools",
description="Check file operation tool availability",
category=CheckCategory.TOOLS,
severity=CheckSeverity.LOW,
)
def check_tools_file_ops(config: DoctorConfig) -> CheckResult:
"""Check file operation tool availability."""
try:
from praisonaiagents.tools import read_file, write_file, list_files
return CheckResult(
id="tools_file_ops",
title="File Operation Tools",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="File operation tools available (read_file, write_file, list_files)",
)
except ImportError as e:
return CheckResult(
id="tools_file_ops",
title="File Operation Tools",
category=CheckCategory.TOOLS,
status=CheckStatus.WARN,
message="Some file operation tools not available",
details=str(e),
)
@register_check(
id="tools_code_execution",
title="Code Execution Tools",
description="Check code execution tool availability",
category=CheckCategory.TOOLS,
severity=CheckSeverity.LOW,
)
def check_tools_code_execution(config: DoctorConfig) -> CheckResult:
"""Check code execution tool availability."""
available = []
try:
from praisonaiagents.tools import execute_code
available.append("execute_code")
except ImportError:
pass
try:
from praisonaiagents.tools import execute_command
available.append("execute_command")
except ImportError:
pass
if available:
return CheckResult(
id="tools_code_execution",
title="Code Execution Tools",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message=f"Code execution tools available: {', '.join(available)}",
metadata={"available": available},
)
else:
return CheckResult(
id="tools_code_execution",
title="Code Execution Tools",
category=CheckCategory.TOOLS,
status=CheckStatus.SKIP,
message="Code execution tools not loaded (optional)",
)
@register_check(
id="tools_api_keys",
title="Tool API Keys",
description="Check API keys for tools that require them",
category=CheckCategory.TOOLS,
severity=CheckSeverity.INFO,
)
def check_tools_api_keys(config: DoctorConfig) -> CheckResult:
"""Check API keys for tools that require them."""
configured = []
missing = []
api_keys = {
"TAVILY_API_KEY": "Tavily search",
"EXA_API_KEY": "Exa search",
"YDC_API_KEY": "You.com search",
"SPIDER_API_KEY": "Spider web crawling",
}
for key, description in api_keys.items():
if os.environ.get(key):
configured.append(description)
else:
missing.append(f"{description} ({key})")
if configured:
return CheckResult(
id="tools_api_keys",
title="Tool API Keys",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message=f"{len(configured)} tool API key(s) configured",
details=f"Configured: {', '.join(configured)}",
metadata={"configured": configured, "missing": missing},
)
else:
return CheckResult(
id="tools_api_keys",
title="Tool API Keys",
category=CheckCategory.TOOLS,
status=CheckStatus.SKIP,
message="No optional tool API keys configured",
details="Tools like Tavily, Exa require API keys for enhanced functionality",
)
@register_check(
id="tools_optional_deps",
title="Tool Optional Dependencies",
description="Check optional dependencies for tools",
category=CheckCategory.TOOLS,
severity=CheckSeverity.INFO,
requires_deep=True,
)
def check_tools_optional_deps(config: DoctorConfig) -> CheckResult:
"""Check optional dependencies for tools."""
available = []
missing = []
deps_to_check = [
("crawl4ai", "Crawl4AI web crawling"),
("tavily", "Tavily search"),
("pandas", "Pandas data tools"),
("openpyxl", "Excel tools"),
("yfinance", "Yahoo Finance tools"),
("duckdb", "DuckDB tools"),
("pymongo", "MongoDB tools"),
("arxiv", "arXiv tools"),
]
for package, description in deps_to_check:
try:
__import__(package)
available.append(description)
except ImportError:
missing.append(f"{description} ({package})")
total = len(deps_to_check)
return CheckResult(
id="tools_optional_deps",
title="Tool Optional Dependencies",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message=f"{len(available)}/{total} optional tool dependencies installed",
details=f"Missing: {', '.join(missing[:3])}{'...' if len(missing) > 3 else ''}" if missing else "All installed",
metadata={"available": available, "missing": missing},
)
@register_check(
id="tools_summary",
title="Tools Summary",
description="Summary of tool availability by category",
category=CheckCategory.TOOLS,
severity=CheckSeverity.INFO,
)
def check_tools_summary(config: DoctorConfig) -> CheckResult:
"""Summary of tool availability by category."""
try:
from praisonaiagents.tools import TOOL_MAPPINGS
category_counts = {}
for cat_name, cat_info in TOOL_CATEGORIES.items():
available = sum(1 for t in cat_info["tools"] if t in TOOL_MAPPINGS)
total = len(cat_info["tools"])
category_counts[cat_name] = f"{available}/{total}"
summary = ", ".join(f"{k}: {v}" for k, v in category_counts.items())
return CheckResult(
id="tools_summary",
title="Tools Summary",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message=f"Tool categories: {summary}",
metadata={"categories": category_counts},
)
except ImportError:
return CheckResult(
id="tools_summary",
title="Tools Summary",
category=CheckCategory.TOOLS,
status=CheckStatus.SKIP,
message="Cannot generate tools summary",
)
@@ -0,0 +1,266 @@
"""
Doctor engine for executing health checks.
Provides the core execution logic for running doctor checks.
"""
import os
import platform
import sys
import traceback
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
from datetime import datetime, timezone
from typing import Callable, List, Optional
import time
from .models import (
CheckCategory,
CheckResult,
CheckStatus,
CheckSeverity,
DoctorConfig,
DoctorReport,
EnvironmentSummary,
)
from .registry import CheckRegistry, get_registry
class DoctorEngine:
"""
Engine for executing doctor checks.
Handles check execution, timeout management, and report generation.
"""
def __init__(self, config: Optional[DoctorConfig] = None):
"""
Initialize the doctor engine.
Args:
config: Doctor configuration
"""
self.config = config or DoctorConfig()
self.registry = get_registry()
self._results: List[CheckResult] = []
def get_environment_summary(self) -> EnvironmentSummary:
"""Gather environment information."""
# Get package versions lazily
praisonai_version = "unknown"
praisonaiagents_version = "unknown"
try:
from praisonai.version import __version__ as pai_version
praisonai_version = pai_version
except ImportError:
pass
try:
import praisonaiagents
praisonaiagents_version = getattr(praisonaiagents, "__version__", "unknown")
except ImportError:
pass
return EnvironmentSummary(
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
python_executable=sys.executable,
os_name=platform.system(),
os_version=platform.release(),
architecture=platform.machine(),
praisonai_version=praisonai_version,
praisonaiagents_version=praisonaiagents_version,
working_directory=os.getcwd(),
virtual_env=os.environ.get("VIRTUAL_ENV"),
)
def run_check(
self,
check_id: str,
implementation: Callable,
timeout: Optional[float] = None,
) -> CheckResult:
"""
Run a single check with timeout handling.
Args:
check_id: Check identifier
implementation: Check function
timeout: Timeout in seconds
Returns:
Check result
"""
timeout = timeout or self.config.timeout
definition = self.registry.get_check(check_id)
start_time = time.time()
try:
# Run with timeout using ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(implementation, self.config)
try:
result = future.result(timeout=timeout)
result.duration_ms = (time.time() - start_time) * 1000
return result
except FuturesTimeoutError:
return CheckResult(
id=check_id,
title=definition.title if definition else check_id,
category=definition.category if definition else CheckCategory.ENVIRONMENT,
status=CheckStatus.ERROR,
message=f"Check timed out after {timeout}s",
remediation="Increase timeout with --timeout or check for hanging operations",
duration_ms=(time.time() - start_time) * 1000,
severity=CheckSeverity.HIGH,
)
except Exception as e:
# Capture exception details but redact potential secrets
tb = traceback.format_exc()
return CheckResult(
id=check_id,
title=definition.title if definition else check_id,
category=definition.category if definition else CheckCategory.ENVIRONMENT,
status=CheckStatus.ERROR,
message=f"Check failed with error: {type(e).__name__}: {str(e)[:100]}",
details=tb[:500] if not self.config.quiet else None,
remediation="Check the error details and fix the underlying issue",
duration_ms=(time.time() - start_time) * 1000,
severity=CheckSeverity.HIGH,
)
def run_checks(
self,
check_ids: Optional[List[str]] = None,
categories: Optional[List[CheckCategory]] = None,
) -> List[CheckResult]:
"""
Run multiple checks.
Args:
check_ids: Specific check IDs to run (None = all)
categories: Filter by categories
Returns:
List of check results
"""
# Get filtered checks
checks = self.registry.filter_checks(
only=check_ids or self.config.only or None,
skip=self.config.skip or None,
categories=categories,
deep_mode=self.config.deep,
)
# Resolve dependencies and get ordered list
ordered_ids = self.registry.resolve_dependencies([c.id for c in checks])
results = []
failed_deps = set()
for check_id in ordered_ids:
definition = self.registry.get_check(check_id)
implementation = self.registry.get_implementation(check_id)
if not definition or not implementation:
continue
# Check if dependencies failed
if any(dep in failed_deps for dep in definition.dependencies):
results.append(CheckResult(
id=check_id,
title=definition.title,
category=definition.category,
status=CheckStatus.SKIP,
message="Skipped due to failed dependency",
severity=definition.severity,
))
continue
# Run the check
result = self.run_check(check_id, implementation)
results.append(result)
# Track failures for dependency resolution
if result.status in (CheckStatus.FAIL, CheckStatus.ERROR):
failed_deps.add(check_id)
# Fail fast if configured
if self.config.fail_fast:
break
self._results = results
return results
def generate_report(self, results: Optional[List[CheckResult]] = None) -> DoctorReport:
"""
Generate a complete doctor report.
Args:
results: Check results (uses stored results if None)
Returns:
Complete doctor report
"""
results = results or self._results
report = DoctorReport(
version="1.0.0",
timestamp=datetime.now(timezone.utc).isoformat(),
environment=self.get_environment_summary(),
results=results,
mode="deep" if self.config.deep else "fast",
filters={
"only": self.config.only,
"skip": self.config.skip,
},
)
report.calculate_summary()
report.exit_code = report.calculate_exit_code(strict=self.config.strict)
return report
def run(
self,
check_ids: Optional[List[str]] = None,
categories: Optional[List[CheckCategory]] = None,
) -> DoctorReport:
"""
Run checks and generate report.
Args:
check_ids: Specific check IDs to run
categories: Filter by categories
Returns:
Complete doctor report
"""
start_time = time.time()
results = self.run_checks(check_ids, categories)
report = self.generate_report(results)
report.duration_ms = (time.time() - start_time) * 1000
return report
def run_doctor(
config: Optional[DoctorConfig] = None,
check_ids: Optional[List[str]] = None,
categories: Optional[List[CheckCategory]] = None,
) -> DoctorReport:
"""
Convenience function to run doctor checks.
Args:
config: Doctor configuration
check_ids: Specific check IDs to run
categories: Filter by categories
Returns:
Complete doctor report
"""
engine = DoctorEngine(config)
return engine.run(check_ids, categories)
@@ -0,0 +1,310 @@
"""
Output formatters for the Doctor CLI module.
Provides text and JSON formatting with secret redaction.
"""
import json
import re
import sys
from typing import Any, Dict, List, Optional, TextIO
from .models import CheckResult, CheckStatus, DoctorReport, ReportSummary
# Patterns for detecting secrets
SECRET_PATTERNS = [
re.compile(r'(sk-[a-zA-Z0-9]{20,})', re.IGNORECASE), # OpenAI
re.compile(r'(sk-ant-[a-zA-Z0-9-]{20,})', re.IGNORECASE), # Anthropic
re.compile(r'(AIza[a-zA-Z0-9_-]{35})', re.IGNORECASE), # Google
re.compile(r'(tvly-[a-zA-Z0-9]{20,})', re.IGNORECASE), # Tavily
re.compile(r'(xai-[a-zA-Z0-9]{20,})', re.IGNORECASE), # xAI
re.compile(r'([a-zA-Z0-9_-]*api[_-]?key[a-zA-Z0-9_-]*=\s*["\']?[a-zA-Z0-9_-]{16,})', re.IGNORECASE),
re.compile(r'([a-zA-Z0-9_-]*token[a-zA-Z0-9_-]*=\s*["\']?[a-zA-Z0-9_-]{16,})', re.IGNORECASE),
re.compile(r'([a-zA-Z0-9_-]*secret[a-zA-Z0-9_-]*=\s*["\']?[a-zA-Z0-9_-]{16,})', re.IGNORECASE),
re.compile(r'(password\s*=\s*["\']?[^\s"\']{8,})', re.IGNORECASE),
]
# Known API key environment variable names
API_KEY_ENV_VARS = [
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"GEMINI_API_KEY",
"TAVILY_API_KEY",
"EXA_API_KEY",
"COHERE_API_KEY",
"HUGGINGFACE_API_KEY",
"HF_TOKEN",
"LANGFUSE_PUBLIC_KEY",
"LANGFUSE_SECRET_KEY",
"LANGSMITH_API_KEY",
"AGENTOPS_API_KEY",
"WANDB_API_KEY",
"DATADOG_API_KEY",
"BRAINTRUST_API_KEY",
"YDC_API_KEY",
]
def redact_secrets(text: str, show_prefix_suffix: bool = False) -> str:
"""
Redact secrets from text.
Args:
text: Text to redact
show_prefix_suffix: If True, show first 4 and last 4 chars
Returns:
Text with secrets redacted
"""
if not text:
return text
result = text
for pattern in SECRET_PATTERNS:
def replace_match(match):
secret = match.group(1)
if show_prefix_suffix and len(secret) > 12:
return f"{secret[:4]}...{secret[-4:]}"
return "***REDACTED***"
result = pattern.sub(replace_match, result)
return result
def redact_dict(data: Dict[str, Any], show_prefix_suffix: bool = False) -> Dict[str, Any]:
"""
Recursively redact secrets from a dictionary.
Args:
data: Dictionary to redact
show_prefix_suffix: If True, show first 4 and last 4 chars
Returns:
Dictionary with secrets redacted
"""
result = {}
for key, value in data.items():
if isinstance(value, str):
# Check if key suggests it's a secret
key_lower = key.lower()
if any(s in key_lower for s in ["key", "token", "secret", "password", "credential"]):
if show_prefix_suffix and len(value) > 12:
result[key] = f"{value[:4]}...{value[-4:]}"
else:
result[key] = "***REDACTED***"
else:
result[key] = redact_secrets(value, show_prefix_suffix)
elif isinstance(value, dict):
result[key] = redact_dict(value, show_prefix_suffix)
elif isinstance(value, list):
result[key] = [
redact_dict(v, show_prefix_suffix) if isinstance(v, dict)
else redact_secrets(v, show_prefix_suffix) if isinstance(v, str)
else v
for v in value
]
else:
result[key] = value
return result
class BaseFormatter:
"""Base class for output formatters."""
def __init__(
self,
no_color: bool = False,
quiet: bool = False,
redact: bool = True,
show_prefix_suffix: bool = False,
):
self.no_color = no_color or not sys.stdout.isatty()
self.quiet = quiet
self.redact = redact
self.show_prefix_suffix = show_prefix_suffix
def format_report(self, report: DoctorReport) -> str:
"""Format a complete report."""
raise NotImplementedError
def format_result(self, result: CheckResult) -> str:
"""Format a single check result."""
raise NotImplementedError
def write(self, report: DoctorReport, output: Optional[TextIO] = None) -> None:
"""Write formatted report to output."""
output = output or sys.stdout
output.write(self.format_report(report))
if not self.format_report(report).endswith("\n"):
output.write("\n")
class TextFormatter(BaseFormatter):
"""Text formatter with optional color support."""
# ANSI color codes
COLORS = {
"reset": "\033[0m",
"bold": "\033[1m",
"dim": "\033[2m",
"red": "\033[31m",
"green": "\033[32m",
"yellow": "\033[33m",
"blue": "\033[34m",
"cyan": "\033[36m",
"white": "\033[37m",
}
STATUS_COLORS = {
CheckStatus.PASS: "green",
CheckStatus.WARN: "yellow",
CheckStatus.FAIL: "red",
CheckStatus.SKIP: "dim",
CheckStatus.ERROR: "red",
}
STATUS_SYMBOLS = {
CheckStatus.PASS: "✓",
CheckStatus.WARN: "⚠",
CheckStatus.FAIL: "✗",
CheckStatus.SKIP: "○",
CheckStatus.ERROR: "✗",
}
def _color(self, text: str, color: str) -> str:
"""Apply color to text if colors are enabled."""
if self.no_color:
return text
return f"{self.COLORS.get(color, '')}{text}{self.COLORS['reset']}"
def _status_symbol(self, status: CheckStatus) -> str:
"""Get colored status symbol."""
symbol = self.STATUS_SYMBOLS.get(status, "?")
color = self.STATUS_COLORS.get(status, "white")
return self._color(symbol, color)
def format_result(self, result: CheckResult) -> str:
"""Format a single check result."""
symbol = self._status_symbol(result.status)
message = result.message
if self.redact:
message = redact_secrets(message, self.show_prefix_suffix)
line = f"{symbol} {result.title}: {message}"
if result.details and not self.quiet:
details = result.details
if self.redact:
details = redact_secrets(details, self.show_prefix_suffix)
line += f"\n {self._color(details, 'dim')}"
if result.remediation and result.status in (CheckStatus.FAIL, CheckStatus.ERROR):
line += f"\n {self._color('Fix:', 'yellow')} {result.remediation}"
return line
def format_summary(self, summary: ReportSummary, strict: bool = False) -> str:
"""Format the summary section."""
parts = []
if summary.passed > 0:
parts.append(self._color(f"{summary.passed} passed", "green"))
if summary.warnings > 0:
color = "red" if strict else "yellow"
parts.append(self._color(f"{summary.warnings} warnings", color))
if summary.failed > 0:
parts.append(self._color(f"{summary.failed} failed", "red"))
if summary.skipped > 0:
parts.append(self._color(f"{summary.skipped} skipped", "dim"))
if summary.errors > 0:
parts.append(self._color(f"{summary.errors} errors", "red"))
return f"{summary.total} checks: " + ", ".join(parts)
def format_report(self, report: DoctorReport) -> str:
"""Format a complete report."""
lines = []
# Header
if not self.quiet:
header = f"PraisonAI Doctor v{report.version}"
lines.append(self._color(header, "bold"))
lines.append("━" * 70)
# Results
for result in report.results:
lines.append(self.format_result(result))
# Summary
if not self.quiet:
lines.append("━" * 70)
lines.append(self.format_summary(report.summary))
# Duration
if not self.quiet and report.duration_ms > 0:
lines.append(self._color(f"Completed in {report.duration_ms:.0f}ms", "dim"))
return "\n".join(lines)
class JsonFormatter(BaseFormatter):
"""JSON formatter with deterministic output."""
def format_result(self, result: CheckResult) -> str:
"""Format a single check result as JSON."""
data = result.to_dict()
if self.redact:
data = redact_dict(data, self.show_prefix_suffix)
return json.dumps(data, indent=2, sort_keys=True)
def format_report(self, report: DoctorReport) -> str:
"""Format a complete report as JSON."""
data = report.to_dict()
if self.redact:
data = redact_dict(data, self.show_prefix_suffix)
# Ensure deterministic ordering
return json.dumps(data, indent=2, sort_keys=True)
def write(self, report: DoctorReport, output: Optional[TextIO] = None) -> None:
"""Write formatted report to output."""
output = output or sys.stdout
output.write(self.format_report(report))
output.write("\n")
def get_formatter(
format_type: str = "text",
no_color: bool = False,
quiet: bool = False,
redact: bool = True,
show_prefix_suffix: bool = False,
) -> BaseFormatter:
"""
Get a formatter instance.
Args:
format_type: "text" or "json"
no_color: Disable ANSI colors
quiet: Minimal output
redact: Redact secrets
show_prefix_suffix: Show partial secrets
Returns:
Formatter instance
"""
if format_type == "json":
return JsonFormatter(
no_color=True, # JSON never has colors
quiet=quiet,
redact=redact,
show_prefix_suffix=show_prefix_suffix,
)
return TextFormatter(
no_color=no_color,
quiet=quiet,
redact=redact,
show_prefix_suffix=show_prefix_suffix,
)
@@ -0,0 +1,397 @@
"""
Doctor CLI handler for PraisonAI.
Provides the main DoctorHandler class that integrates with the CLI.
"""
import argparse
import sys
from typing import List, Optional
from ..base import CommandHandler
from .models import CheckCategory, DoctorConfig, DoctorReport
from .engine import DoctorEngine
from .registry import get_registry
from .formatters import get_formatter
class DoctorHandler(CommandHandler):
"""
Handler for the 'praisonai doctor' command.
Provides comprehensive health checks and diagnostics.
"""
@property
def feature_name(self) -> str:
return "doctor"
def get_actions(self) -> List[str]:
return [
"env",
"config",
"tools",
"db",
"mcp",
"obs",
"skills",
"memory",
"permissions",
"network",
"performance",
"ci",
"selftest",
]
def _register_checks(self) -> None:
"""Register all doctor checks."""
from .checks import register_all_checks
register_all_checks()
def _parse_args(self, args: List[str]) -> argparse.Namespace:
"""Parse doctor command arguments."""
parser = argparse.ArgumentParser(
prog="praisonai doctor",
description="PraisonAI health checks and diagnostics",
)
# Subcommand
parser.add_argument(
"subcommand",
nargs="?",
choices=self.get_actions() + [None],
help="Subcommand to run (default: run all fast checks)",
)
# Global flags
parser.add_argument(
"--json",
action="store_true",
help="Output in JSON format",
)
parser.add_argument(
"--format",
choices=["text", "json"],
default="text",
help="Output format (default: text)",
)
parser.add_argument(
"--output", "-o",
type=str,
help="Write report to file",
)
parser.add_argument(
"--deep",
action="store_true",
help="Enable deeper probes (DB connects, network checks)",
)
parser.add_argument(
"--timeout",
type=float,
default=10.0,
help="Per-check timeout in seconds (default: 10)",
)
parser.add_argument(
"--strict",
action="store_true",
help="Treat warnings as failures",
)
parser.add_argument(
"--quiet", "-q",
action="store_true",
help="Minimal output",
)
parser.add_argument(
"--no-color",
action="store_true",
help="Disable ANSI colors",
)
parser.add_argument(
"--only",
type=str,
help="Only run these check IDs (comma-separated)",
)
parser.add_argument(
"--skip",
type=str,
help="Skip these check IDs (comma-separated)",
)
parser.add_argument(
"--list-checks",
action="store_true",
help="List available check IDs",
)
parser.add_argument(
"--version",
action="store_true",
help="Show doctor module version",
)
# Subcommand-specific flags
parser.add_argument(
"--show-keys",
action="store_true",
help="Show masked API key values (env subcommand)",
)
parser.add_argument(
"--require",
type=str,
help="Require these env vars (comma-separated)",
)
parser.add_argument(
"--file", "-f",
type=str,
help="Config file to validate (config subcommand)",
)
parser.add_argument(
"--schema",
action="store_true",
help="Print expected schema (config subcommand)",
)
parser.add_argument(
"--dsn",
type=str,
help="Database DSN (db subcommand)",
)
parser.add_argument(
"--provider",
type=str,
help="Provider name (db/obs subcommand)",
)
parser.add_argument(
"--read-only",
action="store_true",
default=True,
help="Read-only mode for DB checks (default: true)",
)
parser.add_argument(
"--name",
type=str,
help="Name filter (mcp/tools subcommand)",
)
parser.add_argument(
"--category",
type=str,
help="Category filter (tools subcommand)",
)
parser.add_argument(
"--all",
action="store_true",
dest="all_checks",
help="Show all items (tools subcommand)",
)
parser.add_argument(
"--missing-only",
action="store_true",
help="Show only missing items (tools subcommand)",
)
parser.add_argument(
"--list-tools",
action="store_true",
help="List MCP tools (mcp subcommand)",
)
parser.add_argument(
"--path",
type=str,
help="Path to check (skills subcommand)",
)
parser.add_argument(
"--all-installed",
action="store_true",
help="Check all installed skills",
)
parser.add_argument(
"--budget-ms",
type=int,
help="Import time budget in ms (performance subcommand)",
)
parser.add_argument(
"--top",
type=int,
default=10,
dest="top_n",
help="Number of top items to show (default: 10)",
)
parser.add_argument(
"--fail-fast",
action="store_true",
help="Stop on first failure (ci subcommand)",
)
parser.add_argument(
"--mock",
action="store_true",
default=True,
help="Use mock mode (selftest, default: true)",
)
parser.add_argument(
"--live",
action="store_true",
help="Use live API calls (selftest)",
)
parser.add_argument(
"--model",
type=str,
help="Model to use for selftest",
)
parser.add_argument(
"--save-report",
action="store_true",
help="Save selftest report",
)
return parser.parse_args(args)
def _build_config(self, args: argparse.Namespace) -> DoctorConfig:
"""Build DoctorConfig from parsed arguments."""
return DoctorConfig(
deep=args.deep,
timeout=args.timeout if args.deep else min(args.timeout, 10.0),
strict=args.strict,
quiet=args.quiet,
no_color=args.no_color,
format="json" if args.json else args.format,
output_path=args.output,
only=args.only.split(",") if args.only else [],
skip=args.skip.split(",") if args.skip else [],
show_keys=args.show_keys,
require_keys=args.require.split(",") if args.require else [],
config_file=args.file,
dsn=args.dsn,
provider=args.provider,
read_only=args.read_only,
mock=args.mock and not args.live,
live=args.live,
model=args.model,
budget_ms=args.budget_ms,
top_n=args.top_n,
fail_fast=args.fail_fast,
list_tools=args.list_tools,
all_checks=args.all_checks,
missing_only=args.missing_only,
name=args.name,
category=args.category,
path=args.path,
save_report=args.save_report,
)
def _get_categories_for_subcommand(self, subcommand: Optional[str]) -> Optional[List[CheckCategory]]:
"""Get check categories for a subcommand."""
category_map = {
"env": [CheckCategory.ENVIRONMENT],
"config": [CheckCategory.CONFIG],
"tools": [CheckCategory.TOOLS],
"db": [CheckCategory.DATABASE],
"mcp": [CheckCategory.MCP],
"obs": [CheckCategory.OBSERVABILITY],
"skills": [CheckCategory.SKILLS],
"memory": [CheckCategory.MEMORY],
"permissions": [CheckCategory.PERMISSIONS],
"network": [CheckCategory.NETWORK],
"performance": [CheckCategory.PERFORMANCE],
"selftest": [CheckCategory.SELFTEST],
"ci": None, # CI runs all checks
}
return category_map.get(subcommand)
def execute(self, action: str, action_args: List[str], **kwargs) -> int:
"""
Execute the doctor command.
Args:
action: The subcommand or empty string
action_args: Additional arguments
Returns:
Exit code (0=pass, 1=fail, 2=error)
"""
# Combine action and action_args for parsing
all_args = [action] + action_args if action and action != "help" else action_args
try:
args = self._parse_args(all_args)
except SystemExit:
return 0 # Help was shown
# Handle special flags
if args.version:
from . import __version__
print(f"PraisonAI Doctor v{__version__}")
return 0
# Register all checks
self._register_checks()
if args.list_checks:
registry = get_registry()
print(registry.list_checks_text())
return 0
# Build config
config = self._build_config(args)
# Handle CI mode
if args.subcommand == "ci":
config.format = "json"
config.no_color = True
config.quiet = True
# Get categories for subcommand
categories = self._get_categories_for_subcommand(args.subcommand)
# Run doctor
engine = DoctorEngine(config)
report = engine.run(categories=categories)
# Format output
formatter = get_formatter(
format_type=config.format,
no_color=config.no_color,
quiet=config.quiet,
show_prefix_suffix=config.show_keys,
)
# Write output
if config.output_path:
with open(config.output_path, "w") as f:
formatter.write(report, f)
if not config.quiet:
print(f"Report written to: {config.output_path}")
else:
formatter.write(report, sys.stdout)
return report.exit_code
def run(self, args: List[str]) -> int:
"""
Run the doctor command from CLI.
Args:
args: Command line arguments after 'doctor'
Returns:
Exit code
"""
if not args:
return self.execute("", [])
action = args[0] if args else ""
action_args = args[1:] if len(args) > 1 else []
return self.execute(action, action_args)
def run_doctor(args: Optional[List[str]] = None) -> int:
"""
Convenience function to run doctor from CLI.
Args:
args: Command line arguments (default: sys.argv[1:])
Returns:
Exit code
"""
if args is None:
args = sys.argv[1:]
handler = DoctorHandler()
return handler.run(args)
@@ -0,0 +1,264 @@
"""
Data models for the Doctor CLI module.
Defines the core data structures for check results, reports, and configuration.
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional
import time
class CheckStatus(Enum):
"""Status of a doctor check."""
PASS = "pass"
WARN = "warn"
FAIL = "fail"
SKIP = "skip"
ERROR = "error"
class CheckCategory(Enum):
"""Category of a doctor check."""
ENVIRONMENT = "environment"
CONFIG = "config"
TOOLS = "tools"
DATABASE = "database"
MCP = "mcp"
OBSERVABILITY = "observability"
SKILLS = "skills"
MEMORY = "memory"
PERMISSIONS = "permissions"
NETWORK = "network"
PERFORMANCE = "performance"
SELFTEST = "selftest"
class CheckSeverity(Enum):
"""Severity level of a check."""
CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
INFO = "info"
@dataclass
class CheckResult:
"""Result of a single doctor check."""
id: str
title: str
category: CheckCategory
status: CheckStatus
message: str
details: Optional[str] = None
remediation: Optional[str] = None
duration_ms: float = 0.0
severity: CheckSeverity = CheckSeverity.MEDIUM
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"id": self.id,
"title": self.title,
"category": self.category.value,
"status": self.status.value,
"message": self.message,
"details": self.details,
"remediation": self.remediation,
"duration_ms": round(self.duration_ms, 2),
"severity": self.severity.value,
"metadata": self.metadata,
}
@property
def passed(self) -> bool:
"""Check if this result is considered passing."""
return self.status in (CheckStatus.PASS, CheckStatus.SKIP)
@property
def is_warning(self) -> bool:
"""Check if this result is a warning."""
return self.status == CheckStatus.WARN
@property
def is_failure(self) -> bool:
"""Check if this result is a failure."""
return self.status in (CheckStatus.FAIL, CheckStatus.ERROR)
@dataclass
class CheckDefinition:
"""Definition of a doctor check."""
id: str
title: str
description: str
category: CheckCategory
severity: CheckSeverity = CheckSeverity.MEDIUM
requires_deep: bool = False
dependencies: List[str] = field(default_factory=list)
tags: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"id": self.id,
"title": self.title,
"description": self.description,
"category": self.category.value,
"severity": self.severity.value,
"requires_deep": self.requires_deep,
"dependencies": self.dependencies,
"tags": self.tags,
}
@dataclass
class EnvironmentSummary:
"""Summary of the runtime environment."""
python_version: str = ""
python_executable: str = ""
os_name: str = ""
os_version: str = ""
architecture: str = ""
praisonai_version: str = ""
praisonaiagents_version: str = ""
working_directory: str = ""
virtual_env: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"python_version": self.python_version,
"python_executable": self.python_executable,
"os_name": self.os_name,
"os_version": self.os_version,
"architecture": self.architecture,
"praisonai_version": self.praisonai_version,
"praisonaiagents_version": self.praisonaiagents_version,
"working_directory": self.working_directory,
"virtual_env": self.virtual_env,
}
@dataclass
class ReportSummary:
"""Summary statistics for a doctor report."""
total: int = 0
passed: int = 0
warnings: int = 0
failed: int = 0
skipped: int = 0
errors: int = 0
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"total": self.total,
"passed": self.passed,
"warnings": self.warnings,
"failed": self.failed,
"skipped": self.skipped,
"errors": self.errors,
}
@dataclass
class DoctorReport:
"""Complete doctor report."""
version: str = "1.0.0"
timestamp: str = ""
duration_ms: float = 0.0
environment: EnvironmentSummary = field(default_factory=EnvironmentSummary)
results: List[CheckResult] = field(default_factory=list)
summary: ReportSummary = field(default_factory=ReportSummary)
exit_code: int = 0
mode: str = "fast"
filters: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
if not self.timestamp:
from datetime import datetime, timezone
self.timestamp = datetime.now(timezone.utc).isoformat()
def calculate_summary(self) -> None:
"""Calculate summary statistics from results."""
self.summary = ReportSummary(
total=len(self.results),
passed=sum(1 for r in self.results if r.status == CheckStatus.PASS),
warnings=sum(1 for r in self.results if r.status == CheckStatus.WARN),
failed=sum(1 for r in self.results if r.status == CheckStatus.FAIL),
skipped=sum(1 for r in self.results if r.status == CheckStatus.SKIP),
errors=sum(1 for r in self.results if r.status == CheckStatus.ERROR),
)
def calculate_exit_code(self, strict: bool = False) -> int:
"""
Calculate exit code based on results.
Args:
strict: If True, treat warnings as failures
Returns:
0: All passed (warnings allowed unless strict)
1: Failures present
2: Internal errors
"""
if self.summary.errors > 0:
return 2
if self.summary.failed > 0:
return 1
if strict and self.summary.warnings > 0:
return 1
return 0
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"version": self.version,
"timestamp": self.timestamp,
"duration_ms": round(self.duration_ms, 2),
"environment": self.environment.to_dict(),
"results": [r.to_dict() for r in self.results],
"summary": self.summary.to_dict(),
"exit_code": self.exit_code,
"mode": self.mode,
"filters": self.filters,
}
@dataclass
class DoctorConfig:
"""Configuration for doctor execution."""
deep: bool = False
timeout: float = 10.0
strict: bool = False
quiet: bool = False
no_color: bool = False
format: str = "text"
output_path: Optional[str] = None
only: List[str] = field(default_factory=list)
skip: List[str] = field(default_factory=list)
# Subcommand-specific options
show_keys: bool = False
require_keys: List[str] = field(default_factory=list)
config_file: Optional[str] = None
dsn: Optional[str] = None
provider: Optional[str] = None
read_only: bool = True
mock: bool = True
live: bool = False
model: Optional[str] = None
budget_ms: Optional[int] = None
top_n: int = 10
fail_fast: bool = False
list_tools: bool = False
all_checks: bool = False
missing_only: bool = False
name: Optional[str] = None
category: Optional[str] = None
path: Optional[str] = None
save_report: bool = False
@@ -0,0 +1,239 @@
"""
Check registry for the Doctor CLI module.
Manages registration and discovery of doctor checks.
"""
from typing import Callable, Dict, List, Optional, Set
from .models import CheckDefinition, CheckCategory, CheckSeverity, CheckResult
class CheckRegistry:
"""
Registry for doctor checks.
Manages check definitions and their implementations.
"""
_instance: Optional["CheckRegistry"] = None
def __new__(cls) -> "CheckRegistry":
"""Singleton pattern for global registry."""
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._checks: Dict[str, CheckDefinition] = {}
cls._instance._implementations: Dict[str, Callable] = {}
cls._instance._initialized = False
return cls._instance
@classmethod
def get_instance(cls) -> "CheckRegistry":
"""Get the singleton instance."""
return cls()
@classmethod
def reset(cls) -> None:
"""Reset the registry (for testing)."""
cls._instance = None
def register(
self,
id: str,
title: str,
description: str,
category: CheckCategory,
implementation: Callable,
severity: CheckSeverity = CheckSeverity.MEDIUM,
requires_deep: bool = False,
dependencies: Optional[List[str]] = None,
tags: Optional[List[str]] = None,
) -> None:
"""
Register a doctor check.
Args:
id: Unique identifier (snake_case)
title: Human-readable title
description: Short description of what the check does
category: Check category
implementation: Callable that performs the check
severity: Severity level
requires_deep: Whether this check requires --deep mode
dependencies: List of check IDs this check depends on
tags: Optional tags for filtering
"""
definition = CheckDefinition(
id=id,
title=title,
description=description,
category=category,
severity=severity,
requires_deep=requires_deep,
dependencies=dependencies or [],
tags=tags or [],
)
self._checks[id] = definition
self._implementations[id] = implementation
def get_check(self, id: str) -> Optional[CheckDefinition]:
"""Get a check definition by ID."""
return self._checks.get(id)
def get_implementation(self, id: str) -> Optional[Callable]:
"""Get a check implementation by ID."""
return self._implementations.get(id)
def get_all_checks(self) -> List[CheckDefinition]:
"""Get all registered checks."""
return list(self._checks.values())
def get_check_ids(self) -> List[str]:
"""Get all registered check IDs."""
return list(self._checks.keys())
def get_checks_by_category(self, category: CheckCategory) -> List[CheckDefinition]:
"""Get all checks in a category."""
return [c for c in self._checks.values() if c.category == category]
def get_checks_by_tag(self, tag: str) -> List[CheckDefinition]:
"""Get all checks with a specific tag."""
return [c for c in self._checks.values() if tag in c.tags]
def filter_checks(
self,
only: Optional[List[str]] = None,
skip: Optional[List[str]] = None,
categories: Optional[List[CheckCategory]] = None,
deep_mode: bool = False,
) -> List[CheckDefinition]:
"""
Filter checks based on criteria.
Args:
only: Only include these check IDs
skip: Skip these check IDs
categories: Only include checks in these categories
deep_mode: Include checks that require deep mode
Returns:
Filtered list of check definitions
"""
checks = list(self._checks.values())
# Filter by only
if only:
only_set = set(only)
checks = [c for c in checks if c.id in only_set]
# Filter by skip
if skip:
skip_set = set(skip)
checks = [c for c in checks if c.id not in skip_set]
# Filter by categories
if categories:
checks = [c for c in checks if c.category in categories]
# Filter by deep mode
if not deep_mode:
checks = [c for c in checks if not c.requires_deep]
return checks
def resolve_dependencies(self, check_ids: List[str]) -> List[str]:
"""
Resolve check dependencies and return ordered list.
Args:
check_ids: List of check IDs to resolve
Returns:
Ordered list of check IDs with dependencies first
"""
resolved: List[str] = []
seen: Set[str] = set()
def resolve(id: str) -> None:
if id in seen:
return
seen.add(id)
check = self._checks.get(id)
if check:
for dep in check.dependencies:
resolve(dep)
resolved.append(id)
for id in check_ids:
resolve(id)
return resolved
def list_checks_text(self) -> str:
"""Generate text listing of all checks."""
lines = ["Available Doctor Checks:", ""]
# Group by category
by_category: Dict[CheckCategory, List[CheckDefinition]] = {}
for check in self._checks.values():
if check.category not in by_category:
by_category[check.category] = []
by_category[check.category].append(check)
for category in CheckCategory:
if category in by_category:
lines.append(f" {category.value.upper()}:")
for check in sorted(by_category[category], key=lambda c: c.id):
deep_marker = " [deep]" if check.requires_deep else ""
lines.append(f" {check.id:<30} {check.description}{deep_marker}")
lines.append("")
return "\n".join(lines)
# Global registry instance
_registry = CheckRegistry()
def register_check(
id: str,
title: str,
description: str,
category: CheckCategory,
severity: CheckSeverity = CheckSeverity.MEDIUM,
requires_deep: bool = False,
dependencies: Optional[List[str]] = None,
tags: Optional[List[str]] = None,
):
"""
Decorator to register a doctor check.
Usage:
@register_check(
id="python_version",
title="Python Version",
description="Check Python version is 3.9+",
category=CheckCategory.ENVIRONMENT,
)
def check_python_version(config: DoctorConfig) -> CheckResult:
...
"""
def decorator(func: Callable) -> Callable:
_registry.register(
id=id,
title=title,
description=description,
category=category,
implementation=func,
severity=severity,
requires_deep=requires_deep,
dependencies=dependencies,
tags=tags,
)
return func
return decorator
def get_registry() -> CheckRegistry:
"""Get the global check registry."""
return _registry
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,244 @@
"""
Lite Agent Handler for CLI.
Provides access to the lightweight praisonaiagents.lite subpackage.
Usage: praisonai lite [run|info]
"""
import os
from typing import Any, Dict, Tuple
from .base import FlagHandler
class LiteHandler(FlagHandler):
"""
Handler for lite agent commands.
The lite subpackage provides a minimal agent framework without heavy
dependencies like litellm. Users can bring their own LLM client.
Commands:
praisonai lite run "prompt" - Run a lite agent with custom LLM
praisonai lite info - Show lite package info
praisonai lite example - Show example usage
"""
@property
def feature_name(self) -> str:
return "lite"
@property
def flag_name(self) -> str:
return "lite"
@property
def flag_help(self) -> str:
return "Use lightweight agent without heavy dependencies"
def check_dependencies(self) -> Tuple[bool, str]:
"""Check if lite package is available."""
import importlib.util
if importlib.util.find_spec("praisonaiagents.lite") is not None:
return True, ""
return False, "Lite package requires praisonaiagents>=0.5.0"
def get_info(self) -> Dict[str, Any]:
"""
Get information about the lite package.
Returns:
Dict with package info
"""
import importlib.util
if importlib.util.find_spec("praisonaiagents.lite") is None:
return {
"available": False,
"error": "praisonaiagents.lite not found"
}
return {
"available": True,
"classes": ["LiteAgent", "LiteTask", "LiteToolResult"],
"decorators": ["@tool"],
"helpers": ["create_openai_llm_fn", "create_anthropic_llm_fn"],
"features": [
"BYO-LLM (Bring Your Own LLM)",
"Thread-safe chat history",
"Tool execution",
"No litellm dependency",
"Minimal memory footprint"
]
}
def show_example(self) -> str:
"""
Show example usage of lite package.
Returns:
Example code string
"""
example = '''
# Example: Using praisonaiagents.lite with custom LLM
from praisonaiagents.lite import LiteAgent, tool
# Define a custom LLM function
def my_llm(messages):
"""Your custom LLM implementation."""
# Example: Call OpenAI directly
import openai
client = openai.OpenAI()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages
)
return response.choices[0].message.content
# Or use the built-in OpenAI adapter
from praisonaiagents.lite import create_openai_llm_fn
llm_fn = create_openai_llm_fn(model="gpt-4o-mini")
# Create a lite agent
agent = LiteAgent(
name="MyAgent",
llm_fn=llm_fn,
instructions="You are a helpful assistant."
)
# Chat with the agent
response = agent.chat("Hello!")
print(response)
# Define and use tools
@tool
def add_numbers(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
agent_with_tools = LiteAgent(
name="MathAgent",
llm_fn=llm_fn,
tools=[add_numbers]
)
# Execute tool directly
result = agent_with_tools.execute_tool("add_numbers", a=5, b=3)
print(f"Result: {result.output}") # Output: 8
'''
return example
def run_lite_agent(
self,
prompt: str,
model: str = "gpt-4o-mini",
provider: str = "openai"
) -> str:
"""
Run a lite agent with the given prompt.
Args:
prompt: The prompt to send to the agent
model: Model name to use
provider: LLM provider (openai, anthropic)
Returns:
Agent response
"""
try:
from praisonaiagents.lite import LiteAgent, create_openai_llm_fn, create_anthropic_llm_fn
# Create LLM function based on provider
if provider == "openai":
if not os.environ.get("OPENAI_API_KEY"):
return "Error: OPENAI_API_KEY not set"
llm_fn = create_openai_llm_fn(model=model)
elif provider == "anthropic":
if not os.environ.get("ANTHROPIC_API_KEY"):
return "Error: ANTHROPIC_API_KEY not set"
llm_fn = create_anthropic_llm_fn(model=model)
else:
return f"Error: Unknown provider '{provider}'. Use 'openai' or 'anthropic'."
# Create and run agent
agent = LiteAgent(
name="LiteCLIAgent",
llm_fn=llm_fn,
instructions="You are a helpful assistant. Keep responses concise."
)
return agent.chat(prompt)
except ImportError as e:
return f"Error: {e}"
except Exception as e:
return f"Error: {e}"
def handle(self, subcommand: str = "info", **kwargs) -> Any:
"""
Handle lite subcommands.
Args:
subcommand: One of run, info, example
**kwargs: Additional options (prompt, model, provider)
Returns:
Results
"""
if subcommand == "info":
info = self.get_info()
if info.get("available"):
print("praisonaiagents.lite - Lightweight Agent Package")
print("=" * 50)
print("\nClasses:", ", ".join(info["classes"]))
print("Decorators:", ", ".join(info["decorators"]))
print("Helpers:", ", ".join(info["helpers"]))
print("\nFeatures:")
for feature in info["features"]:
print(f" • {feature}")
else:
print(f"Lite package not available: {info.get('error')}")
return info
elif subcommand == "example":
example = self.show_example()
print(example)
return example
elif subcommand == "run":
prompt = kwargs.get("prompt", "Hello!")
model = kwargs.get("model", "gpt-4o-mini")
provider = kwargs.get("provider", "openai")
response = self.run_lite_agent(prompt, model, provider)
print(response)
return response
else:
print(f"Unknown subcommand: {subcommand}")
print("Available: info, example, run")
return None
def run_lite_command(args) -> int:
"""
Run lite command from CLI args.
Args:
args: Parsed CLI arguments
Returns:
Exit code (0 for success)
"""
handler = LiteHandler()
subcommand = getattr(args, 'lite_command', 'info')
kwargs = {}
if hasattr(args, 'prompt'):
kwargs['prompt'] = args.prompt
if hasattr(args, 'model'):
kwargs['model'] = args.model
if hasattr(args, 'provider'):
kwargs['provider'] = args.provider
result = handler.handle(subcommand, **kwargs)
return 0 if result else 1
@@ -0,0 +1,308 @@
"""
Performance Handler for CLI.
Provides performance benchmarking and regression testing.
Usage: praisonai perf [benchmark|check|import-time|memory]
"""
import os
import sys
import subprocess
from typing import Any, Dict, Tuple
from .base import FlagHandler
class PerformanceHandler(FlagHandler):
"""
Handler for performance benchmarking commands.
Commands:
praisonai perf benchmark - Run full performance benchmark
praisonai perf import-time - Measure import time only
praisonai perf memory - Measure memory usage only
praisonai perf check - Run regression check against targets
praisonai perf lazy-check - Verify lazy imports are working
"""
# Performance targets
IMPORT_TIME_TARGET_MS = 200
IMPORT_TIME_HARD_FAIL_MS = 300
MEMORY_TARGET_MB = 30
MEMORY_HARD_FAIL_MB = 45
@property
def feature_name(self) -> str:
return "performance"
@property
def flag_name(self) -> str:
return "perf"
@property
def flag_help(self) -> str:
return "Run performance benchmarks and regression checks"
def check_dependencies(self) -> Tuple[bool, str]:
"""Check if performance testing is available."""
return True, ""
def measure_import_time(self, runs: int = 5) -> Dict[str, Any]:
"""
Measure import time for praisonaiagents.
Args:
runs: Number of runs to average
Returns:
Dict with timing statistics
"""
times = []
for _ in range(runs):
code = """
import time
start = time.perf_counter()
import praisonaiagents
end = time.perf_counter()
print(f"{(end - start) * 1000:.1f}")
"""
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
)
if result.returncode == 0:
try:
times.append(float(result.stdout.strip()))
except ValueError:
pass
if not times:
return {"error": "Failed to measure import time"}
times.sort()
median = times[len(times) // 2]
return {
"times": times,
"min": min(times),
"max": max(times),
"median": median,
"mean": sum(times) / len(times),
"pass": median < self.IMPORT_TIME_TARGET_MS,
"hard_fail": median > self.IMPORT_TIME_HARD_FAIL_MS
}
def measure_memory(self) -> Dict[str, Any]:
"""
Measure memory usage after importing praisonaiagents.
Returns:
Dict with memory statistics
"""
code = """
import tracemalloc
tracemalloc.start()
import praisonaiagents
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"{current / 1024 / 1024:.1f},{peak / 1024 / 1024:.1f}")
"""
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
)
if result.returncode != 0:
return {"error": f"Failed to measure memory: {result.stderr}"}
try:
current, peak = result.stdout.strip().split(",")
current_mb = float(current)
peak_mb = float(peak)
return {
"current_mb": current_mb,
"peak_mb": peak_mb,
"pass": current_mb < self.MEMORY_TARGET_MB,
"hard_fail": current_mb > self.MEMORY_HARD_FAIL_MB
}
except (ValueError, IndexError) as e:
return {"error": f"Failed to parse memory output: {e}"}
def check_lazy_imports(self) -> Dict[str, bool]:
"""
Check that heavy dependencies are not loaded at import time.
Returns:
Dict mapping module name to whether it's lazy (True = good)
"""
heavy_modules = ["litellm", "chromadb", "mem0", "requests"]
results = {}
for module in heavy_modules:
code = f"""
import sys
# Clear any cached imports
for key in list(sys.modules.keys()):
if key.startswith('{module}'):
del sys.modules[key]
import praisonaiagents
print('{module}' not in sys.modules)
"""
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
)
if result.returncode == 0:
results[module] = result.stdout.strip() == "True"
else:
results[module] = False
return results
def run_benchmark(self, verbose: bool = True) -> Dict[str, Any]:
"""
Run full performance benchmark.
Args:
verbose: Print results to console
Returns:
Dict with all benchmark results
"""
results = {}
if verbose:
print("=" * 60)
print("PraisonAI Agents Performance Benchmark")
print("=" * 60)
# Import time
if verbose:
print("\n[1/3] Measuring import time...")
results["import_time"] = self.measure_import_time()
if verbose and "error" not in results["import_time"]:
median = results["import_time"]["median"]
status = "PASS" if results["import_time"]["pass"] else "FAIL"
print(f" Median: {median:.1f}ms [{status}]")
# Memory
if verbose:
print("\n[2/3] Measuring memory usage...")
results["memory"] = self.measure_memory()
if verbose and "error" not in results["memory"]:
current = results["memory"]["current_mb"]
status = "PASS" if results["memory"]["pass"] else "WARN" if not results["memory"]["hard_fail"] else "FAIL"
print(f" Current: {current:.1f}MB [{status}]")
# Lazy imports
if verbose:
print("\n[3/3] Checking lazy imports...")
results["lazy_imports"] = self.check_lazy_imports()
if verbose:
all_lazy = all(results["lazy_imports"].values())
status = "PASS" if all_lazy else "FAIL"
print(f" All lazy: {all_lazy} [{status}]")
for module, is_lazy in results["lazy_imports"].items():
symbol = "✓" if is_lazy else "✗"
print(f" {symbol} {module}")
# Overall
overall_pass = (
results["import_time"].get("pass", False) and
not results["memory"].get("hard_fail", True) and
all(results["lazy_imports"].values())
)
results["overall_pass"] = overall_pass
if verbose:
print("\n" + "=" * 60)
status = "PASS" if overall_pass else "FAIL"
print(f"Overall: [{status}]")
print("=" * 60)
return results
def run_check(self) -> bool:
"""
Run regression check against targets.
Returns:
True if all checks pass
"""
results = self.run_benchmark(verbose=True)
return results.get("overall_pass", False)
def handle(self, subcommand: str = "benchmark", **kwargs) -> Any:
"""
Handle performance subcommands.
Args:
subcommand: One of benchmark, import-time, memory, check, lazy-check
**kwargs: Additional options
Returns:
Results dict or bool
"""
if subcommand == "benchmark":
return self.run_benchmark(verbose=True)
elif subcommand == "import-time":
result = self.measure_import_time()
if "error" not in result:
print(f"Import time: {result['median']:.1f}ms (median)")
print(f"Target: <{self.IMPORT_TIME_TARGET_MS}ms")
print(f"Status: {'PASS' if result['pass'] else 'FAIL'}")
return result
elif subcommand == "memory":
result = self.measure_memory()
if "error" not in result:
print(f"Memory: {result['current_mb']:.1f}MB")
print(f"Target: <{self.MEMORY_TARGET_MB}MB")
print(f"Status: {'PASS' if result['pass'] else 'WARN' if not result['hard_fail'] else 'FAIL'}")
return result
elif subcommand == "check":
return self.run_check()
elif subcommand == "lazy-check":
results = self.check_lazy_imports()
print("Lazy Import Check:")
for module, is_lazy in results.items():
status = "LAZY (good)" if is_lazy else "EAGER (bad)"
print(f" {module}: {status}")
return all(results.values())
else:
print(f"Unknown subcommand: {subcommand}")
print("Available: benchmark, import-time, memory, check, lazy-check")
return False
# Convenience function for CLI integration
def run_performance_command(args) -> int:
"""
Run performance command from CLI args.
Args:
args: Parsed CLI arguments
Returns:
Exit code (0 for success)
"""
handler = PerformanceHandler()
subcommand = getattr(args, 'perf_command', 'benchmark')
result = handler.handle(subcommand)
if isinstance(result, bool):
return 0 if result else 1
elif isinstance(result, dict):
return 0 if result.get("overall_pass", result.get("pass", False)) else 1
return 0
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,449 @@
"""
Recipes CLI - Commands for managing and running Agent-Recipes.
Provides commands for:
- praison recipes list - List available recipes
- praison recipes info <name> - Show recipe details
- praison recipes run <name> <input> - Run a recipe
- praison recipes doctor <name> - Check recipe dependencies
- praison recipes explain <name> - Show recipe execution plan
- praison recipes init <name> - Initialize recipe in current directory
"""
import os
import sys
import json
import yaml
import argparse
import logging
from pathlib import Path
from typing import Optional, List, Dict, Any
logger = logging.getLogger(__name__)
# Recipe discovery paths in order of precedence
RECIPE_PATHS = [
Path.home() / ".praison" / "templates",
Path.home() / ".config" / "praison" / "templates",
Path.cwd() / ".praison" / "templates",
Path("/Users/praison/Agent-Recipes/agent_recipes/templates"),
]
def find_recipe_paths() -> List[Path]:
"""Find all valid recipe directories."""
paths = []
for p in RECIPE_PATHS:
if p.exists() and p.is_dir():
paths.append(p)
return paths
def list_recipes() -> List[Dict[str, Any]]:
"""List all available recipes."""
recipes = []
seen = set()
for base_path in find_recipe_paths():
for recipe_dir in base_path.iterdir():
if not recipe_dir.is_dir():
continue
template_file = recipe_dir / "TEMPLATE.yaml"
if not template_file.exists():
continue
name = recipe_dir.name
if name in seen:
continue
seen.add(name)
try:
with open(template_file) as f:
template = yaml.safe_load(f)
recipes.append({
"name": name,
"description": template.get("description", ""),
"version": template.get("version", "1.0.0"),
"tags": template.get("tags", []),
"path": str(recipe_dir),
})
except Exception as e:
logger.warning(f"Failed to load recipe {name}: {e}")
return sorted(recipes, key=lambda x: x["name"])
def get_recipe(name: str) -> Optional[Dict[str, Any]]:
"""Get recipe by name."""
for base_path in find_recipe_paths():
recipe_dir = base_path / name
template_file = recipe_dir / "TEMPLATE.yaml"
if template_file.exists():
with open(template_file) as f:
template = yaml.safe_load(f)
template["path"] = str(recipe_dir)
return template
return None
def check_dependencies(recipe: Dict[str, Any]) -> Dict[str, bool]:
"""Check if recipe dependencies are satisfied."""
results = {}
requires = recipe.get("requires", {})
# Check environment variables
for env_var in requires.get("env", []):
results[f"env:{env_var}"] = bool(os.environ.get(env_var))
# Check Python packages
for package in requires.get("packages", []):
try:
__import__(package.replace("-", "_").split("[")[0])
results[f"package:{package}"] = True
except ImportError:
results[f"package:{package}"] = False
# Check external tools
import shutil
for tool in requires.get("external", []):
results[f"external:{tool}"] = shutil.which(tool) is not None
# Check recipe tools
for tool in requires.get("tools", []):
try:
from praisonai_tools.recipe_tools import __all__ as available_tools
# Check if tool class exists
tool_class = tool.replace("_tool", "").title() + "Tool"
results[f"tool:{tool}"] = tool_class in available_tools or tool in str(available_tools)
except ImportError:
results[f"tool:{tool}"] = False
return results
def cmd_list(args: argparse.Namespace) -> int:
"""List available recipes."""
recipes = list_recipes()
if args.json:
print(json.dumps(recipes, indent=2))
return 0
if not recipes:
print("No recipes found.")
return 0
# Group by first tag if available
if args.group:
groups = {}
for r in recipes:
tag = r["tags"][0] if r["tags"] else "other"
if tag not in groups:
groups[tag] = []
groups[tag].append(r)
for group, group_recipes in sorted(groups.items()):
print(f"\n{group.upper()}:")
for r in group_recipes:
print(f" {r['name']:<35} {r['description'][:50]}")
else:
print(f"{'Recipe':<35} {'Description':<50} {'Version'}")
print("-" * 95)
for r in recipes:
desc = r["description"][:47] + "..." if len(r["description"]) > 50 else r["description"]
print(f"{r['name']:<35} {desc:<50} {r['version']}")
print(f"\nTotal: {len(recipes)} recipes")
return 0
def cmd_info(args: argparse.Namespace) -> int:
"""Show recipe details."""
recipe = get_recipe(args.name)
if not recipe:
print(f"Recipe not found: {args.name}")
return 1
if args.json:
print(json.dumps(recipe, indent=2, default=str))
return 0
print(f"Name: {recipe.get('name', args.name)}")
print(f"Version: {recipe.get('version', '1.0.0')}")
print(f"Description: {recipe.get('description', '')}")
print(f"Author: {recipe.get('author', 'unknown')}")
print(f"License: {recipe.get('license', 'unknown')}")
print(f"Path: {recipe.get('path', '')}")
if recipe.get("tags"):
print(f"Tags: {', '.join(recipe['tags'])}")
requires = recipe.get("requires", {})
if requires:
print("\nRequirements:")
if requires.get("env"):
print(f" Environment: {', '.join(requires['env'])}")
if requires.get("packages"):
print(f" Packages: {', '.join(requires['packages'])}")
if requires.get("external"):
print(f" External: {', '.join(requires['external'])}")
if requires.get("tools"):
print(f" Tools: {', '.join(requires['tools'])}")
cli = recipe.get("cli", {})
if cli:
print("\nCLI:")
print(f" Command: {cli.get('command', '')}")
if cli.get("examples"):
print(" Examples:")
for ex in cli["examples"]:
print(f" {ex}")
safety = recipe.get("safety", {})
if safety:
print("\nSafety:")
print(f" Dry-run default: {safety.get('dry_run_default', False)}")
if safety.get("requires_consent"):
print(f" Requires consent: {safety.get('consent_message', 'Yes')}")
if safety.get("legal_disclaimer"):
print(f" Legal disclaimer: {safety.get('legal_disclaimer')}")
return 0
def cmd_doctor(args: argparse.Namespace) -> int:
"""Check recipe dependencies."""
recipe = get_recipe(args.name)
if not recipe:
print(f"Recipe not found: {args.name}")
return 1
print(f"Checking dependencies for: {args.name}")
print("-" * 50)
deps = check_dependencies(recipe)
all_ok = True
for dep, status in sorted(deps.items()):
icon = "✓" if status else "✗"
color = "\033[92m" if status else "\033[91m"
reset = "\033[0m"
print(f" {color}{icon}{reset} {dep}")
if not status:
all_ok = False
print("-" * 50)
if all_ok:
print("All dependencies satisfied!")
return 0
else:
print("\nMissing dependencies. Install with:")
requires = recipe.get("requires", {})
if requires.get("packages"):
missing_pkgs = [p for p in requires["packages"] if not deps.get(f"package:{p}", True)]
if missing_pkgs:
print(f" pip install {' '.join(missing_pkgs)}")
if requires.get("external"):
missing_ext = [e for e in requires["external"] if not deps.get(f"external:{e}", True)]
if missing_ext:
print(f" # Install external tools: {', '.join(missing_ext)}")
if requires.get("env"):
missing_env = [e for e in requires["env"] if not deps.get(f"env:{e}", True)]
if missing_env:
print(f" # Set environment variables: {', '.join(missing_env)}")
return 1
def cmd_run(args: argparse.Namespace) -> int:
"""Run a recipe."""
recipe = get_recipe(args.name)
if not recipe:
print(f"Recipe not found: {args.name}")
return 1
# Check safety requirements
safety = recipe.get("safety", {})
if safety.get("requires_consent") and not args.consent:
print(f"This recipe requires consent: {safety.get('consent_message', '')}")
print("Use --consent flag to acknowledge.")
return 1
if safety.get("legal_disclaimer"):
print(f"LEGAL DISCLAIMER: {safety['legal_disclaimer']}")
# Check dependencies
if not args.skip_checks:
deps = check_dependencies(recipe)
missing = [k for k, v in deps.items() if not v]
if missing:
print(f"Missing dependencies: {', '.join(missing)}")
print("Run 'praison recipes doctor {args.name}' for details.")
if not args.force:
return 1
# Dry run mode
if args.dry_run or (safety.get("dry_run_default") and not args.write):
print(f"DRY RUN: Would execute recipe '{args.name}'")
print(f" Input: {args.input}")
print(f" Output: {args.output or 'default'}")
print("\nUse --write to execute.")
return 0
# Execute recipe
print(f"Running recipe: {args.name}")
print(f"Input: {args.input}")
# TODO: Implement actual recipe execution via praisonaiagents
# For now, show what would be executed
print("\nRecipe execution would involve:")
for tool in recipe.get("requires", {}).get("tools", []):
print(f" - Using {tool}")
print("\nRecipe execution completed (placeholder).")
return 0
def cmd_explain(args: argparse.Namespace) -> int:
"""Explain recipe execution plan."""
recipe = get_recipe(args.name)
if not recipe:
print(f"Recipe not found: {args.name}")
return 1
print(f"Execution Plan for: {args.name}")
print("=" * 50)
print("\n1. Dependency Check:")
for tool in recipe.get("requires", {}).get("tools", []):
print(f" - Load {tool}")
print("\n2. Input Processing:")
inputs = recipe.get("inputs", {})
for name, spec in inputs.items():
print(f" - {name}: {spec.get('type', 'unknown')}")
print("\n3. Execution:")
print(f" - Run recipe workflow")
print("\n4. Output:")
outputs = recipe.get("outputs", {})
for name, spec in outputs.items():
print(f" - {name}: {spec.get('type', 'unknown')}")
return 0
def cmd_init(args: argparse.Namespace) -> int:
"""Initialize recipe in current directory."""
recipe = get_recipe(args.name)
if not recipe:
print(f"Recipe not found: {args.name}")
return 1
target_dir = Path(args.output) if args.output else Path.cwd() / args.name
if target_dir.exists() and not args.force:
print(f"Directory already exists: {target_dir}")
print("Use --force to overwrite.")
return 1
import shutil
source_dir = Path(recipe["path"])
if args.dry_run:
print(f"DRY RUN: Would copy {source_dir} to {target_dir}")
return 0
target_dir.mkdir(parents=True, exist_ok=True)
# Copy recipe files
for item in source_dir.iterdir():
if item.is_file():
shutil.copy2(item, target_dir / item.name)
print(f"Initialized recipe '{args.name}' in {target_dir}")
return 0
def setup_parser(subparsers: argparse._SubParsersAction) -> None:
"""Setup recipes subcommand parser."""
recipes_parser = subparsers.add_parser(
"recipes",
help="Manage and run Agent-Recipes",
description="Commands for managing and running Agent-Recipes templates.",
)
recipes_subparsers = recipes_parser.add_subparsers(dest="recipes_command")
# List command
list_parser = recipes_subparsers.add_parser("list", help="List available recipes")
list_parser.add_argument("--json", action="store_true", help="Output as JSON")
list_parser.add_argument("--group", action="store_true", help="Group by category")
list_parser.set_defaults(func=cmd_list)
# Info command
info_parser = recipes_subparsers.add_parser("info", help="Show recipe details")
info_parser.add_argument("name", help="Recipe name")
info_parser.add_argument("--json", action="store_true", help="Output as JSON")
info_parser.set_defaults(func=cmd_info)
# Doctor command
doctor_parser = recipes_subparsers.add_parser("doctor", help="Check recipe dependencies")
doctor_parser.add_argument("name", help="Recipe name")
doctor_parser.set_defaults(func=cmd_doctor)
# Run command
run_parser = recipes_subparsers.add_parser("run", help="Run a recipe")
run_parser.add_argument("name", help="Recipe name")
run_parser.add_argument("input", nargs="?", help="Input file or value")
run_parser.add_argument("--output", "-o", help="Output directory")
run_parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
run_parser.add_argument("--write", action="store_true", help="Actually execute (for dry-run-default recipes)")
run_parser.add_argument("--force", action="store_true", help="Force execution despite missing deps")
run_parser.add_argument("--consent", action="store_true", help="Acknowledge consent requirements")
run_parser.add_argument("--skip-checks", action="store_true", help="Skip dependency checks")
run_parser.add_argument("--json", action="store_true", help="Output as JSON")
run_parser.set_defaults(func=cmd_run)
# Explain command
explain_parser = recipes_subparsers.add_parser("explain", help="Explain recipe execution plan")
explain_parser.add_argument("name", help="Recipe name")
explain_parser.set_defaults(func=cmd_explain)
# Init command
init_parser = recipes_subparsers.add_parser("init", help="Initialize recipe in directory")
init_parser.add_argument("name", help="Recipe name")
init_parser.add_argument("--output", "-o", help="Target directory")
init_parser.add_argument("--force", action="store_true", help="Overwrite existing")
init_parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
init_parser.set_defaults(func=cmd_init)
def handle_recipes_command(args: argparse.Namespace) -> int:
"""Handle recipes subcommand."""
if not hasattr(args, "func"):
# No subcommand specified, show help
print("Usage: praison recipes <command> [options]")
print("\nCommands:")
print(" list List available recipes")
print(" info Show recipe details")
print(" doctor Check recipe dependencies")
print(" run Run a recipe")
print(" explain Explain recipe execution plan")
print(" init Initialize recipe in directory")
return 0
return args.func(args)
@@ -0,0 +1,812 @@
"""
Serve CLI Feature Handler
Provides CLI commands for launching PraisonAI servers:
- praisonai serve agents - Launch agents as HTTP API
- praisonai serve recipe - Launch recipe runner
- praisonai serve mcp - Launch MCP server
- praisonai serve tools - Launch tools as MCP server
- praisonai serve a2a - Launch A2A server
- praisonai serve a2u - Launch A2U event stream server
- praisonai serve unified - Launch unified server with all providers
All servers include the unified discovery endpoint at /__praisonai__/discovery.
"""
import os
import sys
from typing import Any, Dict, List
class ServeHandler:
"""
CLI handler for serve operations.
Commands:
- agents: Launch agents as HTTP API
- recipe: Launch recipe runner
- mcp: Launch MCP server
- tools: Launch tools as MCP server
- a2a: Launch A2A server
- a2u: Launch A2U event stream server
- unified: Launch unified server
"""
EXIT_SUCCESS = 0
EXIT_GENERAL_ERROR = 1
EXIT_VALIDATION_ERROR = 2
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8765
def __init__(self):
"""Initialize the handler."""
pass
def handle(self, args: List[str]) -> int:
"""
Handle serve subcommand.
Args:
args: Command arguments
Returns:
Exit code
"""
if not args:
self._print_help()
return self.EXIT_SUCCESS
command = args[0]
remaining = args[1:]
commands = {
"agents": self.cmd_agents,
"recipe": self.cmd_recipe,
"mcp": self.cmd_mcp,
"tools": self.cmd_tools,
"a2a": self.cmd_a2a,
"a2u": self.cmd_a2u,
"unified": self.cmd_unified,
"help": lambda _: self._print_help() or self.EXIT_SUCCESS,
"--help": lambda _: self._print_help() or self.EXIT_SUCCESS,
"-h": lambda _: self._print_help() or self.EXIT_SUCCESS,
}
if command in commands:
return commands[command](remaining)
else:
self._print_error(f"Unknown command: {command}")
self._print_help()
return self.EXIT_GENERAL_ERROR
def _print_help(self):
"""Print help message."""
help_text = """
[bold cyan]PraisonAI Serve[/bold cyan]
Launch PraisonAI servers with unified discovery support.
[bold]Usage:[/bold]
praisonai serve <command> [options]
[bold]Commands:[/bold]
agents Launch agents as HTTP API
recipe Launch recipe runner server
mcp Launch MCP server (HTTP mode)
tools Launch tools as MCP server
a2a Launch A2A protocol server
a2u Launch A2U event stream server
unified Launch unified server with all providers
[bold]Common Options:[/bold]
--host <host> Server host (default: 127.0.0.1)
--port <port> Server port (default: 8765)
--reload Enable hot reload
--api-key <key> API key for authentication
[bold]Agents Options:[/bold]
--file <path> Agents YAML file (default: agents.yaml)
--path <path> API endpoint path (default: /agents)
[bold]Recipe Options:[/bold]
--config <path> Config file path (serve.yaml)
--preload Preload all recipes
[bold]MCP Options:[/bold]
--transport Transport type: http, sse (default: http)
[bold]Examples:[/bold]
praisonai serve agents --file agents.yaml --port 8000
praisonai serve recipe --port 8765
praisonai serve mcp --transport http --port 8080
praisonai serve tools --port 8081
praisonai serve a2a --port 8082
praisonai serve unified --port 8765
[bold]Discovery:[/bold]
All servers expose /__praisonai__/discovery for unified endpoint discovery.
Use `praisonai endpoints` to interact with any server.
"""
self._print_rich(help_text)
def _print_rich(self, text: str):
"""Print with rich formatting if available."""
try:
from rich import print as rprint
rprint(text)
except ImportError:
import re
plain = re.sub(r'\[/?[^\]]+\]', '', text)
print(plain)
def _print_error(self, message: str):
"""Print error message."""
try:
from rich import print as rprint
rprint(f"[red]Error: {message}[/red]")
except ImportError:
print(f"Error: {message}", file=sys.stderr)
def _print_success(self, message: str):
"""Print success message."""
try:
from rich import print as rprint
rprint(f"[green]✓ {message}[/green]")
except ImportError:
print(f"✓ {message}")
def _parse_args(self, args: List[str], spec: Dict[str, Any]) -> Dict[str, Any]:
"""Parse command arguments based on spec."""
result = {k: v.get("default") for k, v in spec.items()}
i = 0
while i < len(args):
arg = args[i]
if arg.startswith("--"):
key = arg[2:].replace("-", "_")
if key in spec:
if spec[key].get("flag"):
result[key] = True
elif i + 1 < len(args):
value = args[i + 1]
# Type conversion
if spec[key].get("type") == "int":
value = int(value)
result[key] = value
i += 1
i += 1
else:
i += 1
return result
def cmd_agents(self, args: List[str]) -> int:
"""Launch agents as HTTP API."""
spec = {
"file": {"default": "agents.yaml"},
"host": {"default": self.DEFAULT_HOST},
"port": {"default": self.DEFAULT_PORT, "type": "int"},
"path": {"default": "/agents"},
"reload": {"flag": True, "default": False},
"api_key": {"default": None},
}
parsed = self._parse_args(args, spec)
# Check if agents file exists
if not os.path.exists(parsed["file"]):
self._print_error(f"Agents file not found: {parsed['file']}")
return self.EXIT_VALIDATION_ERROR
try:
self._print_success(f"Starting agents server on {parsed['host']}:{parsed['port']}")
print(f" Agents file: {parsed['file']}")
print(f" Endpoint: {parsed['path']}")
print(" Discovery: /__praisonai__/discovery")
# Create and run server
app = self._create_agents_app(parsed)
self._run_server(app, parsed["host"], parsed["port"], parsed["reload"])
except ImportError as e:
self._print_error(f"Missing dependency: {e}")
print("Install with: pip install praisonai[serve]")
return self.EXIT_GENERAL_ERROR
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
return self.EXIT_SUCCESS
def _create_agents_app(self, config: Dict[str, Any]) -> Any:
"""Create FastAPI app for agents."""
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from praisonai.endpoints.discovery import (
create_discovery_document,
EndpointInfo,
ProviderInfo,
)
from praisonai.endpoints.server import add_discovery_routes
# Load agents from YAML
import yaml
with open(config["file"]) as f:
_ = yaml.safe_load(f) # Validate YAML
# Create discovery document
discovery = create_discovery_document(server_name="praisonai-agents")
discovery.add_provider(ProviderInfo(
type="agents-api",
name="Agents API",
description="Agent HTTP API endpoints",
capabilities=["invoke", "health"],
))
# Create app
app = FastAPI(
title="PraisonAI Agents API",
description="HTTP API for PraisonAI Agents",
)
# Add discovery routes
add_discovery_routes(app, discovery)
# Request model
class AgentQuery(BaseModel):
query: str
# Create endpoint for agents
path = config["path"]
@app.post(path)
async def invoke_agents(request: Request, query_data: AgentQuery = None):
"""Invoke agents with a query."""
if query_data is None:
try:
body = await request.json()
_ = body.get("query", "") # Extract query
except Exception:
raise HTTPException(status_code=400, detail="Invalid request")
try:
# Lazy load and run agents
from praisonai.agents_generator import AgentsGenerator
generator = AgentsGenerator(
agent_file=config["file"],
framework="praisonai",
)
result = generator.generate_crew_and_kickoff()
return {"response": result}
except Exception as e:
return JSONResponse(
{"error": str(e)},
status_code=500,
)
# Add endpoint to discovery
discovery.add_endpoint(EndpointInfo(
name=path.lstrip("/"),
description="Invoke agents workflow",
provider_type="agents-api",
input_schema={"type": "object", "properties": {"query": {"type": "string"}}},
streaming=["none"],
))
# Root endpoint
@app.get("/")
async def root():
return {
"message": "PraisonAI Agents API",
"endpoints": [path],
"discovery": "/__praisonai__/discovery",
}
return app
def cmd_recipe(self, args: List[str]) -> int:
"""Launch recipe runner server."""
spec = {
"host": {"default": self.DEFAULT_HOST},
"port": {"default": self.DEFAULT_PORT, "type": "int"},
"config": {"default": None},
"reload": {"flag": True, "default": False},
"api_key": {"default": None},
"preload": {"flag": True, "default": False},
}
parsed = self._parse_args(args, spec)
try:
self._print_success(f"Starting recipe server on {parsed['host']}:{parsed['port']}")
print(" Discovery: /__praisonai__/discovery")
from praisonai.recipe.serve import serve, load_config
# Load config
config = load_config(parsed["config"]) if parsed["config"] else {}
if parsed["api_key"]:
config["api_key"] = parsed["api_key"]
serve(
host=parsed["host"],
port=parsed["port"],
reload=parsed["reload"],
config=config,
)
except ImportError as e:
self._print_error(f"Missing dependency: {e}")
print("Install with: pip install praisonai[serve]")
return self.EXIT_GENERAL_ERROR
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
return self.EXIT_SUCCESS
def cmd_mcp(self, args: List[str]) -> int:
"""Launch MCP server."""
spec = {
"host": {"default": self.DEFAULT_HOST},
"port": {"default": 8080, "type": "int"},
"transport": {"default": "http"},
"tools_file": {"default": None},
}
parsed = self._parse_args(args, spec)
try:
self._print_success(f"Starting MCP server on {parsed['host']}:{parsed['port']}")
print(f" Transport: {parsed['transport']}")
print(" Discovery: /__praisonai__/discovery")
app = self._create_mcp_app(parsed)
self._run_server(app, parsed["host"], parsed["port"], False)
except ImportError as e:
self._print_error(f"Missing dependency: {e}")
return self.EXIT_GENERAL_ERROR
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
return self.EXIT_SUCCESS
def _create_mcp_app(self, config: Dict[str, Any]) -> Any:
"""Create FastAPI app for MCP server."""
from fastapi import FastAPI
from praisonai.endpoints.discovery import (
create_discovery_document,
ProviderInfo,
)
from praisonai.endpoints.server import add_discovery_routes
# Create discovery document
discovery = create_discovery_document(server_name="praisonai-mcp")
discovery.add_provider(ProviderInfo(
type="mcp",
name="MCP Server",
description=f"MCP server ({config['transport']} transport)",
capabilities=["list-tools", "call-tool"],
))
app = FastAPI(
title="PraisonAI MCP Server",
description="MCP protocol server",
)
add_discovery_routes(app, discovery)
# MCP tools endpoint
@app.get("/mcp/tools")
async def list_tools():
"""List available MCP tools."""
# TODO: Load tools from config or registry
return {"tools": []}
@app.post("/mcp/tools/call")
async def call_tool(request_data: dict):
"""Call an MCP tool."""
tool_name = request_data.get("tool")
_ = request_data.get("arguments", {}) # Arguments for tool
# TODO: Execute tool
return {"result": None, "tool": tool_name}
@app.get("/")
async def root():
return {
"message": "PraisonAI MCP Server",
"transport": config["transport"],
"discovery": "/__praisonai__/discovery",
}
return app
def cmd_tools(self, args: List[str]) -> int:
"""Launch tools as MCP server."""
spec = {
"host": {"default": self.DEFAULT_HOST},
"port": {"default": 8081, "type": "int"},
"tools_file": {"default": None},
}
parsed = self._parse_args(args, spec)
try:
self._print_success(f"Starting tools MCP server on {parsed['host']}:{parsed['port']}")
print(" Discovery: /__praisonai__/discovery")
app = self._create_tools_app(parsed)
self._run_server(app, parsed["host"], parsed["port"], False)
except ImportError as e:
self._print_error(f"Missing dependency: {e}")
return self.EXIT_GENERAL_ERROR
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
return self.EXIT_SUCCESS
def _create_tools_app(self, config: Dict[str, Any]) -> Any:
"""Create FastAPI app for tools MCP server."""
from fastapi import FastAPI
from praisonai.endpoints.discovery import (
create_discovery_document,
ProviderInfo,
)
from praisonai.endpoints.server import add_discovery_routes
discovery = create_discovery_document(server_name="praisonai-tools-mcp")
discovery.add_provider(ProviderInfo(
type="tools-mcp",
name="Tools MCP Server",
description="Python tools exposed as MCP server",
capabilities=["list-tools", "call-tool"],
))
app = FastAPI(
title="PraisonAI Tools MCP Server",
description="Tools exposed as MCP server",
)
add_discovery_routes(app, discovery)
@app.get("/tools")
async def list_tools():
"""List available tools."""
return {"tools": []}
@app.post("/tools/call")
async def call_tool(request_data: dict):
"""Call a tool."""
return {"result": None}
@app.get("/")
async def root():
return {
"message": "PraisonAI Tools MCP Server",
"discovery": "/__praisonai__/discovery",
}
return app
def cmd_a2a(self, args: List[str]) -> int:
"""Launch A2A protocol server."""
spec = {
"host": {"default": self.DEFAULT_HOST},
"port": {"default": 8082, "type": "int"},
"file": {"default": "agents.yaml"},
}
parsed = self._parse_args(args, spec)
try:
self._print_success(f"Starting A2A server on {parsed['host']}:{parsed['port']}")
print(" Agent Card: /.well-known/agent.json")
print(" Discovery: /__praisonai__/discovery")
app = self._create_a2a_app(parsed)
self._run_server(app, parsed["host"], parsed["port"], False)
except ImportError as e:
self._print_error(f"Missing dependency: {e}")
return self.EXIT_GENERAL_ERROR
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
return self.EXIT_SUCCESS
def _create_a2a_app(self, config: Dict[str, Any]) -> Any:
"""Create FastAPI app for A2A server."""
from fastapi import FastAPI
from praisonai.endpoints.discovery import (
create_discovery_document,
EndpointInfo,
ProviderInfo,
)
from praisonai.endpoints.server import add_discovery_routes
discovery = create_discovery_document(server_name="praisonai-a2a")
discovery.add_provider(ProviderInfo(
type="a2a",
name="A2A Protocol",
description="Agent-to-Agent communication",
capabilities=["agent-card", "message-send"],
))
app = FastAPI(
title="PraisonAI A2A Server",
description="A2A protocol server",
)
add_discovery_routes(app, discovery)
# Agent card
@app.get("/.well-known/agent.json")
async def agent_card():
"""Return agent card for A2A discovery."""
return {
"name": "PraisonAI Agent",
"description": "PraisonAI Agent via A2A protocol",
"version": "1.0.0",
"url": f"http://{config.get('host', 'localhost')}:{config.get('port', 8082)}/a2a",
"capabilities": {
"streaming": True,
"pushNotifications": False,
},
"skills": [],
}
@app.get("/status")
async def status():
"""Return server status."""
return {"status": "ok", "name": "PraisonAI A2A", "version": "1.0.0"}
@app.post("/a2a")
async def a2a_endpoint(request_data: dict):
"""Handle A2A JSON-RPC requests."""
method = request_data.get("method")
params = request_data.get("params", {})
request_id = request_data.get("id")
if method == "message/send":
# Handle message
message = params.get("message", {})
parts = message.get("parts", [])
text = ""
for part in parts:
if part.get("type") == "text":
text += part.get("text", "")
# TODO: Process with agent
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"message": {
"role": "assistant",
"parts": [{"type": "text", "text": f"Received: {text}"}],
},
},
}
return {
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
}
discovery.add_endpoint(EndpointInfo(
name="a2a",
description="A2A protocol endpoint",
provider_type="a2a",
streaming=["sse"],
))
@app.get("/")
async def root():
return {
"message": "PraisonAI A2A Server",
"agent_card": "/.well-known/agent.json",
"discovery": "/__praisonai__/discovery",
}
return app
def cmd_a2u(self, args: List[str]) -> int:
"""Launch A2U event stream server."""
spec = {
"host": {"default": self.DEFAULT_HOST},
"port": {"default": 8083, "type": "int"},
}
parsed = self._parse_args(args, spec)
try:
self._print_success(f"Starting A2U server on {parsed['host']}:{parsed['port']}")
print(" Events: /a2u/events/<stream>")
print(" Discovery: /__praisonai__/discovery")
app = self._create_a2u_app(parsed)
self._run_server(app, parsed["host"], parsed["port"], False)
except ImportError as e:
self._print_error(f"Missing dependency: {e}")
return self.EXIT_GENERAL_ERROR
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
return self.EXIT_SUCCESS
def _create_a2u_app(self, config: Dict[str, Any]) -> Any:
"""Create FastAPI app for A2U server."""
from fastapi import FastAPI
from praisonai.endpoints.discovery import (
create_discovery_document,
EndpointInfo,
ProviderInfo,
)
from praisonai.endpoints.server import add_discovery_routes
from praisonai.endpoints.a2u_server import create_a2u_routes
discovery = create_discovery_document(server_name="praisonai-a2u")
discovery.add_provider(ProviderInfo(
type="a2u",
name="A2U Event Stream",
description="Agent-to-User event streaming",
capabilities=["subscribe", "stream"],
))
app = FastAPI(
title="PraisonAI A2U Server",
description="A2U event stream server",
)
add_discovery_routes(app, discovery)
create_a2u_routes(app)
discovery.add_endpoint(EndpointInfo(
name="events",
description="Agent event stream",
provider_type="a2u",
streaming=["sse"],
))
@app.get("/")
async def root():
return {
"message": "PraisonAI A2U Server",
"events": "/a2u/events/<stream>",
"discovery": "/__praisonai__/discovery",
}
return app
def cmd_unified(self, args: List[str]) -> int:
"""Launch unified server with all providers."""
spec = {
"host": {"default": self.DEFAULT_HOST},
"port": {"default": self.DEFAULT_PORT, "type": "int"},
"file": {"default": "agents.yaml"},
"reload": {"flag": True, "default": False},
"api_key": {"default": None},
}
parsed = self._parse_args(args, spec)
try:
self._print_success(f"Starting unified server on {parsed['host']}:{parsed['port']}")
print(" Providers: agents-api, recipe, mcp, a2a, a2u")
print(" Discovery: /__praisonai__/discovery")
app = self._create_unified_app(parsed)
self._run_server(app, parsed["host"], parsed["port"], parsed["reload"])
except ImportError as e:
self._print_error(f"Missing dependency: {e}")
print("Install with: pip install praisonai[serve]")
return self.EXIT_GENERAL_ERROR
except Exception as e:
self._print_error(str(e))
return self.EXIT_GENERAL_ERROR
return self.EXIT_SUCCESS
def _create_unified_app(self, config: Dict[str, Any]) -> Any:
"""Create unified FastAPI app with all providers."""
from fastapi import FastAPI
from praisonai.endpoints.discovery import (
create_discovery_document,
EndpointInfo,
ProviderInfo,
)
from praisonai.endpoints.server import add_discovery_routes
from praisonai.endpoints.a2u_server import create_a2u_routes
discovery = create_discovery_document(server_name="praisonai-unified")
# Add all providers
discovery.add_provider(ProviderInfo(
type="agents-api",
name="Agents API",
capabilities=["invoke", "health"],
))
discovery.add_provider(ProviderInfo(
type="recipe",
name="Recipe Runner",
capabilities=["list", "describe", "invoke", "stream"],
))
discovery.add_provider(ProviderInfo(
type="mcp",
name="MCP Server",
capabilities=["list-tools", "call-tool"],
))
discovery.add_provider(ProviderInfo(
type="a2a",
name="A2A Protocol",
capabilities=["agent-card", "message-send"],
))
discovery.add_provider(ProviderInfo(
type="a2u",
name="A2U Event Stream",
capabilities=["subscribe", "stream"],
))
app = FastAPI(
title="PraisonAI Unified Server",
description="Unified server with all PraisonAI providers",
)
add_discovery_routes(app, discovery)
create_a2u_routes(app)
# Add sample endpoints
discovery.add_endpoint(EndpointInfo(
name="agents",
description="Agents workflow endpoint",
provider_type="agents-api",
))
discovery.add_endpoint(EndpointInfo(
name="events",
description="Agent event stream",
provider_type="a2u",
streaming=["sse"],
))
@app.get("/")
async def root():
return {
"message": "PraisonAI Unified Server",
"providers": ["agents-api", "recipe", "mcp", "a2a", "a2u"],
"discovery": "/__praisonai__/discovery",
}
@app.get("/.well-known/agent.json")
async def agent_card():
return {
"name": "PraisonAI Unified",
"description": "PraisonAI Unified Server",
"version": "1.0.0",
}
return app
def _run_server(self, app: Any, host: str, port: int, reload: bool) -> None:
"""Run the server with uvicorn."""
import uvicorn
uvicorn.run(app, host=host, port=port, reload=reload)
def handle_serve_command(args: List[str]) -> int:
"""Entry point for serve command."""
handler = ServeHandler()
return handler.handle(args)
@@ -85,6 +85,9 @@ class TemplatesHandler:
"add": self.cmd_add,
"add-sources": self.cmd_add_sources,
"remove-sources": self.cmd_remove_sources,
"browse": self.cmd_browse,
"catalog": self.cmd_catalog,
"validate": self.cmd_validate,
"help": lambda _: self._print_help() or 0,
}
@@ -115,6 +118,10 @@ class TemplatesHandler:
add <source> Add template from GitHub or local path
add-sources <src> Add a template source to persistent config
remove-sources Remove a template source from config
browse Open template catalog in browser
catalog build Build catalog locally
catalog sync Sync catalog sources
validate Validate template YAML files
[bold]Options:[/bold]
--offline Use only cached templates (no network)
@@ -936,6 +943,433 @@ class TemplatesHandler:
print(f"\n✅ Removed template source: {source}")
return 0
def cmd_browse(self, args: List[str]) -> int:
"""
Open template catalog in browser.
Args:
args: [--local] [--url <url>] [--print]
"""
import webbrowser
# Default catalog URL
catalog_url = "https://mervinpraison.github.io/praisonai-template-catalog"
# Parse arguments
print_only = "--print" in args
local_mode = "--local" in args
# Custom URL
if "--url" in args:
idx = args.index("--url")
if idx + 1 < len(args):
catalog_url = args[idx + 1]
if local_mode:
print("Local catalog server not implemented yet.")
print(f"Visit the online catalog at: {catalog_url}")
return 0
if print_only:
print(catalog_url)
return 0
print(f"Opening template catalog: {catalog_url}")
try:
webbrowser.open(catalog_url)
return 0
except Exception as e:
print(f"Failed to open browser: {e}")
print(f"Visit: {catalog_url}")
return 1
def cmd_catalog(self, args: List[str]) -> int:
"""
Catalog management commands.
Subcommands:
build Build catalog locally
sync Sync catalog sources
"""
if not args:
print("Usage: praisonai templates catalog <build|sync> [options]")
print("\nSubcommands:")
print(" build Build catalog locally")
print(" sync Sync catalog sources from GitHub")
print("\nExamples:")
print(" praisonai templates catalog build --out ./dist")
print(" praisonai templates catalog sync --source agent-recipes")
return 0
subcmd = args[0]
remaining = args[1:]
if subcmd == "build":
return self._catalog_build(remaining)
elif subcmd == "sync":
return self._catalog_sync(remaining)
else:
print(f"Unknown catalog command: {subcmd}")
return 1
def _catalog_build(self, args: List[str]) -> int:
"""Build catalog locally."""
import subprocess
import os
# Parse arguments
out_dir = None
source_dir = None
minify = "--minify" in args
if "--out" in args:
idx = args.index("--out")
if idx + 1 < len(args):
out_dir = args[idx + 1]
if "--source" in args:
idx = args.index("--source")
if idx + 1 < len(args):
source_dir = args[idx + 1]
# Check if catalog repo is available locally
catalog_repo = Path.home() / "praisonai-template-catalog"
if not catalog_repo.exists():
# Try to find it relative to this package
possible_paths = [
Path(__file__).parent.parent.parent.parent.parent.parent / "praisonai-template-catalog",
Path.cwd() / "praisonai-template-catalog",
]
for p in possible_paths:
if p.exists():
catalog_repo = p
break
if catalog_repo.exists() and (catalog_repo / "scripts" / "build-catalog.js").exists():
print(f"Building catalog using: {catalog_repo}")
cmd = ["node", "scripts/build-catalog.js"]
if out_dir:
cmd.extend(["--out", out_dir])
if source_dir:
cmd.extend(["--source", source_dir])
if minify:
cmd.append("--minify")
try:
result = subprocess.run(cmd, cwd=str(catalog_repo), capture_output=True, text=True)
print(result.stdout)
if result.stderr:
print(result.stderr)
return result.returncode
except FileNotFoundError:
print("Node.js not found. Please install Node.js to build the catalog.")
return 1
else:
# Fallback: Generate minimal catalog using Python
print("Catalog repo not found locally. Generating minimal catalog...")
return self._generate_minimal_catalog(out_dir, source_dir)
def _generate_minimal_catalog(self, out_dir: str = None, source_dir: str = None) -> int:
"""Generate minimal catalog JSON using Python."""
import json
from datetime import datetime
try:
import yaml
except ImportError:
print("PyYAML not installed. Install with: pip install pyyaml")
return 1
# Find templates
if source_dir:
templates_dir = Path(source_dir)
else:
# Try Agent-Recipes
possible_paths = [
Path.home() / "Agent-Recipes" / "agent_recipes" / "templates",
Path.cwd() / "Agent-Recipes" / "agent_recipes" / "templates",
]
templates_dir = None
for p in possible_paths:
if p.exists():
templates_dir = p
break
if not templates_dir:
# Try package templates
try:
import agent_recipes
templates_dir = Path(agent_recipes.__file__).parent / "templates"
except ImportError:
pass
if not templates_dir or not templates_dir.exists():
print("No templates directory found.")
return 1
print(f"Scanning templates in: {templates_dir}")
templates = []
for entry in templates_dir.iterdir():
if not entry.is_dir():
continue
template_yaml = entry / "TEMPLATE.yaml"
if template_yaml.exists():
try:
with open(template_yaml) as f:
data = yaml.safe_load(f)
if data:
templates.append({
"name": data.get("name", entry.name),
"version": data.get("version", "1.0.0"),
"description": data.get("description", ""),
"author": data.get("author", "Unknown"),
"license": data.get("license", "Apache-2.0"),
"tags": data.get("tags", []),
"requires": data.get("requires", {}),
})
except Exception as e:
print(f" Warning: Failed to parse {template_yaml}: {e}")
# Output
output = {
"version": "1.0.0",
"generated_at": datetime.now().isoformat(),
"count": len(templates),
"templates": templates
}
out_path = Path(out_dir) if out_dir else Path.cwd() / "templates.json"
if out_path.is_dir():
out_path = out_path / "templates.json"
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w") as f:
json.dump(output, f, indent=2)
print(f"Generated: {out_path} ({len(templates)} templates)")
return 0
def _catalog_sync(self, args: List[str]) -> int:
"""Sync catalog sources."""
import subprocess
# Parse arguments
config_path = None
source_name = None
cache_dir = None
if "--config" in args:
idx = args.index("--config")
if idx + 1 < len(args):
config_path = args[idx + 1]
if "--source" in args:
idx = args.index("--source")
if idx + 1 < len(args):
source_name = args[idx + 1]
if "--cache-dir" in args:
idx = args.index("--cache-dir")
if idx + 1 < len(args):
cache_dir = args[idx + 1]
# Check if catalog repo is available
catalog_repo = Path.home() / "praisonai-template-catalog"
if catalog_repo.exists() and (catalog_repo / "scripts" / "sync-sources.js").exists():
print(f"Syncing using: {catalog_repo}")
cmd = ["node", "scripts/sync-sources.js"]
if config_path:
cmd.extend(["--config", config_path])
if source_name:
cmd.extend(["--source", source_name])
if cache_dir:
cmd.extend(["--cache-dir", cache_dir])
try:
result = subprocess.run(cmd, cwd=str(catalog_repo), capture_output=True, text=True)
print(result.stdout)
if result.stderr:
print(result.stderr)
return result.returncode
except FileNotFoundError:
print("Node.js not found. Please install Node.js.")
return 1
else:
# Fallback: Clone Agent-Recipes
print("Catalog repo not found. Cloning Agent-Recipes directly...")
cache_path = Path(cache_dir) if cache_dir else Path.home() / ".praison" / "cache"
cache_path.mkdir(parents=True, exist_ok=True)
target = cache_path / "Agent-Recipes"
if target.exists():
print(f"Updating: {target}")
try:
subprocess.run(["git", "-C", str(target), "pull", "--depth=1"], check=True)
print("✓ Updated successfully")
return 0
except subprocess.CalledProcessError as e:
print(f"Failed to update: {e}")
return 1
else:
print(f"Cloning to: {target}")
try:
subprocess.run([
"git", "clone", "--depth=1",
"https://github.com/MervinPraison/Agent-Recipes.git",
str(target)
], check=True)
print("✓ Cloned successfully")
return 0
except subprocess.CalledProcessError as e:
print(f"Failed to clone: {e}")
return 1
def cmd_validate(self, args: List[str]) -> int:
"""
Validate template YAML files.
Args:
args: [--source <dir>] [--strict] [--json]
"""
import subprocess
# Parse arguments
source_dir = None
strict = "--strict" in args
json_output = "--json" in args
if "--source" in args:
idx = args.index("--source")
if idx + 1 < len(args):
source_dir = args[idx + 1]
# Check if catalog repo is available
catalog_repo = Path.home() / "praisonai-template-catalog"
if catalog_repo.exists() and (catalog_repo / "scripts" / "validate-templates.js").exists():
cmd = ["node", "scripts/validate-templates.js"]
if source_dir:
cmd.extend(["--source", source_dir])
if strict:
cmd.append("--strict")
if json_output:
cmd.append("--json")
try:
result = subprocess.run(cmd, cwd=str(catalog_repo), capture_output=True, text=True)
print(result.stdout)
if result.stderr:
print(result.stderr)
return result.returncode
except FileNotFoundError:
print("Node.js not found.")
return 1
else:
# Fallback: Basic Python validation
return self._validate_templates_python(source_dir, strict, json_output)
def _validate_templates_python(self, source_dir: str = None, strict: bool = False, json_output: bool = False) -> int:
"""Validate templates using Python."""
import json as json_module
try:
import yaml
except ImportError:
print("PyYAML not installed. Install with: pip install pyyaml")
return 1
# Find templates directory
if source_dir:
templates_dir = Path(source_dir)
else:
possible_paths = [
Path.home() / "Agent-Recipes" / "agent_recipes" / "templates",
Path.cwd() / "Agent-Recipes" / "agent_recipes" / "templates",
]
templates_dir = None
for p in possible_paths:
if p.exists():
templates_dir = p
break
if not templates_dir or not templates_dir.exists():
print(f"Templates directory not found: {source_dir or 'default locations'}")
return 1
print(f"Validating templates in: {templates_dir}\n")
results = []
errors_count = 0
warnings_count = 0
for entry in sorted(templates_dir.iterdir()):
if not entry.is_dir() or entry.name.startswith("."):
continue
template_yaml = entry / "TEMPLATE.yaml"
if not template_yaml.exists():
continue
result = {"name": entry.name, "valid": True, "errors": [], "warnings": []}
try:
with open(template_yaml) as f:
data = yaml.safe_load(f)
# Check required fields
required = ["name", "version", "description"]
for field in required:
if not data.get(field):
result["errors"].append(f"Missing required field: {field}")
result["valid"] = False
# Check version format
version = data.get("version", "")
if version and not self._is_valid_version(version):
result["warnings"].append(f"Invalid version format: {version}")
# Check workflow file
workflow = data.get("workflow", "workflow.yaml")
if isinstance(workflow, str) and not (entry / workflow).exists():
if strict:
result["errors"].append(f"Workflow file not found: {workflow}")
result["valid"] = False
else:
result["warnings"].append(f"Workflow file not found: {workflow}")
except yaml.YAMLError as e:
result["errors"].append(f"Invalid YAML: {e}")
result["valid"] = False
except Exception as e:
result["errors"].append(f"Error: {e}")
result["valid"] = False
results.append(result)
errors_count += len(result["errors"])
warnings_count += len(result["warnings"])
# Output
if json_output:
print(json_module.dumps(results, indent=2))
else:
for r in results:
status = "✓" if r["valid"] else "✗"
color = "\033[32m" if r["valid"] else "\033[31m"
print(f"{color}{status}\033[0m {r['name']}")
for err in r["errors"]:
print(f" \033[31m✗ ERROR:\033[0m {err}")
for warn in r["warnings"]:
print(f" \033[33m⚠ WARNING:\033[0m {warn}")
print(f"\n{len(results)} templates checked, {errors_count} errors, {warnings_count} warnings")
return 1 if errors_count > 0 else 0
def _is_valid_version(self, version: str) -> bool:
"""Check if version string is valid semver."""
import re
return bool(re.match(r'^\d+\.\d+\.\d+', version))
def import_time():
"""Get current time (helper for cache expiry display)."""
+26 -1
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', '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']
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']
parser = argparse.ArgumentParser(prog="praisonai", description="praisonAI command-line interface")
parser.add_argument("--framework", choices=["crewai", "autogen", "praisonai"], help="Specify the framework")
@@ -1186,6 +1186,24 @@ class PraisonAI:
exit_code = handle_templates_command(unknown_args)
sys.exit(exit_code)
elif args.command == 'recipe':
# Recipe command - new unified recipe system
from .features.recipe import handle_recipe_command
exit_code = handle_recipe_command(unknown_args)
sys.exit(exit_code)
elif args.command == 'endpoints':
# Endpoints command - unified client CLI for all server types
from .features.endpoints import handle_endpoints_command
exit_code = handle_endpoints_command(unknown_args)
sys.exit(exit_code)
elif args.command == 'serve':
# Serve command - launch PraisonAI servers
from .features.serve import handle_serve_command
exit_code = handle_serve_command(unknown_args)
sys.exit(exit_code)
elif args.command == 'agents':
# Agents command - run multiple agents with custom definitions
if not PRAISONAI_AVAILABLE:
@@ -1358,6 +1376,13 @@ class PraisonAI:
if handler:
exit_code = handler(args, unknown_args)
sys.exit(exit_code if exit_code else 0)
elif args.command == 'doctor':
# Doctor command - comprehensive health checks and diagnostics
from .features.doctor.handler import DoctorHandler
handler = DoctorHandler()
exit_code = handler.run(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):
+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.0 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.7.5 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -0,0 +1,53 @@
"""
Unified Endpoints Module
Provides unified discovery, provider adapters, and server utilities
for all PraisonAI serve features.
Provider Types:
- recipe: Recipe runner endpoints
- agents-api: Single/multi-agent HTTP API
- mcp: MCP server (stdio, http, sse)
- tools-mcp: Tools exposed as MCP server
- a2a: Agent-to-agent protocol
- a2u: Agent-to-user event stream
Architecture:
- Discovery schema is versioned and consistent across all providers
- Provider adapters abstract protocol differences
- CLI client uses unified discovery for list/describe/invoke/health
"""
# Lazy loading for all exports
_lazy_imports = {
"DiscoveryDocument": ".discovery",
"EndpointInfo": ".discovery",
"ProviderInfo": ".discovery",
"SCHEMA_VERSION": ".discovery",
"create_discovery_document": ".discovery",
"BaseProvider": ".providers.base",
"RecipeProvider": ".providers.recipe",
"AgentsAPIProvider": ".providers.agents_api",
"MCPProvider": ".providers.mcp",
"ToolsMCPProvider": ".providers.tools_mcp",
"A2AProvider": ".providers.a2a",
"A2UProvider": ".providers.a2u",
"ProviderRegistry": ".registry",
"get_provider": ".registry",
"register_provider": ".registry",
"list_provider_types": ".registry",
"get_provider_class": ".registry",
"create_unified_app": ".server",
"add_discovery_routes": ".server",
}
def __getattr__(name: str):
if name in _lazy_imports:
module_path = _lazy_imports[name]
import importlib
module = importlib.import_module(module_path, package=__name__)
return getattr(module, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__():
return list(_lazy_imports.keys())
@@ -0,0 +1,410 @@
"""
A2U (Agent-to-User) Event Stream Server
Provides SSE-based event streaming for agent-to-user communication.
"""
import asyncio
import json
import logging
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set
logger = logging.getLogger(__name__)
@dataclass
class A2UEvent:
"""An event in the A2U stream."""
event_type: str
data: Dict[str, Any]
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
event_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
def to_sse(self) -> str:
"""Convert to SSE format."""
return f"event: {self.event_type}\ndata: {json.dumps(self.data)}\nid: {self.event_id}\n\n"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"event_type": self.event_type,
"data": self.data,
"timestamp": self.timestamp,
"event_id": self.event_id,
}
@dataclass
class A2USubscription:
"""A subscription to an A2U event stream."""
subscription_id: str
stream_name: str
filters: List[str] = field(default_factory=list)
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def matches_event(self, event: A2UEvent) -> bool:
"""Check if event matches subscription filters."""
if not self.filters:
return True
return event.event_type in self.filters
class A2UEventBus:
"""
Event bus for A2U event distribution.
Manages subscriptions and broadcasts events to subscribers.
"""
def __init__(self):
"""Initialize the event bus."""
self._subscriptions: Dict[str, A2USubscription] = {}
self._queues: Dict[str, asyncio.Queue] = {}
self._streams: Dict[str, Set[str]] = {} # stream_name -> subscription_ids
def subscribe(
self,
stream_name: str = "events",
filters: Optional[List[str]] = None,
) -> A2USubscription:
"""
Subscribe to an event stream.
Args:
stream_name: Name of the stream to subscribe to
filters: Optional list of event types to filter
Returns:
A2USubscription object
"""
subscription_id = f"sub-{uuid.uuid4().hex[:12]}"
subscription = A2USubscription(
subscription_id=subscription_id,
stream_name=stream_name,
filters=filters or [],
)
self._subscriptions[subscription_id] = subscription
self._queues[subscription_id] = asyncio.Queue()
if stream_name not in self._streams:
self._streams[stream_name] = set()
self._streams[stream_name].add(subscription_id)
logger.debug(f"Created subscription {subscription_id} for stream {stream_name}")
return subscription
def unsubscribe(self, subscription_id: str) -> bool:
"""
Unsubscribe from an event stream.
Args:
subscription_id: ID of the subscription to remove
Returns:
True if unsubscribed, False if not found
"""
if subscription_id not in self._subscriptions:
return False
subscription = self._subscriptions[subscription_id]
# Remove from stream set
if subscription.stream_name in self._streams:
self._streams[subscription.stream_name].discard(subscription_id)
# Clean up
del self._subscriptions[subscription_id]
del self._queues[subscription_id]
logger.debug(f"Removed subscription {subscription_id}")
return True
async def publish(self, event: A2UEvent, stream_name: str = "events") -> int:
"""
Publish an event to a stream.
Args:
event: Event to publish
stream_name: Name of the stream
Returns:
Number of subscribers that received the event
"""
if stream_name not in self._streams:
return 0
count = 0
for sub_id in self._streams[stream_name]:
subscription = self._subscriptions.get(sub_id)
if subscription and subscription.matches_event(event):
await self._queues[sub_id].put(event)
count += 1
logger.debug(f"Published event {event.event_type} to {count} subscribers")
return count
def publish_sync(self, event: A2UEvent, stream_name: str = "events") -> int:
"""
Synchronously publish an event (creates event loop if needed).
Args:
event: Event to publish
stream_name: Name of the stream
Returns:
Number of subscribers that received the event
"""
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# Schedule in running loop
asyncio.ensure_future(self.publish(event, stream_name))
return len(self._streams.get(stream_name, set()))
else:
return loop.run_until_complete(self.publish(event, stream_name))
except RuntimeError:
# No event loop, create one
return asyncio.run(self.publish(event, stream_name))
async def get_events(
self,
subscription_id: str,
timeout: float = 30.0,
):
"""
Async generator for subscription events.
Args:
subscription_id: ID of the subscription
timeout: Timeout for waiting for events
Yields:
A2UEvent objects
"""
if subscription_id not in self._queues:
return
queue = self._queues[subscription_id]
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=timeout)
yield event
except asyncio.TimeoutError:
# Send keepalive
yield A2UEvent(event_type="keepalive", data={})
# Global event bus instance
_event_bus: Optional[A2UEventBus] = None
def get_event_bus() -> A2UEventBus:
"""Get or create the global event bus."""
global _event_bus
if _event_bus is None:
_event_bus = A2UEventBus()
return _event_bus
def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None:
"""
Add A2U routes to a FastAPI/Starlette application.
Args:
app: FastAPI or Starlette application
event_bus: Optional event bus (uses global if not provided)
"""
bus = event_bus or get_event_bus()
try:
from starlette.responses import JSONResponse, StreamingResponse
from starlette.routing import Route
except ImportError:
try:
from fastapi.responses import JSONResponse, StreamingResponse
except ImportError:
raise ImportError("Starlette or FastAPI required for A2U routes")
async def a2u_info(request):
"""GET /a2u/info - Get A2U server info."""
return JSONResponse({
"name": "A2U Event Stream",
"version": "1.0.0",
"streams": list(bus._streams.keys()) or ["events"],
"event_types": [
"agent.started",
"agent.thinking",
"agent.tool_call",
"agent.response",
"agent.completed",
"agent.error",
],
})
async def a2u_subscribe(request):
"""POST /a2u/subscribe - Subscribe to an event stream."""
try:
body = await request.json()
except Exception:
body = {}
stream_name = body.get("stream", "events")
filters = body.get("filters", [])
subscription = bus.subscribe(stream_name, filters)
base_url = str(request.url).rsplit("/", 1)[0]
return JSONResponse({
"subscription_id": subscription.subscription_id,
"stream_name": subscription.stream_name,
"stream_url": f"{base_url}/events/{subscription.subscription_id}",
"created_at": subscription.created_at,
})
async def a2u_unsubscribe(request):
"""POST /a2u/unsubscribe - Unsubscribe from an event stream."""
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
subscription_id = body.get("subscription_id")
if not subscription_id:
return JSONResponse({"error": "subscription_id required"}, status_code=400)
if bus.unsubscribe(subscription_id):
return JSONResponse({"status": "unsubscribed"})
else:
return JSONResponse({"error": "Subscription not found"}, status_code=404)
async def a2u_events_stream(request):
"""GET /a2u/events/{stream_name} - Stream events via SSE."""
stream_name = request.path_params.get("stream_name", "events")
# Create subscription for this stream
subscription = bus.subscribe(stream_name)
async def event_generator():
try:
async for event in bus.get_events(subscription.subscription_id):
yield event.to_sse()
finally:
bus.unsubscribe(subscription.subscription_id)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def a2u_events_subscription(request):
"""GET /a2u/events/sub/{subscription_id} - Stream events for subscription."""
subscription_id = request.path_params.get("subscription_id")
if subscription_id not in bus._subscriptions:
return JSONResponse({"error": "Subscription not found"}, status_code=404)
async def event_generator():
async for event in bus.get_events(subscription_id):
yield event.to_sse()
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def a2u_health(request):
"""GET /a2u/health - A2U health check."""
return JSONResponse({
"status": "healthy",
"active_subscriptions": len(bus._subscriptions),
"active_streams": len(bus._streams),
})
# Add routes based on app type
if hasattr(app, 'add_api_route'):
# FastAPI
app.add_api_route("/a2u/info", a2u_info, methods=["GET"])
app.add_api_route("/a2u/subscribe", a2u_subscribe, methods=["POST"])
app.add_api_route("/a2u/unsubscribe", a2u_unsubscribe, methods=["POST"])
app.add_api_route("/a2u/events/{stream_name}", a2u_events_stream, methods=["GET"])
app.add_api_route("/a2u/events/sub/{subscription_id}", a2u_events_subscription, methods=["GET"])
app.add_api_route("/a2u/health", a2u_health, methods=["GET"])
elif hasattr(app, 'routes'):
# Starlette
app.routes.extend([
Route("/a2u/info", a2u_info, methods=["GET"]),
Route("/a2u/subscribe", a2u_subscribe, methods=["POST"]),
Route("/a2u/unsubscribe", a2u_unsubscribe, methods=["POST"]),
Route("/a2u/events/{stream_name}", a2u_events_stream, methods=["GET"]),
Route("/a2u/events/sub/{subscription_id}", a2u_events_subscription, methods=["GET"]),
Route("/a2u/health", a2u_health, methods=["GET"]),
])
# Helper functions for publishing events
def emit_agent_event(
event_type: str,
data: Dict[str, Any],
agent_id: Optional[str] = None,
stream_name: str = "events",
) -> None:
"""
Emit an agent event to the A2U stream.
Args:
event_type: Type of event (e.g., "agent.started", "agent.response")
data: Event data
agent_id: Optional agent ID to include
stream_name: Stream to publish to
"""
if agent_id:
data["agent_id"] = agent_id
event = A2UEvent(event_type=event_type, data=data)
get_event_bus().publish_sync(event, stream_name)
def emit_agent_started(agent_id: str, agent_name: str, **kwargs) -> None:
"""Emit agent.started event."""
emit_agent_event("agent.started", {"agent_name": agent_name, **kwargs}, agent_id)
def emit_agent_thinking(agent_id: str, message: str = "", **kwargs) -> None:
"""Emit agent.thinking event."""
emit_agent_event("agent.thinking", {"message": message, **kwargs}, agent_id)
def emit_agent_tool_call(agent_id: str, tool_name: str, arguments: Dict = None, **kwargs) -> None:
"""Emit agent.tool_call event."""
emit_agent_event("agent.tool_call", {"tool_name": tool_name, "arguments": arguments or {}, **kwargs}, agent_id)
def emit_agent_response(agent_id: str, response: str, **kwargs) -> None:
"""Emit agent.response event."""
emit_agent_event("agent.response", {"response": response, **kwargs}, agent_id)
def emit_agent_completed(agent_id: str, result: Any = None, **kwargs) -> None:
"""Emit agent.completed event."""
emit_agent_event("agent.completed", {"result": result, **kwargs}, agent_id)
def emit_agent_error(agent_id: str, error: str, **kwargs) -> None:
"""Emit agent.error event."""
emit_agent_event("agent.error", {"error": error, **kwargs}, agent_id)
@@ -0,0 +1,165 @@
"""
Unified Discovery Schema
Provides versioned discovery document format for all PraisonAI serve features.
This schema is consistent across all provider types.
"""
from dataclasses import dataclass, field, asdict
from typing import Any, Dict, List, Optional
from enum import Enum
# Schema version - increment on breaking changes
SCHEMA_VERSION = "1.0.0"
class ProviderType(str, Enum):
"""Supported provider types."""
RECIPE = "recipe"
AGENTS_API = "agents-api"
MCP = "mcp"
TOOLS_MCP = "tools-mcp"
A2A = "a2a"
A2U = "a2u"
class StreamingMode(str, Enum):
"""Supported streaming modes."""
NONE = "none"
SSE = "sse"
WEBSOCKET = "websocket"
MCP_STREAM = "mcp-stream"
class AuthMode(str, Enum):
"""Supported authentication modes."""
NONE = "none"
API_KEY = "api-key"
BEARER = "bearer"
JWT = "jwt"
@dataclass
class EndpointInfo:
"""Information about a single endpoint."""
name: str
description: str = ""
provider_type: str = "recipe"
tags: List[str] = field(default_factory=list)
version: str = "1.0.0"
# Schema information
input_schema: Optional[Dict[str, Any]] = None
output_schema: Optional[Dict[str, Any]] = None
# Capabilities
streaming: List[str] = field(default_factory=lambda: ["none"])
auth_modes: List[str] = field(default_factory=lambda: ["none"])
# Metadata
examples: List[Dict[str, Any]] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return asdict(self)
@dataclass
class ProviderInfo:
"""Information about a provider."""
type: str
name: str
version: str = "1.0.0"
description: str = ""
capabilities: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return asdict(self)
@dataclass
class DiscoveryDocument:
"""
Unified discovery document for PraisonAI servers.
This document is served at /__praisonai__/discovery and provides
a consistent interface for discovering endpoints across all provider types.
"""
schema_version: str = SCHEMA_VERSION
server_name: str = "praisonai"
server_version: str = "1.0.0"
# Provider information
providers: List[ProviderInfo] = field(default_factory=list)
# Endpoint information
endpoints: List[EndpointInfo] = field(default_factory=list)
# Server capabilities
auth_modes: List[str] = field(default_factory=lambda: ["none"])
streaming_modes: List[str] = field(default_factory=lambda: ["none", "sse"])
# Metadata
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"schema_version": self.schema_version,
"server_name": self.server_name,
"server_version": self.server_version,
"providers": [p.to_dict() for p in self.providers],
"endpoints": [e.to_dict() for e in self.endpoints],
"auth_modes": self.auth_modes,
"streaming_modes": self.streaming_modes,
"metadata": self.metadata,
}
def add_provider(self, provider: ProviderInfo) -> None:
"""Add a provider to the discovery document."""
self.providers.append(provider)
def add_endpoint(self, endpoint: EndpointInfo) -> None:
"""Add an endpoint to the discovery document."""
self.endpoints.append(endpoint)
def get_endpoints_by_type(self, provider_type: str) -> List[EndpointInfo]:
"""Get endpoints filtered by provider type."""
return [e for e in self.endpoints if e.provider_type == provider_type]
def get_endpoint_by_name(self, name: str) -> Optional[EndpointInfo]:
"""Get an endpoint by name."""
for e in self.endpoints:
if e.name == name:
return e
return None
def create_discovery_document(
server_name: str = "praisonai",
server_version: Optional[str] = None,
) -> DiscoveryDocument:
"""
Create a new discovery document.
Args:
server_name: Name of the server
server_version: Version of the server (auto-detected if not provided)
Returns:
DiscoveryDocument instance
"""
if server_version is None:
try:
from praisonai.version import __version__
server_version = __version__
except ImportError:
server_version = "unknown"
return DiscoveryDocument(
server_name=server_name,
server_version=server_version,
)
@@ -0,0 +1,28 @@
"""
Provider Adapters
Each provider adapter implements the BaseProvider interface
and handles protocol-specific details for discovery and invocation.
"""
# Lazy loading for all exports
_lazy_imports = {
"BaseProvider": ".base",
"RecipeProvider": ".recipe",
"AgentsAPIProvider": ".agents_api",
"MCPProvider": ".mcp",
"ToolsMCPProvider": ".tools_mcp",
"A2AProvider": ".a2a",
"A2UProvider": ".a2u",
}
def __getattr__(name: str):
if name in _lazy_imports:
module_path = _lazy_imports[name]
import importlib
module = importlib.import_module(module_path, package=__name__)
return getattr(module, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__():
return list(_lazy_imports.keys())
@@ -0,0 +1,253 @@
"""
A2A Provider
Provider adapter for Agent-to-Agent protocol endpoints.
"""
from typing import Any, Dict, Iterator, List, Optional
from .base import BaseProvider, InvokeResult, HealthResult
from ..discovery import EndpointInfo, ProviderInfo
class A2AProvider(BaseProvider):
"""
Provider adapter for A2A (Agent-to-Agent) protocol endpoints.
A2A enables agent-to-agent communication following the A2A protocol spec.
Supports discovery via /.well-known/agent.json and message passing.
"""
provider_type = "a2a"
def get_provider_info(self) -> ProviderInfo:
"""Get provider information."""
return ProviderInfo(
type=self.provider_type,
name="A2A Protocol",
description="Agent-to-Agent communication protocol",
capabilities=["agent-card", "message-send", "task-management"],
)
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""List available A2A agents."""
# Try unified discovery first
result = self._make_request("GET", "/__praisonai__/discovery")
if not result.get("error") and result.get("data"):
data = result.get("data", {})
endpoints = []
for ep in data.get("endpoints", []):
if ep.get("provider_type") == self.provider_type:
endpoints.append(EndpointInfo(
name=ep.get("name", ""),
description=ep.get("description", ""),
provider_type=self.provider_type,
tags=ep.get("tags", []),
streaming=ep.get("streaming", ["sse"]),
auth_modes=ep.get("auth_modes", ["none"]),
))
return endpoints
# Fallback: try agent card
result = self._make_request("GET", "/.well-known/agent.json")
if result.get("error"):
return []
agent_card = result.get("data", {})
return [EndpointInfo(
name=agent_card.get("name", "a2a-agent"),
description=agent_card.get("description", "A2A Agent"),
provider_type=self.provider_type,
version=agent_card.get("version", "1.0.0"),
streaming=["sse"] if agent_card.get("capabilities", {}).get("streaming") else ["none"],
auth_modes=["none"],
metadata={"agent_card": agent_card},
)]
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
"""Get detailed information about an A2A agent."""
# Get agent card
result = self._make_request("GET", "/.well-known/agent.json")
if result.get("error"):
return None
agent_card = result.get("data", {})
return EndpointInfo(
name=agent_card.get("name", name),
description=agent_card.get("description", ""),
provider_type=self.provider_type,
version=agent_card.get("version", "1.0.0"),
streaming=["sse"] if agent_card.get("capabilities", {}).get("streaming") else ["none"],
auth_modes=["none"],
metadata={
"agent_card": agent_card,
"skills": agent_card.get("skills", []),
"capabilities": agent_card.get("capabilities", {}),
},
)
def invoke(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
stream: bool = False,
) -> InvokeResult:
"""Send a message to an A2A agent."""
import uuid
# Build A2A message
message = input_data.get("message", "") if input_data else ""
if not message and config:
message = config.get("message", config.get("query", ""))
body = {
"jsonrpc": "2.0",
"method": "message/send",
"id": str(uuid.uuid4()),
"params": {
"message": {
"role": "user",
"parts": [{"type": "text", "text": message}],
},
},
}
result = self._make_request("POST", "/a2a", json_data=body)
if result.get("error"):
return InvokeResult(
ok=False,
status="error",
error=result["error"].get("message", str(result["error"])),
)
data = result.get("data", {})
# Handle JSON-RPC response
if "result" in data:
return InvokeResult(
ok=True,
status="success",
data=data["result"],
)
elif "error" in data:
return InvokeResult(
ok=False,
status="error",
error=data["error"].get("message", str(data["error"])),
)
return InvokeResult(
ok=True,
status="success",
data=data,
)
def invoke_stream(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
) -> Iterator[Dict[str, Any]]:
"""Send a streaming message to an A2A agent."""
import json
import urllib.request
import uuid
message = input_data.get("message", "") if input_data else ""
if not message and config:
message = config.get("message", config.get("query", ""))
body = {
"jsonrpc": "2.0",
"method": "message/stream",
"id": str(uuid.uuid4()),
"params": {
"message": {
"role": "user",
"parts": [{"type": "text", "text": message}],
},
},
}
full_url = f"{self.base_url}/a2a"
headers = {
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
if self.api_key:
headers["X-API-Key"] = self.api_key
data = json.dumps(body).encode("utf-8")
try:
req = urllib.request.Request(full_url, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=300) as response:
buffer = ""
for line in response:
line = line.decode("utf-8")
buffer += line
if buffer.endswith("\n\n"):
event_type = "message"
event_data = ""
for part in buffer.strip().split("\n"):
if part.startswith("event:"):
event_type = part[6:].strip()
elif part.startswith("data:"):
event_data = part[5:].strip()
if event_data:
try:
yield {"event": event_type, "data": json.loads(event_data)}
except json.JSONDecodeError:
yield {"event": event_type, "data": event_data}
buffer = ""
except Exception as e:
yield {"event": "error", "data": {"error": str(e)}}
def health(self) -> HealthResult:
"""Check A2A server health."""
# Try status endpoint first
result = self._make_request("GET", "/status")
if not result.get("error"):
data = result.get("data", {})
return HealthResult(
healthy=data.get("status") == "ok",
status=data.get("status", "unknown"),
server_name=data.get("name", "A2A Agent"),
server_version=data.get("version"),
provider_type=self.provider_type,
)
# Fallback: try agent card
result = self._make_request("GET", "/.well-known/agent.json")
if result.get("error"):
return HealthResult(
healthy=False,
status="unhealthy",
provider_type=self.provider_type,
metadata={"error": result["error"].get("message", str(result["error"]))},
)
agent_card = result.get("data", {})
return HealthResult(
healthy=True,
status="healthy",
server_name=agent_card.get("name", "A2A Agent"),
server_version=agent_card.get("version"),
provider_type=self.provider_type,
)
@@ -0,0 +1,208 @@
"""
A2U Provider
Provider adapter for Agent-to-User event stream endpoints.
"""
from typing import Any, Dict, Iterator, List, Optional
from .base import BaseProvider, InvokeResult, HealthResult
from ..discovery import EndpointInfo, ProviderInfo
class A2UProvider(BaseProvider):
"""
Provider adapter for A2U (Agent-to-User) event stream endpoints.
A2U provides a protocol surface for streaming agent events to users,
typically via SSE or WebSocket. This enables real-time UI updates.
"""
provider_type = "a2u"
def get_provider_info(self) -> ProviderInfo:
"""Get provider information."""
return ProviderInfo(
type=self.provider_type,
name="A2U Event Stream",
description="Agent-to-User event streaming protocol",
capabilities=["event-stream", "subscribe", "unsubscribe"],
)
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""List available A2U event streams."""
# Try unified discovery first
result = self._make_request("GET", "/__praisonai__/discovery")
if not result.get("error") and result.get("data"):
data = result.get("data", {})
endpoints = []
for ep in data.get("endpoints", []):
if ep.get("provider_type") == self.provider_type:
endpoints.append(EndpointInfo(
name=ep.get("name", ""),
description=ep.get("description", ""),
provider_type=self.provider_type,
tags=ep.get("tags", []),
streaming=ep.get("streaming", ["sse"]),
auth_modes=ep.get("auth_modes", ["none"]),
))
return endpoints
# Fallback: check for events endpoint
result = self._make_request("GET", "/a2u/info")
if result.get("error"):
# Return default event stream endpoint
return [EndpointInfo(
name="events",
description="Agent event stream",
provider_type=self.provider_type,
streaming=["sse"],
auth_modes=["none"],
)]
data = result.get("data", {})
streams = data.get("streams", [{"name": "events", "description": "Agent event stream"}])
endpoints = []
for stream in streams:
endpoints.append(EndpointInfo(
name=stream.get("name", "events"),
description=stream.get("description", "Event stream"),
provider_type=self.provider_type,
streaming=["sse", "websocket"],
auth_modes=["none"],
))
return endpoints
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
"""Get detailed information about an A2U event stream."""
endpoints = self.list_endpoints()
for ep in endpoints:
if ep.name == name:
return ep
# Return default info
return EndpointInfo(
name=name,
description=f"Event stream: {name}",
provider_type=self.provider_type,
streaming=["sse"],
auth_modes=["none"],
metadata={
"event_types": [
"agent.started",
"agent.thinking",
"agent.tool_call",
"agent.response",
"agent.completed",
"agent.error",
],
},
)
def invoke(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
stream: bool = False,
) -> InvokeResult:
"""Subscribe to an A2U event stream (returns subscription info)."""
body = {
"stream": name,
"filters": input_data.get("filters", []) if input_data else [],
}
result = self._make_request("POST", "/a2u/subscribe", json_data=body)
if result.get("error"):
return InvokeResult(
ok=False,
status="error",
error=result["error"].get("message", str(result["error"])),
)
data = result.get("data", {})
return InvokeResult(
ok=True,
status="subscribed",
data={
"subscription_id": data.get("subscription_id"),
"stream_url": data.get("stream_url", f"{self.base_url}/a2u/events/{name}"),
},
)
def invoke_stream(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
) -> Iterator[Dict[str, Any]]:
"""Stream events from an A2U endpoint."""
import json
import urllib.request
stream_path = f"/a2u/events/{name}"
full_url = f"{self.base_url}{stream_path}"
headers = {"Accept": "text/event-stream"}
if self.api_key:
headers["X-API-Key"] = self.api_key
try:
req = urllib.request.Request(full_url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=300) as response:
buffer = ""
for line in response:
line = line.decode("utf-8")
buffer += line
if buffer.endswith("\n\n"):
event_type = "event"
event_data = ""
for part in buffer.strip().split("\n"):
if part.startswith("event:"):
event_type = part[6:].strip()
elif part.startswith("data:"):
event_data = part[5:].strip()
if event_data:
try:
yield {"event": event_type, "data": json.loads(event_data)}
except json.JSONDecodeError:
yield {"event": event_type, "data": event_data}
buffer = ""
except Exception as e:
yield {"event": "error", "data": {"error": str(e)}}
def health(self) -> HealthResult:
"""Check A2U server health."""
result = self._make_request("GET", "/health")
if result.get("error"):
# Try a2u-specific health
result = self._make_request("GET", "/a2u/health")
if result.get("error"):
return HealthResult(
healthy=False,
status="unhealthy",
provider_type=self.provider_type,
metadata={"error": result["error"].get("message", str(result["error"]))},
)
data = result.get("data", {})
return HealthResult(
healthy=True,
status="healthy",
server_name=data.get("server_name", "A2U Server"),
server_version=data.get("version"),
provider_type=self.provider_type,
)
@@ -0,0 +1,171 @@
"""
Agents API Provider
Provider adapter for agents-as-API endpoints (single agent or multi-agent router).
"""
from typing import Any, Dict, List, Optional
from .base import BaseProvider, InvokeResult, HealthResult
from ..discovery import EndpointInfo, ProviderInfo
class AgentsAPIProvider(BaseProvider):
"""
Provider adapter for agents-as-API endpoints.
Connects to FastAPI-based agent servers launched via Agent.launch() or Agents.launch()
and provides unified discovery and invocation interface.
"""
provider_type = "agents-api"
def get_provider_info(self) -> ProviderInfo:
"""Get provider information."""
return ProviderInfo(
type=self.provider_type,
name="Agents API",
description="Agent HTTP API endpoints",
capabilities=["list", "describe", "invoke", "health"],
)
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""List available agent endpoints."""
# Try unified discovery first
result = self._make_request("GET", "/__praisonai__/discovery")
if not result.get("error") and result.get("data"):
data = result.get("data", {})
endpoints = []
for ep in data.get("endpoints", []):
if ep.get("provider_type") == self.provider_type:
endpoints.append(EndpointInfo(
name=ep.get("name", ""),
description=ep.get("description", ""),
provider_type=self.provider_type,
tags=ep.get("tags", []),
version=ep.get("version", "1.0.0"),
streaming=ep.get("streaming", ["none"]),
auth_modes=ep.get("auth_modes", ["none"]),
))
return endpoints
# Fallback: try root endpoint for endpoint list
result = self._make_request("GET", "/")
if result.get("error"):
return []
data = result.get("data", {})
endpoint_paths = data.get("endpoints", [])
endpoints = []
for path in endpoint_paths:
endpoints.append(EndpointInfo(
name=path.lstrip("/"),
description=f"Agent endpoint at {path}",
provider_type=self.provider_type,
streaming=["none"],
auth_modes=["none"],
))
return endpoints
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
"""Get detailed information about an agent endpoint."""
# Try unified discovery first
result = self._make_request("GET", "/__praisonai__/discovery")
if not result.get("error") and result.get("data"):
data = result.get("data", {})
for ep in data.get("endpoints", []):
if ep.get("name") == name:
return EndpointInfo(
name=ep.get("name", name),
description=ep.get("description", ""),
provider_type=self.provider_type,
tags=ep.get("tags", []),
version=ep.get("version", "1.0.0"),
input_schema=ep.get("input_schema"),
output_schema=ep.get("output_schema"),
streaming=ep.get("streaming", ["none"]),
auth_modes=ep.get("auth_modes", ["none"]),
metadata=ep.get("metadata", {}),
)
# Fallback: return basic info
return EndpointInfo(
name=name,
description=f"Agent endpoint: {name}",
provider_type=self.provider_type,
input_schema={"type": "object", "properties": {"query": {"type": "string"}}},
streaming=["none"],
auth_modes=["none"],
)
def invoke(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
stream: bool = False,
) -> InvokeResult:
"""Invoke an agent endpoint."""
# Normalize endpoint path
path = name if name.startswith("/") else f"/{name}"
# Build request body
body = input_data or {}
if "query" not in body and config and "query" in config:
body["query"] = config["query"]
result = self._make_request("POST", path, json_data=body)
if result.get("status") == 401:
return InvokeResult(
ok=False,
status="auth_error",
error="Authentication required",
)
if result.get("status") == 404:
return InvokeResult(
ok=False,
status="not_found",
error=f"Endpoint not found: {name}",
)
if result.get("error"):
return InvokeResult(
ok=False,
status="error",
error=result["error"].get("message", str(result["error"])),
)
data = result.get("data", {})
return InvokeResult(
ok=True,
status="success",
data=data.get("response", data),
)
def health(self) -> HealthResult:
"""Check agent server health."""
result = self._make_request("GET", "/health")
if result.get("error"):
return HealthResult(
healthy=False,
status="unhealthy",
provider_type=self.provider_type,
metadata={"error": result["error"].get("message", str(result["error"]))},
)
data = result.get("data", {})
return HealthResult(
healthy=data.get("status") == "ok",
status=data.get("status", "unknown"),
server_name="PraisonAI Agents API",
provider_type=self.provider_type,
metadata={"endpoints": data.get("endpoints", [])},
)
@@ -0,0 +1,231 @@
"""
Base Provider Interface
Abstract base class for all provider adapters.
Each provider implements discovery, invocation, and health check methods.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, Iterator, List, Optional
from ..discovery import EndpointInfo, ProviderInfo
@dataclass
class InvokeResult:
"""Result from invoking an endpoint."""
ok: bool
status: str = "success"
data: Optional[Any] = None
error: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"ok": self.ok,
"status": self.status,
"data": self.data,
"error": self.error,
"metadata": self.metadata,
}
@dataclass
class HealthResult:
"""Result from health check."""
healthy: bool
status: str = "healthy"
server_name: Optional[str] = None
server_version: Optional[str] = None
provider_type: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"healthy": self.healthy,
"status": self.status,
"server_name": self.server_name,
"server_version": self.server_version,
"provider_type": self.provider_type,
"metadata": self.metadata,
}
class BaseProvider(ABC):
"""
Abstract base class for provider adapters.
Each provider type (recipe, agents-api, mcp, etc.) implements this interface
to provide consistent discovery and invocation behavior.
"""
# Provider type identifier
provider_type: str = "base"
def __init__(
self,
base_url: str = "http://localhost:8765",
api_key: Optional[str] = None,
timeout: float = 30.0,
):
"""
Initialize the provider.
Args:
base_url: Base URL of the server
api_key: Optional API key for authentication
timeout: Request timeout in seconds
"""
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
@abstractmethod
def get_provider_info(self) -> ProviderInfo:
"""
Get provider information.
Returns:
ProviderInfo with provider details
"""
pass
@abstractmethod
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""
List available endpoints.
Args:
tags: Optional list of tags to filter by
Returns:
List of EndpointInfo objects
"""
pass
@abstractmethod
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
"""
Get detailed information about an endpoint.
Args:
name: Endpoint name
Returns:
EndpointInfo or None if not found
"""
pass
@abstractmethod
def invoke(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
stream: bool = False,
) -> InvokeResult:
"""
Invoke an endpoint.
Args:
name: Endpoint name
input_data: Input data for the endpoint
config: Optional configuration overrides
stream: Whether to stream the response
Returns:
InvokeResult with response data
"""
pass
def invoke_stream(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
) -> Iterator[Dict[str, Any]]:
"""
Invoke an endpoint with streaming.
Args:
name: Endpoint name
input_data: Input data for the endpoint
config: Optional configuration overrides
Yields:
Stream events as dictionaries
"""
# Default implementation - subclasses can override
result = self.invoke(name, input_data, config, stream=True)
if result.ok:
yield {"event": "complete", "data": result.data}
else:
yield {"event": "error", "data": {"error": result.error}}
@abstractmethod
def health(self) -> HealthResult:
"""
Check server health.
Returns:
HealthResult with health status
"""
pass
def _make_request(
self,
method: str,
path: str,
json_data: Optional[Dict] = None,
) -> Dict[str, Any]:
"""
Make HTTP request to server.
Args:
method: HTTP method
path: URL path
json_data: Optional JSON body
Returns:
Response data dictionary
"""
import json
import urllib.request
import urllib.error
full_url = f"{self.base_url}{path}"
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["X-API-Key"] = self.api_key
data = None
if json_data:
data = json.dumps(json_data).encode("utf-8")
try:
req = urllib.request.Request(
full_url,
data=data,
headers=headers,
method=method,
)
with urllib.request.urlopen(req, timeout=self.timeout) as response:
body = response.read().decode("utf-8")
return {"status": response.status, "data": json.loads(body) if body else {}}
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8") if e.fp else ""
try:
error_data = json.loads(body) if body else {}
except json.JSONDecodeError:
error_data = {"message": body}
return {"status": e.code, "error": error_data}
except urllib.error.URLError as e:
return {"status": 0, "error": {"message": f"Connection error: {e.reason}"}}
except Exception as e:
return {"status": 0, "error": {"message": str(e)}}
@@ -0,0 +1,263 @@
"""
MCP Provider
Provider adapter for MCP server endpoints (stdio, http, sse).
"""
from typing import Any, Dict, Iterator, List, Optional
from .base import BaseProvider, InvokeResult, HealthResult
from ..discovery import EndpointInfo, ProviderInfo
class MCPProvider(BaseProvider):
"""
Provider adapter for MCP server endpoints.
Supports multiple MCP transport types:
- stdio: Standard input/output (requires subprocess)
- http: HTTP-based MCP server
- sse: Server-Sent Events MCP gateway
"""
provider_type = "mcp"
def __init__(
self,
base_url: str = "http://localhost:8765",
api_key: Optional[str] = None,
timeout: float = 30.0,
transport: str = "http",
command: Optional[str] = None,
args: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None,
):
"""
Initialize MCP provider.
Args:
base_url: Base URL for HTTP/SSE transport
api_key: Optional API key
timeout: Request timeout
transport: Transport type (stdio, http, sse)
command: Command for stdio transport
args: Command arguments for stdio transport
env: Environment variables for stdio transport
"""
super().__init__(base_url, api_key, timeout)
self.transport = transport
self.command = command
self.args = args or []
self.env = env or {}
def get_provider_info(self) -> ProviderInfo:
"""Get provider information."""
return ProviderInfo(
type=self.provider_type,
name="MCP Server",
description=f"MCP server ({self.transport} transport)",
capabilities=["list-tools", "call-tool", "list-resources"],
)
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""List available MCP tools as endpoints."""
if self.transport == "stdio":
return self._list_stdio_tools(tags)
else:
return self._list_http_tools(tags)
def _list_stdio_tools(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""List tools from stdio MCP server."""
try:
from praisonaiagents.mcp import MCP
mcp = MCP(command=self.command, args=self.args, env=self.env)
tools = mcp.get_tools()
endpoints = []
for tool in tools:
tool_name = getattr(tool, 'name', str(tool))
tool_desc = getattr(tool, 'description', '')
endpoints.append(EndpointInfo(
name=tool_name,
description=tool_desc,
provider_type=self.provider_type,
streaming=["mcp-stream"],
auth_modes=["none"],
metadata={"transport": "stdio"},
))
return endpoints
except Exception:
return []
def _list_http_tools(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""List tools from HTTP MCP server."""
# Try unified discovery first
result = self._make_request("GET", "/__praisonai__/discovery")
if not result.get("error") and result.get("data"):
data = result.get("data", {})
endpoints = []
for ep in data.get("endpoints", []):
if ep.get("provider_type") == self.provider_type:
endpoints.append(EndpointInfo(
name=ep.get("name", ""),
description=ep.get("description", ""),
provider_type=self.provider_type,
tags=ep.get("tags", []),
streaming=ep.get("streaming", ["mcp-stream"]),
auth_modes=ep.get("auth_modes", ["none"]),
))
return endpoints
# Fallback: try MCP tools endpoint
result = self._make_request("GET", "/mcp/tools")
if result.get("error"):
return []
tools = result.get("data", {}).get("tools", [])
endpoints = []
for tool in tools:
endpoints.append(EndpointInfo(
name=tool.get("name", ""),
description=tool.get("description", ""),
provider_type=self.provider_type,
input_schema=tool.get("inputSchema"),
streaming=["mcp-stream"],
auth_modes=["none"],
))
return endpoints
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
"""Get detailed information about an MCP tool."""
endpoints = self.list_endpoints()
for ep in endpoints:
if ep.name == name:
return ep
return None
def invoke(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
stream: bool = False,
) -> InvokeResult:
"""Invoke an MCP tool."""
if self.transport == "stdio":
return self._invoke_stdio(name, input_data)
else:
return self._invoke_http(name, input_data)
def _invoke_stdio(self, name: str, input_data: Optional[Dict[str, Any]] = None) -> InvokeResult:
"""Invoke tool via stdio MCP."""
try:
from praisonaiagents.mcp import MCP
mcp = MCP(command=self.command, args=self.args, env=self.env)
tools = mcp.get_tools()
for tool in tools:
tool_name = getattr(tool, 'name', str(tool))
if tool_name == name:
if callable(tool):
result = tool(**(input_data or {}))
return InvokeResult(
ok=True,
status="success",
data=result,
)
return InvokeResult(
ok=False,
status="not_found",
error=f"Tool not found: {name}",
)
except Exception as e:
return InvokeResult(
ok=False,
status="error",
error=str(e),
)
def _invoke_http(self, name: str, input_data: Optional[Dict[str, Any]] = None) -> InvokeResult:
"""Invoke tool via HTTP MCP."""
body = {
"tool": name,
"arguments": input_data or {},
}
result = self._make_request("POST", "/mcp/tools/call", json_data=body)
if result.get("error"):
return InvokeResult(
ok=False,
status="error",
error=result["error"].get("message", str(result["error"])),
)
data = result.get("data", {})
return InvokeResult(
ok=True,
status="success",
data=data.get("result", data),
)
def invoke_stream(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
) -> Iterator[Dict[str, Any]]:
"""Invoke MCP tool with streaming."""
# MCP streaming is tool-specific; for now, wrap sync call
result = self.invoke(name, input_data, config)
if result.ok:
yield {"event": "result", "data": result.data}
else:
yield {"event": "error", "data": {"error": result.error}}
def health(self) -> HealthResult:
"""Check MCP server health."""
if self.transport == "stdio":
# For stdio, check if command exists
import shutil
if self.command and shutil.which(self.command):
return HealthResult(
healthy=True,
status="available",
server_name=self.command,
provider_type=self.provider_type,
metadata={"transport": "stdio"},
)
return HealthResult(
healthy=False,
status="unavailable",
provider_type=self.provider_type,
metadata={"error": f"Command not found: {self.command}"},
)
# HTTP health check
result = self._make_request("GET", "/health")
if result.get("error"):
return HealthResult(
healthy=False,
status="unhealthy",
provider_type=self.provider_type,
metadata={"error": result["error"].get("message", str(result["error"]))},
)
data = result.get("data", {})
return HealthResult(
healthy=True,
status="healthy",
server_name=data.get("server_name", "MCP Server"),
server_version=data.get("version"),
provider_type=self.provider_type,
)
@@ -0,0 +1,206 @@
"""
Recipe Provider
Provider adapter for recipe runner endpoints.
"""
from typing import Any, Dict, Iterator, List, Optional
from .base import BaseProvider, InvokeResult, HealthResult
from ..discovery import EndpointInfo, ProviderInfo
class RecipeProvider(BaseProvider):
"""
Provider adapter for recipe runner endpoints.
Connects to recipe runner server (Starlette-based) and provides
unified discovery and invocation interface.
"""
provider_type = "recipe"
def get_provider_info(self) -> ProviderInfo:
"""Get provider information."""
return ProviderInfo(
type=self.provider_type,
name="Recipe Runner",
description="Recipe execution endpoints",
capabilities=["list", "describe", "invoke", "stream", "validate"],
)
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""List available recipe endpoints."""
path = "/v1/recipes"
if tags:
path += f"?tags={','.join(tags)}"
result = self._make_request("GET", path)
if result.get("error"):
return []
recipes = result.get("data", {}).get("recipes", [])
endpoints = []
for r in recipes:
endpoints.append(EndpointInfo(
name=r.get("name", ""),
description=r.get("description", ""),
provider_type=self.provider_type,
tags=r.get("tags", []),
version=r.get("version", "1.0.0"),
streaming=["none", "sse"],
auth_modes=["none", "api-key"],
))
return endpoints
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
"""Get detailed information about a recipe endpoint."""
result = self._make_request("GET", f"/v1/recipes/{name}")
if result.get("status") == 404 or result.get("error"):
return None
data = result.get("data", {})
# Get schema
schema_result = self._make_request("GET", f"/v1/recipes/{name}/schema")
schema_data = schema_result.get("data", {}) if not schema_result.get("error") else {}
return EndpointInfo(
name=data.get("name", name),
description=data.get("description", ""),
provider_type=self.provider_type,
tags=data.get("tags", []),
version=data.get("version", "1.0.0"),
input_schema=schema_data.get("input_schema"),
output_schema=schema_data.get("output_schema"),
streaming=["none", "sse"],
auth_modes=["none", "api-key"],
metadata=data.get("metadata", {}),
)
def invoke(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
stream: bool = False,
) -> InvokeResult:
"""Invoke a recipe endpoint."""
body = {
"recipe": name,
"input": input_data or {},
"config": config or {},
}
result = self._make_request("POST", "/v1/recipes/run", json_data=body)
if result.get("status") == 401:
return InvokeResult(
ok=False,
status="auth_error",
error="Authentication required",
)
if result.get("status") == 404:
return InvokeResult(
ok=False,
status="not_found",
error=f"Recipe not found: {name}",
)
if result.get("error"):
return InvokeResult(
ok=False,
status="error",
error=result["error"].get("message", str(result["error"])),
)
data = result.get("data", {})
return InvokeResult(
ok=data.get("ok", True),
status=data.get("status", "success"),
data=data.get("output"),
metadata={"run_id": data.get("run_id")},
)
def invoke_stream(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
) -> Iterator[Dict[str, Any]]:
"""Invoke a recipe endpoint with streaming."""
import json
import urllib.request
body = {
"recipe": name,
"input": input_data or {},
"config": config or {},
}
full_url = f"{self.base_url}/v1/recipes/stream"
headers = {
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
if self.api_key:
headers["X-API-Key"] = self.api_key
data = json.dumps(body).encode("utf-8")
try:
req = urllib.request.Request(full_url, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=300) as response:
buffer = ""
for line in response:
line = line.decode("utf-8")
buffer += line
if buffer.endswith("\n\n"):
event_type = "message"
event_data = ""
for part in buffer.strip().split("\n"):
if part.startswith("event:"):
event_type = part[6:].strip()
elif part.startswith("data:"):
event_data = part[5:].strip()
if event_data:
try:
yield {"event": event_type, "data": json.loads(event_data)}
except json.JSONDecodeError:
yield {"event": event_type, "data": event_data}
buffer = ""
except Exception as e:
yield {"event": "error", "data": {"error": str(e)}}
def health(self) -> HealthResult:
"""Check recipe server health."""
result = self._make_request("GET", "/health")
if result.get("error"):
return HealthResult(
healthy=False,
status="unhealthy",
provider_type=self.provider_type,
metadata={"error": result["error"].get("message", str(result["error"]))},
)
data = result.get("data", {})
return HealthResult(
healthy=data.get("status") == "healthy",
status=data.get("status", "unknown"),
server_name=data.get("service"),
server_version=data.get("version"),
provider_type=self.provider_type,
)
@@ -0,0 +1,150 @@
"""
Tools MCP Provider
Provider adapter for tools exposed as MCP server.
"""
from typing import Any, Dict, Iterator, List, Optional
from .base import BaseProvider, InvokeResult, HealthResult
from ..discovery import EndpointInfo, ProviderInfo
class ToolsMCPProvider(BaseProvider):
"""
Provider adapter for tools exposed as MCP server.
This provider connects to a ToolsMCPServer that exposes Python functions
as MCP tools.
"""
provider_type = "tools-mcp"
def get_provider_info(self) -> ProviderInfo:
"""Get provider information."""
return ProviderInfo(
type=self.provider_type,
name="Tools MCP Server",
description="Python tools exposed as MCP server",
capabilities=["list-tools", "call-tool"],
)
def list_endpoints(self, tags: Optional[List[str]] = None) -> List[EndpointInfo]:
"""List available tools as endpoints."""
# Try unified discovery first
result = self._make_request("GET", "/__praisonai__/discovery")
if not result.get("error") and result.get("data"):
data = result.get("data", {})
endpoints = []
for ep in data.get("endpoints", []):
if ep.get("provider_type") == self.provider_type:
endpoints.append(EndpointInfo(
name=ep.get("name", ""),
description=ep.get("description", ""),
provider_type=self.provider_type,
tags=ep.get("tags", []),
input_schema=ep.get("input_schema"),
streaming=ep.get("streaming", ["none"]),
auth_modes=ep.get("auth_modes", ["none"]),
))
return endpoints
# Fallback: try tools endpoint
result = self._make_request("GET", "/tools")
if result.get("error"):
return []
tools = result.get("data", {}).get("tools", [])
endpoints = []
for tool in tools:
endpoints.append(EndpointInfo(
name=tool.get("name", ""),
description=tool.get("description", ""),
provider_type=self.provider_type,
input_schema=tool.get("input_schema"),
streaming=["none"],
auth_modes=["none"],
))
return endpoints
def describe_endpoint(self, name: str) -> Optional[EndpointInfo]:
"""Get detailed information about a tool."""
endpoints = self.list_endpoints()
for ep in endpoints:
if ep.name == name:
return ep
return None
def invoke(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
stream: bool = False,
) -> InvokeResult:
"""Invoke a tool."""
body = {
"tool": name,
"arguments": input_data or {},
}
result = self._make_request("POST", "/tools/call", json_data=body)
if result.get("status") == 404:
return InvokeResult(
ok=False,
status="not_found",
error=f"Tool not found: {name}",
)
if result.get("error"):
return InvokeResult(
ok=False,
status="error",
error=result["error"].get("message", str(result["error"])),
)
data = result.get("data", {})
return InvokeResult(
ok=True,
status="success",
data=data.get("result", data),
)
def invoke_stream(
self,
name: str,
input_data: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
) -> Iterator[Dict[str, Any]]:
"""Invoke tool with streaming (wraps sync call)."""
result = self.invoke(name, input_data, config)
if result.ok:
yield {"event": "result", "data": result.data}
else:
yield {"event": "error", "data": {"error": result.error}}
def health(self) -> HealthResult:
"""Check tools server health."""
result = self._make_request("GET", "/health")
if result.get("error"):
return HealthResult(
healthy=False,
status="unhealthy",
provider_type=self.provider_type,
metadata={"error": result["error"].get("message", str(result["error"]))},
)
data = result.get("data", {})
return HealthResult(
healthy=True,
status="healthy",
server_name=data.get("server_name", "Tools MCP Server"),
server_version=data.get("version"),
provider_type=self.provider_type,
)
@@ -0,0 +1,131 @@
"""
Provider Registry
Central registry for provider adapters.
"""
from typing import Dict, List, Optional, Type
from .providers.base import BaseProvider
# Global provider registry
_providers: Dict[str, Type[BaseProvider]] = {}
def register_provider(provider_type: str, provider_class: Type[BaseProvider]) -> None:
"""
Register a provider class.
Args:
provider_type: Provider type identifier
provider_class: Provider class to register
"""
_providers[provider_type] = provider_class
def get_provider(
provider_type: str,
base_url: str = "http://localhost:8765",
api_key: Optional[str] = None,
**kwargs,
) -> Optional[BaseProvider]:
"""
Get a provider instance by type.
Args:
provider_type: Provider type identifier
base_url: Base URL for the provider
api_key: Optional API key
**kwargs: Additional provider-specific arguments
Returns:
Provider instance or None if not found
"""
# Lazy register built-in providers
_ensure_providers_registered()
if provider_type not in _providers:
return None
return _providers[provider_type](base_url=base_url, api_key=api_key, **kwargs)
def list_provider_types() -> List[str]:
"""
List all registered provider types.
Returns:
List of provider type identifiers
"""
_ensure_providers_registered()
return list(_providers.keys())
def get_provider_class(provider_type: str) -> Optional[Type[BaseProvider]]:
"""
Get a provider class by type.
Args:
provider_type: Provider type identifier
Returns:
Provider class or None if not found
"""
_ensure_providers_registered()
return _providers.get(provider_type)
def _ensure_providers_registered() -> None:
"""Ensure built-in providers are registered."""
if _providers:
return
# Lazy import and register built-in providers
from .providers.recipe import RecipeProvider
from .providers.agents_api import AgentsAPIProvider
from .providers.mcp import MCPProvider
from .providers.tools_mcp import ToolsMCPProvider
from .providers.a2a import A2AProvider
from .providers.a2u import A2UProvider
register_provider("recipe", RecipeProvider)
register_provider("agents-api", AgentsAPIProvider)
register_provider("mcp", MCPProvider)
register_provider("tools-mcp", ToolsMCPProvider)
register_provider("a2a", A2AProvider)
register_provider("a2u", A2UProvider)
class ProviderRegistry:
"""
Provider registry class for managing provider instances.
This class provides a more object-oriented interface to the provider registry.
"""
def __init__(self):
"""Initialize the registry."""
_ensure_providers_registered()
def get(
self,
provider_type: str,
base_url: str = "http://localhost:8765",
api_key: Optional[str] = None,
**kwargs,
) -> Optional[BaseProvider]:
"""Get a provider instance."""
return get_provider(provider_type, base_url, api_key, **kwargs)
def register(self, provider_type: str, provider_class: Type[BaseProvider]) -> None:
"""Register a provider class."""
register_provider(provider_type, provider_class)
def list_types(self) -> List[str]:
"""List all provider types."""
return list_provider_types()
def get_class(self, provider_type: str) -> Optional[Type[BaseProvider]]:
"""Get a provider class."""
return get_provider_class(provider_type)
+161
View File
@@ -0,0 +1,161 @@
"""
Unified Server Utilities
Provides utilities for adding discovery endpoints to any server.
"""
from typing import Any, List, Optional
from .discovery import (
DiscoveryDocument,
EndpointInfo,
ProviderInfo,
SCHEMA_VERSION,
create_discovery_document,
)
def add_discovery_routes(
app: Any,
discovery: DiscoveryDocument,
path: str = "/__praisonai__/discovery",
) -> None:
"""
Add unified discovery routes to a Starlette/FastAPI application.
Args:
app: Starlette or FastAPI application
discovery: DiscoveryDocument to serve
path: Path for discovery endpoint
"""
try:
from starlette.responses import JSONResponse
from starlette.routing import Route
except ImportError:
# Try FastAPI
try:
from fastapi.responses import JSONResponse
except ImportError:
raise ImportError("Starlette or FastAPI required for discovery routes")
async def discovery_handler(request):
"""Return discovery document."""
return JSONResponse(discovery.to_dict())
async def health_handler(request):
"""Return health status with discovery info."""
return JSONResponse({
"status": "healthy",
"schema_version": SCHEMA_VERSION,
"server_name": discovery.server_name,
"server_version": discovery.server_version,
"providers": [p.type for p in discovery.providers],
"endpoint_count": len(discovery.endpoints),
})
# Add routes based on app type
if hasattr(app, 'add_api_route'):
# FastAPI
app.add_api_route(path, discovery_handler, methods=["GET"])
if not _has_route(app, "/health"):
app.add_api_route("/health", health_handler, methods=["GET"])
elif hasattr(app, 'routes'):
# Starlette
app.routes.append(Route(path, discovery_handler, methods=["GET"]))
if not _has_route(app, "/health"):
app.routes.append(Route("/health", health_handler, methods=["GET"]))
def _has_route(app: Any, path: str) -> bool:
"""Check if app already has a route at path."""
if hasattr(app, 'routes'):
for route in app.routes:
if hasattr(route, 'path') and route.path == path:
return True
return False
def create_unified_app(
providers: Optional[List[str]] = None,
server_name: str = "praisonai",
host: str = "127.0.0.1",
port: int = 8765,
cors_origins: Optional[List[str]] = None,
api_key: Optional[str] = None,
) -> Any:
"""
Create a unified server application with discovery support.
Args:
providers: List of provider types to enable
server_name: Server name for discovery
host: Server host
port: Server port
cors_origins: CORS allowed origins
api_key: Optional API key for authentication
Returns:
FastAPI application
"""
try:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
except ImportError:
raise ImportError("FastAPI required. Install with: pip install fastapi")
# Create discovery document
discovery = create_discovery_document(server_name=server_name)
# Create FastAPI app
app = FastAPI(
title=f"{server_name} Unified API",
description="Unified PraisonAI endpoints server",
version=discovery.server_version,
)
# Add CORS if configured
if cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_methods=["*"],
allow_headers=["*"],
)
# Add discovery routes
add_discovery_routes(app, discovery)
# Store discovery document for later modification
app.state.discovery = discovery
return app
def register_endpoint_to_discovery(
app: Any,
endpoint: EndpointInfo,
) -> None:
"""
Register an endpoint to the app's discovery document.
Args:
app: FastAPI/Starlette app with discovery
endpoint: EndpointInfo to register
"""
if hasattr(app, 'state') and hasattr(app.state, 'discovery'):
app.state.discovery.add_endpoint(endpoint)
def register_provider_to_discovery(
app: Any,
provider: ProviderInfo,
) -> None:
"""
Register a provider to the app's discovery document.
Args:
app: FastAPI/Starlette app with discovery
provider: ProviderInfo to register
"""
if hasattr(app, 'state') and hasattr(app.state, 'discovery'):
app.state.discovery.add_provider(provider)
+103
View File
@@ -0,0 +1,103 @@
"""
PraisonAI Recipe Module
Provides a minimal, client-friendly API for running Agent Recipes.
All imports are lazy to ensure zero performance impact on Core SDK.
Usage:
from praisonai import recipe
result = recipe.run("support-reply-drafter", input={"ticket": "T-123"})
# Or with streaming
for event in recipe.run_stream("transcript-generator", input="audio.mp3"):
print(event)
"""
__all__ = [
# Core API
"run",
"run_stream",
"validate",
"list_recipes",
"describe",
# Data classes
"RecipeResult",
"RecipeEvent",
"RecipeConfig",
# Exceptions
"RecipeError",
"RecipeNotFoundError",
"RecipeDependencyError",
"RecipePolicyError",
"RecipeValidationError",
# Exit codes
"ExitCode",
]
# Lazy loading implementation
_module_cache = {}
def __getattr__(name):
"""Lazy load recipe components."""
if name in _module_cache:
return _module_cache[name]
if name == "run":
from .core import run
_module_cache[name] = run
return run
elif name == "run_stream":
from .core import run_stream
_module_cache[name] = run_stream
return run_stream
elif name == "validate":
from .core import validate
_module_cache[name] = validate
return validate
elif name == "list_recipes":
from .core import list_recipes
_module_cache[name] = list_recipes
return list_recipes
elif name == "describe":
from .core import describe
_module_cache[name] = describe
return describe
elif name == "RecipeResult":
from .models import RecipeResult
_module_cache[name] = RecipeResult
return RecipeResult
elif name == "RecipeEvent":
from .models import RecipeEvent
_module_cache[name] = RecipeEvent
return RecipeEvent
elif name == "RecipeConfig":
from .models import RecipeConfig
_module_cache[name] = RecipeConfig
return RecipeConfig
elif name == "RecipeError":
from .exceptions import RecipeError
_module_cache[name] = RecipeError
return RecipeError
elif name == "RecipeNotFoundError":
from .exceptions import RecipeNotFoundError
_module_cache[name] = RecipeNotFoundError
return RecipeNotFoundError
elif name == "RecipeDependencyError":
from .exceptions import RecipeDependencyError
_module_cache[name] = RecipeDependencyError
return RecipeDependencyError
elif name == "RecipePolicyError":
from .exceptions import RecipePolicyError
_module_cache[name] = RecipePolicyError
return RecipePolicyError
elif name == "RecipeValidationError":
from .exceptions import RecipeValidationError
_module_cache[name] = RecipeValidationError
return RecipeValidationError
elif name == "ExitCode":
from .models import ExitCode
_module_cache[name] = ExitCode
return ExitCode
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+893
View File
@@ -0,0 +1,893 @@
"""
Recipe Core API
Provides the main recipe execution functions.
"""
import os
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Union
from .models import (
RecipeResult,
RecipeEvent,
RecipeConfig,
RecipeInfo,
RecipeStatus,
ValidationResult,
)
from .exceptions import (
RecipeError,
RecipeNotFoundError,
RecipeDependencyError,
RecipePolicyError,
RecipeTimeoutError,
)
# Default denied tools (dangerous by default)
DEFAULT_DENIED_TOOLS = [
"shell.exec",
"shell.run",
"shell_tool",
"file.write",
"file.delete",
"fs.write",
"fs.delete",
"network.unrestricted",
"db.write",
"db.delete",
"execute_command",
]
def _generate_run_id() -> str:
"""Generate a unique run ID."""
return f"run-{uuid.uuid4().hex[:12]}"
def _generate_trace_id() -> str:
"""Generate a trace ID for distributed tracing."""
return uuid.uuid4().hex
def _get_timestamp() -> str:
"""Get current timestamp in ISO format."""
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,
config: Optional[Dict[str, Any]] = None,
session_id: Optional[str] = None,
options: Optional[Dict[str, Any]] = None,
) -> RecipeResult:
"""
Run a recipe by name.
Args:
name: Recipe name or URI (e.g., "support-reply", "github:user/repo/recipe")
input: Input data (string path or dict)
config: Optional config overrides
session_id: Optional session ID for state grouping
options: Execution options:
- timeout_sec: Timeout in seconds (default: 300)
- dry_run: Validate without executing (default: False)
- verbose: Enable verbose output (default: False)
- force: Force execution even with missing deps (default: False)
- offline: Use only cached templates (default: False)
- mode: Execution mode - "dev" or "prod" (default: "dev")
- allow_dangerous_tools: Allow dangerous tools (default: False)
Returns:
RecipeResult with run_id, status, output, metrics, trace
Example:
>>> from praisonai import recipe
>>> result = recipe.run("support-reply", input={"ticket_id": "T-123"})
>>> if result.ok:
... print(result.output["reply"])
"""
options = options or {}
config = config or {}
input = input or {}
# Generate identifiers
run_id = _generate_run_id()
trace_id = _generate_trace_id()
session_id = session_id or f"session-{uuid.uuid4().hex[:8]}"
start_time = time.time()
trace = {
"run_id": run_id,
"trace_id": trace_id,
"session_id": session_id,
}
try:
# Load template
recipe_config = _load_recipe(name, offline=options.get("offline", False))
if recipe_config is None:
return RecipeResult(
run_id=run_id,
recipe=name,
version="unknown",
status=RecipeStatus.FAILED,
error=f"Recipe not found: {name}",
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
# Check dependencies
if not options.get("force", False):
dep_result = _check_dependencies(recipe_config)
if not dep_result["all_satisfied"]:
missing = _format_missing_deps(dep_result)
return RecipeResult(
run_id=run_id,
recipe=recipe_config.name,
version=recipe_config.version,
status=RecipeStatus.MISSING_DEPS,
error=f"Missing dependencies: {', '.join(missing)}",
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
# Check tool permissions
if not options.get("allow_dangerous_tools", False):
policy_error = _check_tool_policy(recipe_config)
if policy_error:
return RecipeResult(
run_id=run_id,
recipe=recipe_config.name,
version=recipe_config.version,
status=RecipeStatus.POLICY_DENIED,
error=policy_error,
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
# Dry run mode
if options.get("dry_run", False):
return RecipeResult(
run_id=run_id,
recipe=recipe_config.name,
version=recipe_config.version,
status=RecipeStatus.DRY_RUN,
output={
"plan": "Would execute recipe with provided config",
"recipe": recipe_config.name,
"config": {**recipe_config.defaults, **config},
},
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
# Merge input and config
if isinstance(input, str):
merged_config = {**recipe_config.defaults, "input": input, **config}
else:
merged_config = {**recipe_config.defaults, **input, **config}
# Execute recipe
output = _execute_recipe(
recipe_config,
merged_config,
session_id,
options,
)
duration = time.time() - start_time
return RecipeResult(
run_id=run_id,
recipe=recipe_config.name,
version=recipe_config.version,
status=RecipeStatus.SUCCESS,
output=output,
metrics={"duration_sec": round(duration, 2)},
trace=trace,
)
except RecipeNotFoundError as e:
return RecipeResult(
run_id=run_id,
recipe=name,
version="unknown",
status=RecipeStatus.FAILED,
error=str(e),
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
except RecipeDependencyError as e:
return RecipeResult(
run_id=run_id,
recipe=e.recipe or name,
version="unknown",
status=RecipeStatus.MISSING_DEPS,
error=str(e),
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
except RecipePolicyError as e:
return RecipeResult(
run_id=run_id,
recipe=e.recipe or name,
version="unknown",
status=RecipeStatus.POLICY_DENIED,
error=str(e),
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
except RecipeTimeoutError as e:
return RecipeResult(
run_id=run_id,
recipe=name,
version="unknown",
status=RecipeStatus.TIMEOUT,
error=str(e),
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
except Exception as e:
return RecipeResult(
run_id=run_id,
recipe=name,
version="unknown",
status=RecipeStatus.FAILED,
error=str(e),
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
def run_stream(
name: str,
input: Union[str, Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
session_id: Optional[str] = None,
options: Optional[Dict[str, Any]] = None,
) -> Iterator[RecipeEvent]:
"""
Run a recipe with streaming events.
Yields RecipeEvent objects for progress tracking.
Args:
name: Recipe name or URI
input: Input data
config: Optional config overrides
session_id: Optional session ID
options: Execution options
Yields:
RecipeEvent objects with event_type and data
Example:
>>> for event in recipe.run_stream("transcript-generator", input="audio.mp3"):
... print(f"[{event.event_type}] {event.data}")
"""
options = options or {}
config = config or {}
input = input or {}
run_id = _generate_run_id()
trace_id = _generate_trace_id()
session_id = session_id or f"session-{uuid.uuid4().hex[:8]}"
# Started event
yield RecipeEvent(
event_type="started",
data={
"run_id": run_id,
"recipe": name,
"trace_id": trace_id,
"session_id": session_id,
},
)
try:
# Load recipe
yield RecipeEvent(
event_type="progress",
data={"step": "loading", "message": f"Loading recipe: {name}"},
)
recipe_config = _load_recipe(name, offline=options.get("offline", False))
if recipe_config is None:
yield RecipeEvent(
event_type="error",
data={"code": "not_found", "message": f"Recipe not found: {name}"},
)
return
# Check dependencies
yield RecipeEvent(
event_type="progress",
data={"step": "checking_deps", "message": "Checking dependencies"},
)
if not options.get("force", False):
dep_result = _check_dependencies(recipe_config)
if not dep_result["all_satisfied"]:
missing = _format_missing_deps(dep_result)
yield RecipeEvent(
event_type="error",
data={
"code": "missing_deps",
"message": f"Missing dependencies: {', '.join(missing)}",
"missing": missing,
},
)
return
# Dry run
if options.get("dry_run", False):
yield RecipeEvent(
event_type="completed",
data={
"run_id": run_id,
"status": RecipeStatus.DRY_RUN,
"message": "Dry run completed",
},
)
return
# Execute
yield RecipeEvent(
event_type="progress",
data={"step": "executing", "message": "Executing recipe"},
)
# Merge config
if isinstance(input, str):
merged_config = {**recipe_config.defaults, "input": input, **config}
else:
merged_config = {**recipe_config.defaults, **input, **config}
start_time = time.time()
output = _execute_recipe(recipe_config, merged_config, session_id, options)
duration = time.time() - start_time
# Output event
yield RecipeEvent(
event_type="output",
data={"output": output},
)
# Completed event
yield RecipeEvent(
event_type="completed",
data={
"run_id": run_id,
"status": RecipeStatus.SUCCESS,
"duration_sec": round(duration, 2),
},
)
except Exception as e:
yield RecipeEvent(
event_type="error",
data={"code": "execution_error", "message": str(e)},
)
def validate(name: str, offline: bool = False) -> ValidationResult:
"""
Validate a recipe and check dependencies.
Args:
name: Recipe name or URI
offline: Use only cached templates
Returns:
ValidationResult with valid status, errors, warnings, dependencies
Example:
>>> result = recipe.validate("support-reply")
>>> if result.valid:
... print("Recipe is valid")
>>> else:
... print(f"Errors: {result.errors}")
"""
errors = []
warnings = []
try:
recipe_config = _load_recipe(name, offline=offline)
if recipe_config is None:
return ValidationResult(
valid=False,
recipe=name,
version="unknown",
errors=[f"Recipe not found: {name}"],
)
# Check dependencies
dep_result = _check_dependencies(recipe_config)
# Check for missing required deps
for pkg in dep_result.get("packages", []):
if not pkg.get("available", False):
errors.append(f"Missing package: {pkg['name']}")
for env in dep_result.get("env", []):
if not env.get("available", False):
errors.append(f"Missing env var: ${env['name']}")
for ext in dep_result.get("external", []):
if not ext.get("available", False):
warnings.append(f"Missing external tool: {ext['name']}")
# Check tool policy
policy_error = _check_tool_policy(recipe_config)
if policy_error:
warnings.append(f"Tool policy: {policy_error}")
return ValidationResult(
valid=len(errors) == 0,
recipe=recipe_config.name,
version=recipe_config.version,
errors=errors,
warnings=warnings,
dependencies=dep_result,
)
except Exception as e:
return ValidationResult(
valid=False,
recipe=name,
version="unknown",
errors=[str(e)],
)
def list_recipes(
source_filter: Optional[str] = None,
tags: Optional[List[str]] = None,
offline: bool = False,
) -> List[RecipeInfo]:
"""
List available recipes.
Args:
source_filter: Filter by source (local, package, github)
tags: Filter by tags
offline: Use only cached templates
Returns:
List of RecipeInfo objects
Example:
>>> recipes = recipe.list_recipes(tags=["video"])
>>> for r in recipes:
... print(f"{r.name}: {r.description}")
"""
try:
from praisonai.templates import TemplateDiscovery
discovery = TemplateDiscovery()
templates = discovery.list_templates(source_filter=source_filter)
recipes = []
for t in templates:
# Filter by tags if specified
if tags:
template_tags = getattr(t, 'tags', []) or []
if not any(tag in template_tags for tag in tags):
continue
recipes.append(RecipeInfo(
name=t.name,
version=getattr(t, 'version', '1.0.0'),
description=getattr(t, 'description', ''),
tags=getattr(t, 'tags', []) or [],
path=str(t.path) if hasattr(t, 'path') else '',
source=getattr(t, 'source', 'local'),
))
return recipes
except Exception:
return []
def describe(name: str, offline: bool = False) -> Optional[RecipeConfig]:
"""
Get detailed information about a recipe.
Args:
name: Recipe name or URI
offline: Use only cached templates
Returns:
RecipeConfig with full recipe details, or None if not found
Example:
>>> info = recipe.describe("support-reply")
>>> print(f"Required env vars: {info.get_required_env()}")
"""
return _load_recipe(name, offline=offline)
# --- Internal Functions ---
def _load_recipe(name: str, offline: bool = False) -> Optional[RecipeConfig]:
"""Load a recipe by name or URI."""
try:
from praisonai.templates import TemplateDiscovery, TemplateLoader
discovery = TemplateDiscovery()
discovered = discovery.find_template(name)
if discovered:
loader = TemplateLoader(offline=offline)
template = loader.load(str(discovered.path))
return RecipeConfig(
name=template.name,
version=template.version,
description=template.description,
author=template.author,
license=template.license,
tags=template.tags,
requires=template.requires,
tools=template.raw.get("tools", {}),
config_schema=template.config_schema,
defaults=template.defaults,
outputs=template.raw.get("outputs", []),
governance=template.raw.get("governance", {}),
data_policy=template.raw.get("data_policy", {}),
path=str(template.path) if template.path else None,
raw=template.raw,
)
# Try loading directly as URI
loader = TemplateLoader(offline=offline)
try:
template = loader.load(name)
return RecipeConfig(
name=template.name,
version=template.version,
description=template.description,
author=template.author,
license=template.license,
tags=template.tags,
requires=template.requires,
tools=template.raw.get("tools", {}),
config_schema=template.config_schema,
defaults=template.defaults,
outputs=template.raw.get("outputs", []),
governance=template.raw.get("governance", {}),
data_policy=template.raw.get("data_policy", {}),
path=str(template.path) if template.path else None,
raw=template.raw,
)
except Exception:
pass
return None
except Exception:
return None
def _check_dependencies(recipe_config: RecipeConfig) -> Dict[str, Any]:
"""Check if recipe dependencies are satisfied."""
result = {
"all_satisfied": True,
"packages": [],
"env": [],
"tools": [],
"external": [],
}
# Check Python packages
for pkg in recipe_config.get_required_packages():
try:
__import__(pkg.replace("-", "_"))
result["packages"].append({"name": pkg, "available": True})
except ImportError:
result["packages"].append({"name": pkg, "available": False})
result["all_satisfied"] = False
# Check environment variables
for env_var in recipe_config.get_required_env():
available = env_var in os.environ
result["env"].append({"name": env_var, "available": available})
if not available:
result["all_satisfied"] = False
# Check external tools
import shutil
for ext in recipe_config.get_external_deps():
ext_name = ext.get("name", ext) if isinstance(ext, dict) else ext
available = shutil.which(ext_name) is not None
result["external"].append({"name": ext_name, "available": available})
# External tools are warnings, not errors
return result
def _format_missing_deps(dep_result: Dict[str, Any]) -> List[str]:
"""Format missing dependencies as a list of strings."""
missing = []
for pkg in dep_result.get("packages", []):
if not pkg.get("available", False):
missing.append(pkg["name"])
for env in dep_result.get("env", []):
if not env.get("available", False):
missing.append(f"${env['name']}")
return missing
def _check_tool_policy(recipe_config: RecipeConfig) -> Optional[str]:
"""Check if recipe uses denied tools. Returns error message or None."""
allowed = set(recipe_config.get_allowed_tools())
denied = set(recipe_config.get_denied_tools())
required = set(recipe_config.get_required_tools())
# Check if any required tools are in default denied list
for tool in required:
if tool in DEFAULT_DENIED_TOOLS and tool not in allowed:
return f"Tool '{tool}' is denied by default. Use allow_dangerous_tools=True to override."
# Check explicit denials
for tool in required:
if tool in denied:
return f"Tool '{tool}' is explicitly denied by recipe policy."
return None
def _execute_recipe(
recipe_config: RecipeConfig,
merged_config: Dict[str, Any],
session_id: str,
options: Dict[str, Any],
) -> Any:
"""Execute the recipe workflow."""
try:
from praisonai.templates import TemplateLoader
from praisonai.templates.tool_override import create_tool_registry_with_overrides
loader = TemplateLoader()
# Load workflow config
from praisonai.templates.loader import TemplateConfig as LoaderTemplateConfig
# Create a TemplateConfig compatible with loader
template_path = Path(recipe_config.path) if recipe_config.path else None
loader_config = LoaderTemplateConfig(
name=recipe_config.name,
description=recipe_config.description,
version=recipe_config.version,
author=recipe_config.author,
license=recipe_config.license,
tags=recipe_config.tags,
requires=recipe_config.requires,
workflow_file=recipe_config.raw.get("workflow", "workflow.yaml"),
agents_file=recipe_config.raw.get("agents", "agents.yaml"),
config_schema=recipe_config.config_schema,
defaults=merged_config,
skills=recipe_config.raw.get("skills", []),
cli=recipe_config.raw.get("cli", {}),
raw=recipe_config.raw,
path=template_path,
)
workflow_config = loader.load_workflow_config(loader_config)
# Build tool registry
tool_registry = create_tool_registry_with_overrides(
include_defaults=True,
template_dir=recipe_config.path,
)
# Execute based on workflow type
if "agents" in workflow_config and "tasks" in workflow_config:
return _execute_praisonai_workflow(
workflow_config, merged_config, tool_registry, options
)
elif "steps" in workflow_config:
return _execute_steps_workflow(
workflow_config, merged_config, tool_registry, options
)
else:
# Simple agent execution
return _execute_simple_agent(
workflow_config, merged_config, tool_registry, options
)
except ImportError as e:
raise RecipeError(f"Missing dependency for recipe execution: {e}")
except Exception as e:
raise RecipeError(f"Recipe execution failed: {e}")
def _execute_praisonai_workflow(
workflow_config: Dict[str, Any],
config: Dict[str, Any],
tool_registry: Any,
options: Dict[str, Any],
) -> Any:
"""Execute a PraisonAI agents/tasks workflow."""
from praisonaiagents import Agent, Task, PraisonAIAgents
from praisonai.templates.tool_override import resolve_tools
agents = []
agent_map = {}
for agent_cfg in workflow_config.get("agents", []):
agent_tools = resolve_tools(
agent_cfg.get("tools", []),
registry=tool_registry,
)
agent = Agent(
name=agent_cfg.get("name", "Agent"),
role=agent_cfg.get("role", ""),
goal=agent_cfg.get("goal", ""),
backstory=agent_cfg.get("backstory", ""),
tools=agent_tools if agent_tools else None,
llm=agent_cfg.get("llm"),
verbose=options.get("verbose", False),
)
agents.append(agent)
agent_map[agent_cfg.get("name")] = agent
tasks = []
for task_cfg in workflow_config.get("tasks", []):
agent_name = task_cfg.get("agent")
agent = agent_map.get(agent_name, agents[0] if agents else None)
# Substitute config values in description
description = task_cfg.get("description", "")
try:
description = description.format(**config)
except KeyError:
pass
task = Task(
name=task_cfg.get("name", "Task"),
description=description,
expected_output=task_cfg.get("expected_output", ""),
agent=agent,
)
tasks.append(task)
praison = PraisonAIAgents(
agents=agents,
tasks=tasks,
process=workflow_config.get("process", "sequential"),
verbose=options.get("verbose", 1) if options.get("verbose") else 0,
)
return praison.start()
def _execute_steps_workflow(
workflow_config: Dict[str, Any],
config: Dict[str, Any],
tool_registry: Any,
options: Dict[str, Any],
) -> Any:
"""Execute a steps-based workflow."""
# For now, convert to simple execution
return {"message": "Steps workflow executed", "config": config}
def _execute_simple_agent(
workflow_config: Dict[str, Any],
config: Dict[str, Any],
tool_registry: Any,
options: Dict[str, Any],
) -> Any:
"""Execute a simple single-agent workflow."""
from praisonaiagents import Agent
agent = Agent(
name=workflow_config.get("name", "RecipeAgent"),
role=workflow_config.get("role", "AI Assistant"),
goal=workflow_config.get("goal", "Complete the task"),
backstory=workflow_config.get("backstory", ""),
verbose=options.get("verbose", False),
)
prompt = config.get("input", config.get("prompt", ""))
if prompt:
return agent.chat(prompt)
return {"message": "Recipe executed", "config": config}
@@ -0,0 +1,54 @@
"""
Recipe Exceptions
Custom exceptions for recipe operations.
"""
class RecipeError(Exception):
"""Base exception for recipe operations."""
def __init__(self, message: str, recipe: str = None, details: dict = None):
super().__init__(message)
self.message = message
self.recipe = recipe
self.details = details or {}
class RecipeNotFoundError(RecipeError):
"""Recipe not found."""
pass
class RecipeDependencyError(RecipeError):
"""Missing dependencies for recipe execution."""
def __init__(self, message: str, recipe: str = None, missing: list = None):
super().__init__(message, recipe, {"missing": missing or []})
self.missing = missing or []
class RecipePolicyError(RecipeError):
"""Security policy blocked execution."""
def __init__(self, message: str, recipe: str = None, policy: str = None):
super().__init__(message, recipe, {"policy": policy})
self.policy = policy
class RecipeValidationError(RecipeError):
"""Recipe validation failed."""
def __init__(self, message: str, recipe: str = None, errors: list = None):
super().__init__(message, recipe, {"errors": errors or []})
self.errors = errors or []
class RecipeTimeoutError(RecipeError):
"""Recipe execution timed out."""
pass
class RecipeExecutionError(RecipeError):
"""Recipe execution failed."""
pass
+402
View File
@@ -0,0 +1,402 @@
"""
Run History Storage Module
Provides storage and retrieval of recipe run history for:
- Export/replay functionality
- Audit trails
- Debugging and analysis
"""
import json
import shutil
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional
from .models import RecipeResult
# Default storage path
DEFAULT_RUNS_PATH = Path.home() / ".praison" / "runs"
class RunHistoryError(Exception):
"""Base exception for run history operations."""
pass
class RunNotFoundError(RunHistoryError):
"""Run not found in history."""
pass
def _get_timestamp() -> str:
"""Get current timestamp in ISO format."""
return datetime.now(timezone.utc).isoformat()
class RunHistory:
"""
Local run history storage.
Storage structure:
~/.praison/runs/
├── index.json # Run index for fast lookup
└── <run_id>/
├── run.json # Run metadata and result
├── input.json # Input data
├── output.json # Output data
└── events.jsonl # Event stream (if streaming)
"""
def __init__(self, path: Optional[Path] = None):
"""Initialize run history storage."""
self.path = Path(path) if path else DEFAULT_RUNS_PATH
self.index_path = self.path / "index.json"
self._ensure_structure()
def _ensure_structure(self):
"""Ensure storage directory structure exists."""
self.path.mkdir(parents=True, exist_ok=True)
if not self.index_path.exists():
self._save_index({"runs": {}, "updated": _get_timestamp()})
def _load_index(self) -> Dict[str, Any]:
"""Load run index."""
if self.index_path.exists():
with open(self.index_path) as f:
return json.load(f)
return {"runs": {}, "updated": _get_timestamp()}
def _save_index(self, index: Dict[str, Any]):
"""Save run index."""
index["updated"] = _get_timestamp()
with open(self.index_path, "w") as f:
json.dump(index, f, indent=2)
def store(
self,
result: RecipeResult,
input_data: Optional[Dict[str, Any]] = None,
events: Optional[List[Dict[str, Any]]] = None,
data_policy: Optional[Dict[str, Any]] = None,
) -> str:
"""
Store a run result in history.
Args:
result: RecipeResult to store
input_data: Original input data
events: List of streaming events (if any)
data_policy: Data policy for retention/export
Returns:
run_id
"""
run_id = result.run_id
run_dir = self.path / run_id
run_dir.mkdir(parents=True, exist_ok=True)
# Check data policy
export_allowed = True
retention_days = None
if data_policy:
export_allowed = data_policy.get("export_allowed", True)
retention_days = data_policy.get("retention_days")
# Store run metadata
run_data = {
"run_id": run_id,
"recipe": result.recipe,
"version": result.version,
"status": result.status,
"ok": result.ok,
"error": result.error,
"metrics": result.metrics,
"trace": result.trace,
"stored_at": _get_timestamp(),
"export_allowed": export_allowed,
"retention_days": retention_days,
}
with open(run_dir / "run.json", "w") as f:
json.dump(run_data, f, indent=2)
# Store input (if allowed)
if input_data and export_allowed:
with open(run_dir / "input.json", "w") as f:
json.dump(input_data, f, indent=2)
# Store output (if allowed)
if result.output and export_allowed:
with open(run_dir / "output.json", "w") as f:
json.dump(result.output, f, indent=2)
# Store events (if any)
if events:
with open(run_dir / "events.jsonl", "w") as f:
for event in events:
f.write(json.dumps(event) + "\n")
# Update index
index = self._load_index()
index["runs"][run_id] = {
"recipe": result.recipe,
"version": result.version,
"status": result.status,
"stored_at": run_data["stored_at"],
"session_id": result.trace.get("session_id"),
}
self._save_index(index)
return run_id
def get(self, run_id: str) -> Dict[str, Any]:
"""
Get a run from history.
Args:
run_id: Run ID to retrieve
Returns:
Dict with run data, input, output, events
Raises:
RunNotFoundError: If run not found
"""
run_dir = self.path / run_id
if not run_dir.exists():
raise RunNotFoundError(f"Run not found: {run_id}")
# Load run metadata
run_path = run_dir / "run.json"
if not run_path.exists():
raise RunNotFoundError(f"Run metadata missing: {run_id}")
with open(run_path) as f:
run_data = json.load(f)
# Load input
input_path = run_dir / "input.json"
if input_path.exists():
with open(input_path) as f:
run_data["input"] = json.load(f)
# Load output
output_path = run_dir / "output.json"
if output_path.exists():
with open(output_path) as f:
run_data["output"] = json.load(f)
# Load events
events_path = run_dir / "events.jsonl"
if events_path.exists():
events = []
with open(events_path) as f:
for line in f:
if line.strip():
events.append(json.loads(line))
run_data["events"] = events
return run_data
def list_runs(
self,
recipe: Optional[str] = None,
session_id: Optional[str] = None,
status: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> List[Dict[str, Any]]:
"""
List runs from history.
Args:
recipe: Filter by recipe name
session_id: Filter by session ID
status: Filter by status
limit: Maximum number of results
offset: Offset for pagination
Returns:
List of run summaries
"""
index = self._load_index()
runs = []
for run_id, info in index["runs"].items():
# Apply filters
if recipe and info.get("recipe") != recipe:
continue
if session_id and info.get("session_id") != session_id:
continue
if status and info.get("status") != status:
continue
runs.append({
"run_id": run_id,
**info,
})
# Sort by stored_at descending
runs.sort(key=lambda x: x.get("stored_at", ""), reverse=True)
# Apply pagination
return runs[offset:offset + limit]
def export(
self,
run_id: str,
output_path: Optional[Path] = None,
) -> Path:
"""
Export a run as a JSON bundle.
Args:
run_id: Run ID to export
output_path: Output file path (default: <run_id>.export.json)
Returns:
Path to exported file
Raises:
RunNotFoundError: If run not found
RunHistoryError: If export not allowed
"""
run_data = self.get(run_id)
# Check if export is allowed
if not run_data.get("export_allowed", True):
raise RunHistoryError(f"Export not allowed for run: {run_id}")
# Prepare export bundle
export_bundle = {
"format": "praison-run-export",
"version": "1.0",
"exported_at": _get_timestamp(),
"run": run_data,
}
# Write to file
output_path = Path(output_path) if output_path else Path(f"{run_id}.export.json")
with open(output_path, "w") as f:
json.dump(export_bundle, f, indent=2)
return output_path
def delete(self, run_id: str) -> bool:
"""
Delete a run from history.
Args:
run_id: Run ID to delete
Returns:
True if deleted
"""
run_dir = self.path / run_id
if run_dir.exists():
shutil.rmtree(run_dir)
index = self._load_index()
if run_id in index["runs"]:
del index["runs"][run_id]
self._save_index(index)
return True
def cleanup(self, retention_days: Optional[int] = None) -> int:
"""
Clean up old runs based on retention policy.
Args:
retention_days: Override retention days (default: use per-run policy)
Returns:
Number of runs deleted
"""
index = self._load_index()
deleted = 0
now = datetime.now(timezone.utc)
for run_id in list(index["runs"].keys()):
run_dir = self.path / run_id
run_path = run_dir / "run.json"
if not run_path.exists():
# Clean up orphaned index entry
del index["runs"][run_id]
deleted += 1
continue
with open(run_path) as f:
run_data = json.load(f)
# Check retention
run_retention = retention_days or run_data.get("retention_days")
if run_retention:
stored_at = datetime.fromisoformat(run_data["stored_at"].replace("Z", "+00:00"))
if now - stored_at > timedelta(days=run_retention):
self.delete(run_id)
deleted += 1
return deleted
def get_stats(self) -> Dict[str, Any]:
"""Get storage statistics."""
index = self._load_index()
total_runs = len(index["runs"])
total_size = 0
for run_id in index["runs"]:
run_dir = self.path / run_id
if run_dir.exists():
for f in run_dir.iterdir():
if f.is_file():
total_size += f.stat().st_size
return {
"total_runs": total_runs,
"total_size_bytes": total_size,
"storage_path": str(self.path),
}
# Global instance for convenience
_default_history: Optional[RunHistory] = None
def get_history(path: Optional[Path] = None) -> RunHistory:
"""Get or create default run history instance."""
global _default_history
if path:
return RunHistory(path)
if _default_history is None:
_default_history = RunHistory()
return _default_history
def store_run(
result: RecipeResult,
input_data: Optional[Dict[str, Any]] = None,
events: Optional[List[Dict[str, Any]]] = None,
data_policy: Optional[Dict[str, Any]] = None,
) -> str:
"""Convenience function to store a run."""
return get_history().store(result, input_data, events, data_policy)
def get_run(run_id: str) -> Dict[str, Any]:
"""Convenience function to get a run."""
return get_history().get(run_id)
def list_runs(**kwargs) -> List[Dict[str, Any]]:
"""Convenience function to list runs."""
return get_history().list_runs(**kwargs)
def export_run(run_id: str, output_path: Optional[Path] = None) -> Path:
"""Convenience function to export a run."""
return get_history().export(run_id, output_path)
+266
View File
@@ -0,0 +1,266 @@
"""
Recipe Data Models
Defines the core data structures for recipe execution.
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import IntEnum
from typing import Any, Dict, List, Optional
class ExitCode(IntEnum):
"""Stable exit codes for CLI operations."""
SUCCESS = 0
GENERAL_ERROR = 1
VALIDATION_ERROR = 2
RUNTIME_ERROR = 3
POLICY_DENIED = 4
TIMEOUT = 5
MISSING_DEPS = 6
NOT_FOUND = 7
class RecipeStatus:
"""Recipe execution status constants."""
SUCCESS = "success"
FAILED = "failed"
DRY_RUN = "dry_run"
POLICY_DENIED = "policy_denied"
TIMEOUT = "timeout"
MISSING_DEPS = "missing_deps"
VALIDATION_ERROR = "validation_error"
@dataclass
class RecipeResult:
"""
Result of a recipe execution.
Attributes:
run_id: Unique identifier for this execution
recipe: Recipe name
version: Recipe version
status: Execution status (success, failed, dry_run, policy_denied, etc.)
output: Recipe-specific output data
metrics: Execution metrics (duration, tokens, etc.)
error: Error message if failed
trace: Tracing identifiers (run_id, trace_id, session_id, agent_id)
"""
run_id: str
recipe: str
version: str
status: str
output: Any = None
metrics: Dict[str, Any] = field(default_factory=dict)
error: Optional[str] = None
trace: Dict[str, str] = field(default_factory=dict)
@property
def ok(self) -> bool:
"""Check if execution was successful."""
return self.status in (RecipeStatus.SUCCESS, RecipeStatus.DRY_RUN)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"ok": self.ok,
"run_id": self.run_id,
"recipe": self.recipe,
"version": self.version,
"status": self.status,
"output": self.output,
"metrics": self.metrics,
"error": self.error,
"trace": self.trace,
}
def to_exit_code(self) -> int:
"""Convert status to CLI exit code."""
status_to_code = {
RecipeStatus.SUCCESS: ExitCode.SUCCESS,
RecipeStatus.DRY_RUN: ExitCode.SUCCESS,
RecipeStatus.FAILED: ExitCode.RUNTIME_ERROR,
RecipeStatus.POLICY_DENIED: ExitCode.POLICY_DENIED,
RecipeStatus.TIMEOUT: ExitCode.TIMEOUT,
RecipeStatus.MISSING_DEPS: ExitCode.MISSING_DEPS,
RecipeStatus.VALIDATION_ERROR: ExitCode.VALIDATION_ERROR,
}
return status_to_code.get(self.status, ExitCode.GENERAL_ERROR)
@dataclass
class RecipeEvent:
"""
Streaming event from recipe execution.
Attributes:
event_type: Type of event (started, progress, log, output, completed, error)
data: Event-specific data
timestamp: ISO format timestamp
"""
event_type: str
data: Dict[str, Any] = field(default_factory=dict)
timestamp: str = ""
def __post_init__(self):
if not self.timestamp:
self.timestamp = datetime.now(timezone.utc).isoformat()
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON/SSE serialization."""
return {
"event": self.event_type,
"data": self.data,
"timestamp": self.timestamp,
}
def to_sse(self) -> str:
"""Convert to Server-Sent Events format."""
import json
return f"event: {self.event_type}\ndata: {json.dumps(self.data)}\n\n"
@dataclass
class RecipeConfig:
"""
Recipe configuration and metadata.
Attributes:
name: Recipe name (kebab-case)
version: SemVer version string
description: Recipe description
author: Author name or identifier
license: License identifier (e.g., Apache-2.0)
tags: Discovery tags
requires: Dependencies (packages, env, tools, external)
tools: Tool permissions (allow, deny)
config_schema: JSON Schema for input configuration
defaults: Default configuration values
outputs: Expected output definitions
governance: Governance settings (approval, cost limits, audit)
data_policy: Data handling policy (PII, retention)
path: Source path of the recipe
"""
name: str
version: str = "1.0.0"
description: str = ""
author: Optional[str] = None
license: Optional[str] = None
tags: List[str] = field(default_factory=list)
# Dependencies
requires: Dict[str, Any] = field(default_factory=dict)
# Tool permissions
tools: Dict[str, List[str]] = field(default_factory=dict)
# Configuration
config_schema: Dict[str, Any] = field(default_factory=dict)
defaults: Dict[str, Any] = field(default_factory=dict)
# Outputs
outputs: List[Dict[str, Any]] = field(default_factory=list)
# Governance
governance: Dict[str, Any] = field(default_factory=dict)
# Data policy
data_policy: Dict[str, Any] = field(default_factory=dict)
# Source path
path: Optional[str] = None
# Raw config dict
raw: Dict[str, Any] = field(default_factory=dict)
def get_allowed_tools(self) -> List[str]:
"""Get list of explicitly allowed tools."""
return self.tools.get("allow", [])
def get_denied_tools(self) -> List[str]:
"""Get list of explicitly denied tools."""
return self.tools.get("deny", [])
def get_required_packages(self) -> List[str]:
"""Get list of required Python packages."""
packages = self.requires.get("packages", [])
return [packages] if isinstance(packages, str) else packages
def get_required_env(self) -> List[str]:
"""Get list of required environment variables."""
env = self.requires.get("env", [])
return [env] if isinstance(env, str) else env
def get_required_tools(self) -> List[str]:
"""Get list of required tools."""
tools = self.requires.get("tools", [])
return [tools] if isinstance(tools, str) else tools
def get_external_deps(self) -> List[Dict[str, Any]]:
"""Get list of external dependencies (ffmpeg, etc.)."""
return self.requires.get("external", [])
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"name": self.name,
"version": self.version,
"description": self.description,
"author": self.author,
"license": self.license,
"tags": self.tags,
"requires": self.requires,
"tools": self.tools,
"config_schema": self.config_schema,
"defaults": self.defaults,
"outputs": self.outputs,
"governance": self.governance,
"data_policy": self.data_policy,
"path": self.path,
}
@dataclass
class ValidationResult:
"""Result of recipe validation."""
valid: bool
recipe: str
version: str
errors: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
dependencies: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"valid": self.valid,
"recipe": self.recipe,
"version": self.version,
"errors": self.errors,
"warnings": self.warnings,
"dependencies": self.dependencies,
}
@dataclass
class RecipeInfo:
"""Recipe information for listing/discovery."""
name: str
version: str
description: str
tags: List[str]
path: str
source: str # local, package, github
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"name": self.name,
"version": self.version,
"description": self.description,
"tags": self.tags,
"path": self.path,
"source": self.source,
}
+422
View File
@@ -0,0 +1,422 @@
"""
Recipe Policy Module
Provides policy management for recipes:
- Policy packs (reusable org-wide policies)
- Tool allow/deny enforcement
- Network/file restrictions
- PII defaults
- Mode enforcement (dev/prod)
"""
import json
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Union
import yaml
class PolicyError(Exception):
"""Base exception for policy operations."""
pass
class PolicyDeniedError(PolicyError):
"""Action denied by policy."""
pass
# Default denied tools (dangerous by default)
DEFAULT_DENIED_TOOLS: Set[str] = {
"shell.exec",
"shell.run",
"shell_tool",
"execute_command",
"file.write",
"file.delete",
"fs.write",
"fs.delete",
"network.unrestricted",
"db.write",
"db.delete",
"db.drop",
}
# Default allowed tools (safe by default)
DEFAULT_ALLOWED_TOOLS: Set[str] = {
"web.search",
"web_search",
"tavily_search",
"file.read",
"fs.read",
"db.query",
"db.read",
}
class PolicyPack:
"""
Reusable policy pack for org-wide policy management.
Policy pack YAML format:
```yaml
name: my-org-policy
version: "1.0"
description: Organization-wide security policy
tools:
allow:
- web.search
- db.query
deny:
- shell.exec
- file.write
network:
allow_domains:
- api.openai.com
- api.anthropic.com
deny_domains:
- localhost
- 127.0.0.1
files:
allow_paths:
- /tmp
- ./outputs
deny_paths:
- /etc
- /var
pii:
mode: redact # allow, deny, redact
fields:
- email
- phone
- ssn
data:
retention_days: 30
export_allowed: true
modes:
dev:
allow_interactive_prompts: true
strict_tool_enforcement: false
prod:
allow_interactive_prompts: false
strict_tool_enforcement: true
require_auth: true
```
"""
def __init__(
self,
name: str = "default",
config: Optional[Dict[str, Any]] = None,
):
"""Initialize policy pack."""
self.name = name
self.config = config or {}
# Tool policies
self.allowed_tools: Set[str] = set(self.config.get("tools", {}).get("allow", []))
self.denied_tools: Set[str] = set(self.config.get("tools", {}).get("deny", []))
# Add defaults
self.denied_tools.update(DEFAULT_DENIED_TOOLS)
# Network policies
self.allowed_domains: Set[str] = set(self.config.get("network", {}).get("allow_domains", []))
self.denied_domains: Set[str] = set(self.config.get("network", {}).get("deny_domains", []))
# File policies
self.allowed_paths: List[str] = self.config.get("files", {}).get("allow_paths", [])
self.denied_paths: List[str] = self.config.get("files", {}).get("deny_paths", [])
# PII policy
self.pii_mode: str = self.config.get("pii", {}).get("mode", "allow")
self.pii_fields: List[str] = self.config.get("pii", {}).get("fields", [])
# Data policy
self.retention_days: Optional[int] = self.config.get("data", {}).get("retention_days")
self.export_allowed: bool = self.config.get("data", {}).get("export_allowed", True)
# Mode-specific settings
self.modes: Dict[str, Dict[str, Any]] = self.config.get("modes", {})
@classmethod
def load(cls, path: Union[str, Path]) -> "PolicyPack":
"""Load policy pack from file."""
path = Path(path)
if not path.exists():
raise PolicyError(f"Policy file not found: {path}")
with open(path) as f:
if path.suffix in (".yaml", ".yml"):
config = yaml.safe_load(f)
else:
config = json.load(f)
name = config.get("name", path.stem)
return cls(name=name, config=config)
def save(self, path: Union[str, Path]):
"""Save policy pack to file."""
path = Path(path)
data = {
"name": self.name,
"version": self.config.get("version", "1.0"),
"description": self.config.get("description", ""),
"tools": {
"allow": list(self.allowed_tools),
"deny": list(self.denied_tools),
},
"network": {
"allow_domains": list(self.allowed_domains),
"deny_domains": list(self.denied_domains),
},
"files": {
"allow_paths": self.allowed_paths,
"deny_paths": self.denied_paths,
},
"pii": {
"mode": self.pii_mode,
"fields": self.pii_fields,
},
"data": {
"retention_days": self.retention_days,
"export_allowed": self.export_allowed,
},
"modes": self.modes,
}
with open(path, "w") as f:
if path.suffix in (".yaml", ".yml"):
yaml.dump(data, f, default_flow_style=False)
else:
json.dump(data, f, indent=2)
def check_tool(self, tool_id: str, mode: str = "dev") -> bool:
"""
Check if a tool is allowed.
Args:
tool_id: Tool identifier
mode: Execution mode (dev/prod)
Returns:
True if allowed
Raises:
PolicyDeniedError: If tool is denied
"""
# Explicit deny always wins
if tool_id in self.denied_tools:
raise PolicyDeniedError(f"Tool denied by policy: {tool_id}")
# Check mode-specific settings
mode_config = self.modes.get(mode, {})
strict = mode_config.get("strict_tool_enforcement", mode == "prod")
if strict:
# In strict mode, tool must be explicitly allowed
if tool_id not in self.allowed_tools and tool_id not in DEFAULT_ALLOWED_TOOLS:
raise PolicyDeniedError(f"Tool not in allowlist (strict mode): {tool_id}")
return True
def check_domain(self, domain: str) -> bool:
"""Check if a network domain is allowed."""
if domain in self.denied_domains:
raise PolicyDeniedError(f"Domain denied by policy: {domain}")
if self.allowed_domains and domain not in self.allowed_domains:
raise PolicyDeniedError(f"Domain not in allowlist: {domain}")
return True
def check_path(self, path: str) -> bool:
"""Check if a file path is allowed."""
path_obj = Path(path).resolve()
for denied in self.denied_paths:
if str(path_obj).startswith(str(Path(denied).resolve())):
raise PolicyDeniedError(f"Path denied by policy: {path}")
if self.allowed_paths:
allowed = False
for allow in self.allowed_paths:
if str(path_obj).startswith(str(Path(allow).resolve())):
allowed = True
break
if not allowed:
raise PolicyDeniedError(f"Path not in allowlist: {path}")
return True
def get_data_policy(self) -> Dict[str, Any]:
"""Get data policy configuration."""
return {
"pii": {
"mode": self.pii_mode,
"fields": self.pii_fields,
},
"retention_days": self.retention_days,
"export_allowed": self.export_allowed,
}
def get_mode_config(self, mode: str) -> Dict[str, Any]:
"""Get mode-specific configuration."""
return self.modes.get(mode, {})
def merge(self, other: "PolicyPack") -> "PolicyPack":
"""
Merge another policy pack (other takes precedence).
Args:
other: Policy pack to merge
Returns:
New merged policy pack
"""
merged_config = {
"name": f"{self.name}+{other.name}",
"tools": {
"allow": list(self.allowed_tools | other.allowed_tools),
"deny": list(self.denied_tools | other.denied_tools),
},
"network": {
"allow_domains": list(self.allowed_domains | other.allowed_domains),
"deny_domains": list(self.denied_domains | other.denied_domains),
},
"files": {
"allow_paths": self.allowed_paths + other.allowed_paths,
"deny_paths": self.denied_paths + other.denied_paths,
},
"pii": {
"mode": other.pii_mode or self.pii_mode,
"fields": list(set(self.pii_fields + other.pii_fields)),
},
"data": {
"retention_days": other.retention_days or self.retention_days,
"export_allowed": other.export_allowed and self.export_allowed,
},
"modes": {**self.modes, **other.modes},
}
return PolicyPack(name=merged_config["name"], config=merged_config)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"name": self.name,
"tools": {
"allow": list(self.allowed_tools),
"deny": list(self.denied_tools),
},
"network": {
"allow_domains": list(self.allowed_domains),
"deny_domains": list(self.denied_domains),
},
"files": {
"allow_paths": self.allowed_paths,
"deny_paths": self.denied_paths,
},
"pii": {
"mode": self.pii_mode,
"fields": self.pii_fields,
},
"data": {
"retention_days": self.retention_days,
"export_allowed": self.export_allowed,
},
"modes": self.modes,
}
# Default policy packs
DEFAULT_DEV_POLICY = PolicyPack(
name="default-dev",
config={
"tools": {
"allow": list(DEFAULT_ALLOWED_TOOLS),
"deny": list(DEFAULT_DENIED_TOOLS),
},
"pii": {"mode": "allow"},
"modes": {
"dev": {
"allow_interactive_prompts": True,
"strict_tool_enforcement": False,
},
},
},
)
DEFAULT_PROD_POLICY = PolicyPack(
name="default-prod",
config={
"tools": {
"allow": list(DEFAULT_ALLOWED_TOOLS),
"deny": list(DEFAULT_DENIED_TOOLS),
},
"pii": {"mode": "redact", "fields": ["email", "phone", "ssn"]},
"modes": {
"prod": {
"allow_interactive_prompts": False,
"strict_tool_enforcement": True,
"require_auth": True,
},
},
},
)
def get_default_policy(mode: str = "dev") -> PolicyPack:
"""Get default policy for a mode."""
if mode == "prod":
return DEFAULT_PROD_POLICY
return DEFAULT_DEV_POLICY
def load_policy(
policy_path: Optional[Union[str, Path]] = None,
mode: str = "dev",
) -> PolicyPack:
"""
Load policy from file or return default.
Args:
policy_path: Path to policy file (optional)
mode: Execution mode
Returns:
PolicyPack instance
"""
if policy_path:
return PolicyPack.load(policy_path)
return get_default_policy(mode)
def check_tool_policy(
tool_id: str,
policy: Optional[PolicyPack] = None,
mode: str = "dev",
) -> bool:
"""
Check if a tool is allowed by policy.
Args:
tool_id: Tool identifier
policy: Policy pack (optional, uses default)
mode: Execution mode
Returns:
True if allowed
Raises:
PolicyDeniedError: If tool is denied
"""
policy = policy or get_default_policy(mode)
return policy.check_tool(tool_id, mode)
+558
View File
@@ -0,0 +1,558 @@
"""
Recipe Registry Module
Provides local and remote registry support for recipe distribution.
Supports:
- Local filesystem registry (~/.praison/registry)
- Remote HTTP registry (optional)
- Publish, pull, search, list operations
"""
import hashlib
import json
import os
import shutil
import tarfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from urllib.parse import urlparse
# Default registry paths
DEFAULT_REGISTRY_PATH = Path.home() / ".praison" / "registry"
DEFAULT_RUNS_PATH = Path.home() / ".praison" / "runs"
class RegistryError(Exception):
"""Base exception for registry operations."""
pass
class RecipeNotFoundError(RegistryError):
"""Recipe not found in registry."""
pass
class RecipeExistsError(RegistryError):
"""Recipe version already exists in registry."""
pass
class RegistryAuthError(RegistryError):
"""Authentication failed for registry."""
pass
def _get_timestamp() -> str:
"""Get current timestamp in ISO format."""
return datetime.now(timezone.utc).isoformat()
def _calculate_checksum(file_path: Path) -> str:
"""Calculate SHA256 checksum of a file."""
sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
return sha256.hexdigest()
class LocalRegistry:
"""
Local filesystem-based recipe registry.
Storage structure:
~/.praison/registry/
├── index.json # Registry index
└── recipes/
└── <name>/
└── <version>/
├── manifest.json
└── <name>-<version>.praison
"""
def __init__(self, path: Optional[Path] = None):
"""Initialize local registry."""
self.path = Path(path) if path else DEFAULT_REGISTRY_PATH
self.recipes_path = self.path / "recipes"
self.index_path = self.path / "index.json"
self._ensure_structure()
def _ensure_structure(self):
"""Ensure registry directory structure exists."""
self.path.mkdir(parents=True, exist_ok=True)
self.recipes_path.mkdir(parents=True, exist_ok=True)
if not self.index_path.exists():
self._save_index({"recipes": {}, "updated": _get_timestamp()})
def _load_index(self) -> Dict[str, Any]:
"""Load registry index."""
if self.index_path.exists():
with open(self.index_path) as f:
return json.load(f)
return {"recipes": {}, "updated": _get_timestamp()}
def _save_index(self, index: Dict[str, Any]):
"""Save registry index."""
index["updated"] = _get_timestamp()
with open(self.index_path, "w") as f:
json.dump(index, f, indent=2)
def publish(
self,
bundle_path: Union[str, Path],
force: bool = False,
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Publish a recipe bundle to the registry.
Args:
bundle_path: Path to .praison bundle file
force: Overwrite existing version if True
metadata: Additional metadata to store
Returns:
Dict with name, version, path, checksum
Raises:
RecipeExistsError: If version exists and force=False
RegistryError: If bundle is invalid
"""
bundle_path = Path(bundle_path)
if not bundle_path.exists():
raise RegistryError(f"Bundle not found: {bundle_path}")
# Extract manifest from bundle
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}")
name = manifest.get("name")
version = manifest.get("version")
if not name or not version:
raise RegistryError("Bundle manifest missing name or version")
# Check if version exists
recipe_dir = self.recipes_path / name / version
if recipe_dir.exists() and not force:
raise RecipeExistsError(f"Recipe {name}@{version} already exists. Use --force to overwrite.")
# Create recipe directory
recipe_dir.mkdir(parents=True, exist_ok=True)
# Copy bundle
bundle_name = f"{name}-{version}.praison"
dest_path = recipe_dir / bundle_name
shutil.copy2(bundle_path, dest_path)
# Calculate checksum
checksum = _calculate_checksum(dest_path)
# Create registry manifest
registry_manifest = {
"name": name,
"version": version,
"description": manifest.get("description", ""),
"tags": manifest.get("tags", []),
"author": manifest.get("author", ""),
"checksum": checksum,
"published_at": _get_timestamp(),
"bundle_path": str(dest_path),
"files": manifest.get("files", []),
**(metadata or {}),
}
# Save manifest
manifest_path = recipe_dir / "manifest.json"
with open(manifest_path, "w") as f:
json.dump(registry_manifest, f, indent=2)
# Update index
index = self._load_index()
if name not in index["recipes"]:
index["recipes"][name] = {"versions": {}, "latest": version}
index["recipes"][name]["versions"][version] = {
"checksum": checksum,
"published_at": registry_manifest["published_at"],
}
index["recipes"][name]["latest"] = version
self._save_index(index)
return {
"name": name,
"version": version,
"path": str(dest_path),
"checksum": checksum,
"published_at": registry_manifest["published_at"],
}
def pull(
self,
name: str,
version: Optional[str] = None,
output_dir: Optional[Path] = None,
verify_checksum: bool = True,
) -> Dict[str, Any]:
"""
Pull a recipe from the registry.
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
Raises:
RecipeNotFoundError: If recipe/version not found
"""
index = self._load_index()
if name not in index["recipes"]:
raise RecipeNotFoundError(f"Recipe not found: {name}")
recipe_info = index["recipes"][name]
version = version or recipe_info.get("latest")
if version not in recipe_info["versions"]:
raise RecipeNotFoundError(f"Version not found: {name}@{version}")
# Get bundle path
bundle_name = f"{name}-{version}.praison"
bundle_path = self.recipes_path / name / version / bundle_name
if not bundle_path.exists():
raise RecipeNotFoundError(f"Bundle file missing: {bundle_path}")
# Verify checksum
if verify_checksum:
expected = recipe_info["versions"][version]["checksum"]
actual = _calculate_checksum(bundle_path)
if expected != actual:
raise RegistryError(f"Checksum mismatch for {name}@{version}")
# Extract to output directory
output_dir = Path(output_dir) if output_dir else Path.cwd()
output_dir.mkdir(parents=True, exist_ok=True)
recipe_dir = output_dir / name
recipe_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(bundle_path, "r:gz") as tar:
tar.extractall(recipe_dir)
return {
"name": name,
"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 all recipes in the registry.
Args:
tags: Filter by tags (optional)
Returns:
List of recipe info dicts
"""
index = self._load_index()
recipes = []
for name, info in index["recipes"].items():
# Load full manifest for latest version
latest = info.get("latest")
if latest:
manifest_path = self.recipes_path / name / latest / "manifest.json"
if manifest_path.exists():
with open(manifest_path) as f:
manifest = json.load(f)
# Filter by tags if specified
if tags:
recipe_tags = manifest.get("tags", [])
if not any(t in recipe_tags for t in tags):
continue
recipes.append({
"name": name,
"version": latest,
"description": manifest.get("description", ""),
"tags": manifest.get("tags", []),
"versions": list(info["versions"].keys()),
})
return recipes
def search(
self,
query: str,
tags: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""
Search recipes by name, description, or tags.
Args:
query: Search query
tags: Filter by tags (optional)
Returns:
List of matching recipe info dicts
"""
all_recipes = self.list_recipes(tags=tags)
query_lower = query.lower()
results = []
for recipe in all_recipes:
# Search in name, description, tags
if (
query_lower in recipe["name"].lower()
or query_lower in recipe.get("description", "").lower()
or any(query_lower in t.lower() for t in recipe.get("tags", []))
):
results.append(recipe)
return results
def get_versions(self, name: str) -> List[str]:
"""Get all versions of a recipe."""
index = self._load_index()
if name not in index["recipes"]:
raise RecipeNotFoundError(f"Recipe not found: {name}")
return list(index["recipes"][name]["versions"].keys())
def get_info(self, name: str, version: Optional[str] = None) -> Dict[str, Any]:
"""Get detailed info about a recipe version."""
index = self._load_index()
if name not in index["recipes"]:
raise RecipeNotFoundError(f"Recipe not found: {name}")
recipe_info = index["recipes"][name]
version = version or recipe_info.get("latest")
manifest_path = self.recipes_path / name / version / "manifest.json"
if not manifest_path.exists():
raise RecipeNotFoundError(f"Version not found: {name}@{version}")
with open(manifest_path) as f:
return json.load(f)
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
"""
index = self._load_index()
if name not in index["recipes"]:
raise RecipeNotFoundError(f"Recipe not found: {name}")
if version:
# Delete specific version
version_dir = self.recipes_path / name / version
if version_dir.exists():
shutil.rmtree(version_dir)
if version in index["recipes"][name]["versions"]:
del index["recipes"][name]["versions"][version]
# Update latest if needed
versions = list(index["recipes"][name]["versions"].keys())
if versions:
index["recipes"][name]["latest"] = sorted(versions)[-1]
else:
del index["recipes"][name]
else:
# Delete all versions
recipe_dir = self.recipes_path / name
if recipe_dir.exists():
shutil.rmtree(recipe_dir)
del index["recipes"][name]
self._save_index(index)
return True
class RemoteRegistry:
"""
Remote HTTP-based recipe registry client.
Supports:
- Token-based authentication
- Publish/pull operations
- Search and list
"""
def __init__(
self,
url: str,
token: Optional[str] = None,
timeout: int = 30,
):
"""
Initialize remote registry client.
Args:
url: Registry base URL
token: Authentication token
timeout: Request timeout in seconds
"""
self.url = url.rstrip("/")
self.token = token or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
self.timeout = timeout
def _request(
self,
method: str,
path: str,
data: Optional[Dict] = None,
files: Optional[Dict] = 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
try:
req = urllib.request.Request(url, data=body, headers=headers, method=method)
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 failed")
elif e.code == 404:
raise RecipeNotFoundError(f"Not found: {path}")
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}")
def publish(
self,
bundle_path: Union[str, Path],
force: bool = False,
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Publish bundle to remote registry."""
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()
import base64
encoded = base64.b64encode(bundle_data).decode()
return self._request("POST", "/v1/recipes/publish", {
"bundle": encoded,
"force": force,
"metadata": metadata or {},
})
def pull(
self,
name: str,
version: Optional[str] = None,
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")
# Decode and save bundle
import base64
bundle_data = base64.b64decode(result["bundle"])
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}-{result['version']}.praison"
with open(bundle_path, "wb") as f:
f.write(bundle_data)
# Extract
recipe_dir = output_dir / name
recipe_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(bundle_path, "r:gz") as tar:
tar.extractall(recipe_dir)
return {
"name": name,
"version": result["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}"
if tags:
params += f"&tags={','.join(tags)}"
return self._request("GET", f"/v1/recipes/search{params}").get("results", [])
def get_registry(
registry: Optional[str] = None,
token: Optional[str] = None,
) -> Union[LocalRegistry, RemoteRegistry]:
"""
Get appropriate registry instance.
Args:
registry: Registry path or URL (default: local)
token: Auth token for remote registry
Returns:
LocalRegistry or RemoteRegistry instance
"""
if registry is None:
return LocalRegistry()
# Check if it's a URL
parsed = urlparse(registry)
if parsed.scheme in ("http", "https"):
return RemoteRegistry(registry, token=token)
# Local path
return LocalRegistry(Path(registry))
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more