Compare commits

...
4 Commits
Author SHA1 Message Date
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
Mervin Praison 6e1dc34864 Release v2.7.0 2025-12-29 01:33:40 +00:00
95 changed files with 16608 additions and 363 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.6.8" \
"praisonai>=2.7.2" \
"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.6.8" \
"praisonai>=2.7.2" \
"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.6.8" \
"praisonai>=2.7.2" \
"praisonai[ui]" \
"praisonai[crewai]"
+1
View File
@@ -30,6 +30,7 @@ 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) |
## 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()
+108
View File
@@ -0,0 +1,108 @@
"""
Performance Benchmark Example for PraisonAI.
This example demonstrates how to measure and verify performance metrics
for PraisonAI CLI and SDK imports.
Usage:
python benchmark_example.py
"""
import time
import tracemalloc
import sys
def measure_import_time(module_name: str) -> float:
"""Measure import time for a module."""
# Clear from cache if already imported
if module_name in sys.modules:
del sys.modules[module_name]
start = time.time()
__import__(module_name)
elapsed = time.time() - start
return elapsed * 1000 # Return milliseconds
def measure_memory_usage(import_func) -> tuple:
"""Measure memory usage during import."""
tracemalloc.start()
import_func()
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return current / 1024 / 1024, peak / 1024 / 1024 # Return MB
def main():
print("=" * 60)
print("PraisonAI Performance Benchmark")
print("=" * 60)
# Test 1: CLI Import Time
print("\n1. CLI Import Time")
print("-" * 40)
import os
os.environ['LOGLEVEL'] = 'WARNING'
start = time.time()
from praisonai.cli.main import PraisonAI
cli_import_time = (time.time() - start) * 1000
print(f" CLI import time: {cli_import_time:.0f}ms")
print(f" Status: {'✓ FAST' if cli_import_time < 500 else '✗ SLOW'}")
# Test 2: Memory Usage
print("\n2. Memory Usage")
print("-" * 40)
tracemalloc.start()
# Force re-import by clearing cache
for mod in list(sys.modules.keys()):
if 'praisonai' in mod:
del sys.modules[mod]
from praisonai.cli.main import PraisonAI as PraisonAI2
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f" Current memory: {current/1024/1024:.1f}MB")
print(f" Peak memory: {peak/1024/1024:.1f}MB")
print(f" Status: {'✓ LOW' if peak/1024/1024 < 50 else '✗ HIGH'}")
# Test 3: Lazy Loading Verification
print("\n3. Lazy Loading Verification")
print("-" * 40)
heavy_modules = ['litellm', 'instructor', 'praisonai.auto']
loaded = [m for m in heavy_modules if m in sys.modules]
if loaded:
print(f" Heavy modules loaded: {loaded}")
print(" Status: ✗ NOT LAZY")
else:
print(" Heavy modules NOT loaded at startup")
print(" Status: ✓ LAZY")
# Summary
print("\n" + "=" * 60)
print("Summary")
print("=" * 60)
all_pass = (
cli_import_time < 500 and
peak/1024/1024 < 50 and
len(loaded) == 0
)
if all_pass:
print("✓ All performance benchmarks PASSED")
else:
print("✗ Some benchmarks FAILED")
return 0 if all_pass else 1
if __name__ == "__main__":
sys.exit(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!")
+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()
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""
Import time benchmark for praisonaiagents.
This benchmark measures the import time of the praisonaiagents package
and verifies that heavy dependencies are not loaded at import time.
Usage:
python benchmarks/import_time.py
CI Gate:
- PASS: import time < 200ms
- WARN: import time 200-300ms
- FAIL: import time > 300ms
"""
import sys
import time
import statistics
def clear_modules():
"""Clear all praisonai and litellm related modules from cache."""
to_remove = [m for m in sys.modules.keys()
if 'praison' in m or 'litellm' in m]
for mod in to_remove:
del sys.modules[mod]
def measure_import_time(runs: int = 5) -> dict:
"""
Measure import time over multiple runs.
Returns:
dict with min, max, median, mean times in milliseconds
"""
times = []
for _ in range(runs):
clear_modules()
start = time.perf_counter()
import praisonaiagents # noqa: F401
end = time.perf_counter()
times.append((end - start) * 1000)
# Clean up for next run
if 'praisonaiagents' in sys.modules:
del sys.modules['praisonaiagents']
times.sort()
return {
'times': times,
'min': min(times),
'max': max(times),
'median': statistics.median(times),
'mean': statistics.mean(times),
'stdev': statistics.stdev(times) if len(times) > 1 else 0,
}
def check_lazy_imports() -> dict:
"""
Check that heavy dependencies are NOT loaded after importing praisonaiagents.
Returns:
dict with module names and whether they are loaded
"""
clear_modules()
import praisonaiagents # noqa: F401
heavy_modules = [
'litellm',
'chromadb',
'mem0',
'requests',
]
results = {}
for mod in heavy_modules:
results[mod] = mod in sys.modules
return results
def main():
"""Run the benchmark and print results."""
print("=" * 60)
print("PraisonAI Agents Import Time Benchmark")
print("=" * 60)
# Measure import time
print("\n[1/2] Measuring import time (5 runs)...")
results = measure_import_time(runs=5)
print(f"\nImport times (ms): {[round(t, 1) for t in results['times']]}")
print(f" Min: {results['min']:.1f} ms")
print(f" Max: {results['max']:.1f} ms")
print(f" Median: {results['median']:.1f} ms")
print(f" Mean: {results['mean']:.1f} ms")
print(f" StdDev: {results['stdev']:.1f} ms")
# Check lazy imports
print("\n[2/2] Checking lazy imports...")
lazy_results = check_lazy_imports()
print("\nHeavy modules loaded at import time:")
all_lazy = True
for mod, loaded in lazy_results.items():
status = "LOADED (BAD)" if loaded else "NOT LOADED (GOOD)"
print(f" {mod}: {status}")
if loaded:
all_lazy = False
# Determine pass/fail
print("\n" + "=" * 60)
print("RESULTS")
print("=" * 60)
median_time = results['median']
if median_time < 200:
time_status = "PASS"
time_msg = f"Import time {median_time:.0f}ms < 200ms target"
elif median_time < 300:
time_status = "WARN"
time_msg = f"Import time {median_time:.0f}ms between 200-300ms"
else:
time_status = "FAIL"
time_msg = f"Import time {median_time:.0f}ms > 300ms limit"
lazy_status = "PASS" if all_lazy else "FAIL"
lazy_msg = "All heavy deps lazy loaded" if all_lazy else "Some heavy deps loaded eagerly"
print(f"\nImport Time: [{time_status}] {time_msg}")
print(f"Lazy Imports: [{lazy_status}] {lazy_msg}")
overall = "PASS" if time_status != "FAIL" and lazy_status == "PASS" else "FAIL"
print(f"\nOverall: [{overall}]")
# Exit code for CI
return 0 if overall == "PASS" else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
Memory usage benchmark for praisonaiagents.
This benchmark measures the memory footprint of importing praisonaiagents
and verifies it stays within acceptable limits.
Usage:
python benchmarks/memory_usage.py
CI Gate:
- PASS: memory < 30MB
- WARN: memory 30-45MB
- FAIL: memory > 45MB
"""
import sys
import tracemalloc
def clear_modules():
"""Clear all praisonai and litellm related modules from cache."""
to_remove = [m for m in sys.modules.keys()
if 'praison' in m or 'litellm' in m]
for mod in to_remove:
del sys.modules[mod]
def measure_memory() -> dict:
"""
Measure memory usage after importing praisonaiagents.
Returns:
dict with current and peak memory in MB
"""
clear_modules()
tracemalloc.start()
import praisonaiagents # noqa: F401
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return {
'current_bytes': current,
'peak_bytes': peak,
'current_mb': current / 1024 / 1024,
'peak_mb': peak / 1024 / 1024,
}
def main():
"""Run the benchmark and print results."""
print("=" * 60)
print("PraisonAI Agents Memory Usage Benchmark")
print("=" * 60)
print("\nMeasuring memory usage...")
results = measure_memory()
print(f"\nMemory Usage:")
print(f" Current: {results['current_mb']:.1f} MB")
print(f" Peak: {results['peak_mb']:.1f} MB")
# Determine pass/fail based on peak memory
peak_mb = results['peak_mb']
print("\n" + "=" * 60)
print("RESULTS")
print("=" * 60)
if peak_mb < 30:
status = "PASS"
msg = f"Memory {peak_mb:.1f}MB < 30MB target"
elif peak_mb < 45:
status = "WARN"
msg = f"Memory {peak_mb:.1f}MB between 30-45MB"
else:
status = "FAIL"
msg = f"Memory {peak_mb:.1f}MB > 45MB limit"
print(f"\nMemory: [{status}] {msg}")
# Exit code for CI
return 0 if status != "FAIL" else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""
Example: Using PraisonAI Agents Lite Package
The lite package provides a minimal agent framework without heavy dependencies
like litellm. It's ideal for users who want to bring their own LLM client.
This example shows:
1. Creating a LiteAgent with a custom LLM function
2. Using the built-in OpenAI adapter
3. Thread-safe chat history management
"""
import os
def example_custom_llm():
"""Example 1: Using a custom LLM function."""
from praisonaiagents.lite import LiteAgent
# Define your own LLM function
def my_simple_llm(messages):
"""A mock LLM that echoes back the last message."""
last_msg = messages[-1]["content"] if messages else "No message"
return f"Echo: {last_msg}"
agent = LiteAgent(
name="EchoAgent",
llm_fn=my_simple_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 2: Using the built-in OpenAI adapter."""
from praisonaiagents.lite import LiteAgent, create_openai_llm_fn
# Check for API key
if not os.environ.get("OPENAI_API_KEY"):
print("Skipping OpenAI example - OPENAI_API_KEY not set")
return
# Create LLM function using OpenAI SDK directly (no litellm)
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"OpenAI Response: {response}")
def example_with_tools():
"""Example 3: Using tools with LiteAgent."""
from praisonaiagents.lite import LiteAgent, tool
@tool
def add_numbers(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
def mock_llm(messages):
return "The sum is 8"
agent = LiteAgent(
name="MathAgent",
llm_fn=mock_llm,
tools=[add_numbers]
)
# Execute tool directly
result = agent.execute_tool("add_numbers", a=5, b=3)
print(f"Tool result: {result.output}")
print(f"Tool success: {result.success}")
def example_anthropic_adapter():
"""Example 4: Using the built-in Anthropic adapter."""
from praisonaiagents.lite import LiteAgent, create_anthropic_llm_fn
# Check for API key
if not os.environ.get("ANTHROPIC_API_KEY"):
print("Skipping Anthropic example - ANTHROPIC_API_KEY not set")
return
# Create LLM function using Anthropic SDK directly
llm_fn = create_anthropic_llm_fn(model="claude-3-5-sonnet-20241022")
agent = LiteAgent(
name="ClaudeAgent",
llm_fn=llm_fn,
instructions="You are a helpful assistant. Keep responses brief."
)
response = agent.chat("What is the capital of France?")
print(f"Claude Response: {response}")
if __name__ == "__main__":
print("=" * 60)
print("PraisonAI Agents Lite Examples")
print("=" * 60)
print("\n[1] Custom LLM Function:")
example_custom_llm()
print("\n[2] OpenAI Adapter:")
example_openai_adapter()
print("\n[3] Tools with LiteAgent:")
example_with_tools()
print("\n[4] Anthropic Adapter:")
example_anthropic_adapter()
print("\n" + "=" * 60)
print("Examples completed!")
+322 -234
View File
@@ -1,5 +1,8 @@
"""
Praison AI Agents - A package for hierarchical AI agent task execution
This module uses lazy loading to minimize import time and memory usage.
Heavy dependencies like litellm are only loaded when actually needed.
"""
# Apply warning patch BEFORE any imports to intercept warnings at the source
@@ -11,51 +14,18 @@ from . import _logging
# Configure root logger after logging is initialized
_logging.configure_root_logger()
# Now import everything else
from .agent.agent import Agent
from .agent.image_agent import ImageAgent
from .agent.context_agent import ContextAgent, create_context_agent
from .agent.deep_research_agent import (
DeepResearchAgent,
DeepResearchResponse,
Citation,
ReasoningStep,
WebSearchCall,
CodeExecutionStep,
MCPCall,
FileSearchCall,
Provider
)
from .agent.query_rewriter_agent import (
QueryRewriterAgent,
RewriteStrategy,
RewriteResult
)
from .agent.prompt_expander_agent import (
PromptExpanderAgent,
ExpandStrategy,
ExpandResult
)
from .agents.agents import PraisonAIAgents
from .task.task import Task
# Import configuration (lightweight, no heavy deps)
from . import _config
# Lightweight imports that don't trigger heavy dependency chains
from .tools.tools import Tools
from .tools.base import BaseTool, ToolResult, ToolValidationError, validate_tool
from .tools.decorator import tool, FunctionTool
from .tools.registry import get_registry, register_tool, get_tool, ToolRegistry
from .agents.autoagents import AutoAgents
# Knowledge is lazy-loaded to avoid importing heavy deps (chromadb, mem0) at startup
# MCP support (optional)
try:
from .mcp.mcp import MCP
_mcp_available = True
except ImportError:
_mcp_available = False
MCP = None
from .session import Session
# Memory is lazy-loaded to avoid importing chromadb at startup
from .db import db
from .obs import obs
# Workflows - import from dedicated workflows module
# Workflows - lightweight module
from .workflows import (
Workflow, WorkflowStep, WorkflowContext, StepResult,
Route, Parallel, Loop, Repeat,
@@ -64,80 +34,18 @@ from .workflows import (
)
from .guardrails import GuardrailResult, LLMGuardrail
# Fast Context support (lazy loaded to avoid performance impact)
def __getattr__(name):
"""Lazy load heavy modules to avoid impacting package load time."""
# Knowledge module (imports chromadb, mem0)
if name == "Knowledge":
from praisonaiagents.knowledge.knowledge import Knowledge
return Knowledge
elif name == "Chunking":
from praisonaiagents.knowledge.chunking import Chunking
return Chunking
# FastContext support
elif name == "FastContext":
from praisonaiagents.context.fast import FastContext
return FastContext
elif name == "FastContextResult":
from praisonaiagents.context.fast import FastContextResult
return FastContextResult
elif name == "FileMatch":
from praisonaiagents.context.fast import FileMatch
return FileMatch
elif name == "LineRange":
from praisonaiagents.context.fast import LineRange
return LineRange
# Agent Skills support (lazy loaded for zero performance impact)
elif name == "SkillManager":
from praisonaiagents.skills import SkillManager
return SkillManager
elif name == "SkillProperties":
from praisonaiagents.skills import SkillProperties
return SkillProperties
elif name == "SkillMetadata":
from praisonaiagents.skills import SkillMetadata
return SkillMetadata
elif name == "SkillLoader":
from praisonaiagents.skills import SkillLoader
return SkillLoader
# Memory module (imports chromadb)
elif name == "Memory":
from praisonaiagents.memory.memory import Memory
return Memory
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# Planning mode support
try:
from .planning import (
Plan,
PlanStep,
TodoList,
TodoItem,
PlanStorage,
PlanningAgent,
ApprovalCallback,
READ_ONLY_TOOLS,
RESTRICTED_TOOLS,
)
_planning_available = True
except ImportError:
_planning_available = False
Plan = None
PlanStep = None
TodoList = None
TodoItem = None
PlanStorage = None
PlanningAgent = None
ApprovalCallback = None
READ_ONLY_TOOLS = []
RESTRICTED_TOOLS = []
# Handoff - lightweight
from .agent.handoff import Handoff, handoff, handoff_filters, RECOMMENDED_PROMPT_PREFIX, prompt_with_handoff_instructions
# Flow display
# Flow display (optional)
try:
from .flow_display import FlowDisplay, track_workflow
except ImportError:
FlowDisplay = None
track_workflow = None
# Main display utilities - these are used frequently so import directly
# but they don't pull in litellm
from .main import (
TaskOutput,
ReflectionOutput,
@@ -154,98 +62,296 @@ from .main import (
async_display_callbacks,
)
# Telemetry support (lazy loaded)
try:
from .telemetry import (
get_telemetry,
enable_telemetry,
disable_telemetry,
enable_performance_mode,
disable_performance_mode,
cleanup_telemetry_resources,
MinimalTelemetry,
TelemetryCollector
)
_telemetry_available = True
except ImportError:
# Telemetry not available - provide stub functions
_telemetry_available = False
def get_telemetry():
return None
def enable_telemetry(*args, **kwargs):
import logging
logging.warning(
"Telemetry not available. Install with: pip install praisonaiagents[telemetry]"
)
return None
def disable_telemetry():
pass
def enable_performance_mode():
pass
def disable_performance_mode():
pass
def cleanup_telemetry_resources():
pass
MinimalTelemetry = None
TelemetryCollector = None
# Module-level caches for lazy-loaded classes
_lazy_cache = {}
# Add Agents as an alias for PraisonAIAgents
Agents = PraisonAIAgents
# Flags for optional modules
_mcp_available = None
_planning_available = None
_telemetry_available = None
_agui_available = None
_a2a_available = None
# Enable PostHog telemetry by default with actual event posting
# PostHog events are posted by default unless explicitly disabled
# Users can:
# - Disable completely: PRAISONAI_DISABLE_TELEMETRY=true (or DO_NOT_TRACK=true)
# - Enable performance mode: PRAISONAI_PERFORMANCE_MODE=true (minimal overhead, limited events)
# - Enable full telemetry: PRAISONAI_FULL_TELEMETRY=true (detailed tracking)
# - Legacy opt-in mode: PRAISONAI_AUTO_INSTRUMENT=true
if _telemetry_available:
def __getattr__(name):
"""
Lazy load heavy modules to avoid impacting package load time.
This function is called when an attribute is not found in the module.
It enables deferred loading of heavy dependencies like litellm.
"""
global _mcp_available, _planning_available, _telemetry_available
global _agui_available, _a2a_available
# Return cached values if available
if name in _lazy_cache:
return _lazy_cache[name]
# Agent and related classes (triggers litellm import chain)
if name == "Agent":
from .agent.agent import Agent
_lazy_cache[name] = Agent
return Agent
elif name == "ImageAgent":
from .agent.image_agent import ImageAgent
_lazy_cache[name] = ImageAgent
return ImageAgent
elif name == "ContextAgent":
from .agent.context_agent import ContextAgent
_lazy_cache[name] = ContextAgent
return ContextAgent
elif name == "create_context_agent":
from .agent.context_agent import create_context_agent
_lazy_cache[name] = create_context_agent
return create_context_agent
elif name == "DeepResearchAgent":
from .agent.deep_research_agent import DeepResearchAgent
_lazy_cache[name] = DeepResearchAgent
return DeepResearchAgent
elif name == "DeepResearchResponse":
from .agent.deep_research_agent import DeepResearchResponse
_lazy_cache[name] = DeepResearchResponse
return DeepResearchResponse
elif name == "Citation":
from .agent.deep_research_agent import Citation
_lazy_cache[name] = Citation
return Citation
elif name == "ReasoningStep":
from .agent.deep_research_agent import ReasoningStep
_lazy_cache[name] = ReasoningStep
return ReasoningStep
elif name == "WebSearchCall":
from .agent.deep_research_agent import WebSearchCall
_lazy_cache[name] = WebSearchCall
return WebSearchCall
elif name == "CodeExecutionStep":
from .agent.deep_research_agent import CodeExecutionStep
_lazy_cache[name] = CodeExecutionStep
return CodeExecutionStep
elif name == "MCPCall":
from .agent.deep_research_agent import MCPCall
_lazy_cache[name] = MCPCall
return MCPCall
elif name == "FileSearchCall":
from .agent.deep_research_agent import FileSearchCall
_lazy_cache[name] = FileSearchCall
return FileSearchCall
elif name == "Provider":
from .agent.deep_research_agent import Provider
_lazy_cache[name] = Provider
return Provider
elif name == "QueryRewriterAgent":
from .agent.query_rewriter_agent import QueryRewriterAgent
_lazy_cache[name] = QueryRewriterAgent
return QueryRewriterAgent
elif name == "RewriteStrategy":
from .agent.query_rewriter_agent import RewriteStrategy
_lazy_cache[name] = RewriteStrategy
return RewriteStrategy
elif name == "RewriteResult":
from .agent.query_rewriter_agent import RewriteResult
_lazy_cache[name] = RewriteResult
return RewriteResult
elif name == "PromptExpanderAgent":
from .agent.prompt_expander_agent import PromptExpanderAgent
_lazy_cache[name] = PromptExpanderAgent
return PromptExpanderAgent
elif name == "ExpandStrategy":
from .agent.prompt_expander_agent import ExpandStrategy
_lazy_cache[name] = ExpandStrategy
return ExpandStrategy
elif name == "ExpandResult":
from .agent.prompt_expander_agent import ExpandResult
_lazy_cache[name] = ExpandResult
return ExpandResult
# PraisonAIAgents and Agents alias
elif name == "PraisonAIAgents":
from .agents.agents import PraisonAIAgents
_lazy_cache[name] = PraisonAIAgents
_lazy_cache["Agents"] = PraisonAIAgents # Also cache alias
return PraisonAIAgents
elif name == "Agents":
from .agents.agents import PraisonAIAgents
_lazy_cache["PraisonAIAgents"] = PraisonAIAgents
_lazy_cache[name] = PraisonAIAgents
return PraisonAIAgents
# Task
elif name == "Task":
from .task.task import Task
_lazy_cache[name] = Task
return Task
# AutoAgents
elif name == "AutoAgents":
from .agents.autoagents import AutoAgents
_lazy_cache[name] = AutoAgents
return AutoAgents
# Session (imports requests)
elif name == "Session":
from .session import Session
_lazy_cache[name] = Session
return Session
# MCP support (optional)
elif name == "MCP":
if _mcp_available is None:
try:
from .mcp.mcp import MCP as _MCP
_lazy_cache[name] = _MCP
return _MCP
except ImportError:
return None
return _lazy_cache.get(name)
# Knowledge module (imports chromadb, mem0)
elif name == "Knowledge":
from praisonaiagents.knowledge.knowledge import Knowledge
_lazy_cache[name] = Knowledge
return Knowledge
elif name == "Chunking":
from praisonaiagents.knowledge.chunking import Chunking
_lazy_cache[name] = Chunking
return Chunking
# FastContext support
elif name == "FastContext":
from praisonaiagents.context.fast import FastContext
_lazy_cache[name] = FastContext
return FastContext
elif name == "FastContextResult":
from praisonaiagents.context.fast import FastContextResult
_lazy_cache[name] = FastContextResult
return FastContextResult
elif name == "FileMatch":
from praisonaiagents.context.fast import FileMatch
_lazy_cache[name] = FileMatch
return FileMatch
elif name == "LineRange":
from praisonaiagents.context.fast import LineRange
_lazy_cache[name] = LineRange
return LineRange
# Agent Skills support (lazy loaded for zero performance impact)
elif name == "SkillManager":
from praisonaiagents.skills import SkillManager
_lazy_cache[name] = SkillManager
return SkillManager
elif name == "SkillProperties":
from praisonaiagents.skills import SkillProperties
_lazy_cache[name] = SkillProperties
return SkillProperties
elif name == "SkillMetadata":
from praisonaiagents.skills import SkillMetadata
_lazy_cache[name] = SkillMetadata
return SkillMetadata
elif name == "SkillLoader":
from praisonaiagents.skills import SkillLoader
_lazy_cache[name] = SkillLoader
return SkillLoader
# Memory module (imports chromadb)
elif name == "Memory":
from praisonaiagents.memory.memory import Memory
_lazy_cache[name] = Memory
return Memory
# Planning mode support (lazy)
elif name in ("Plan", "PlanStep", "TodoList", "TodoItem", "PlanStorage",
"PlanningAgent", "ApprovalCallback", "READ_ONLY_TOOLS",
"RESTRICTED_TOOLS"):
try:
from . import planning as _planning
result = getattr(_planning, name)
_lazy_cache[name] = result
return result
except ImportError:
if name in ("READ_ONLY_TOOLS", "RESTRICTED_TOOLS"):
return []
return None
# Telemetry support (lazy loaded)
elif name in ("get_telemetry", "enable_telemetry", "disable_telemetry",
"enable_performance_mode", "disable_performance_mode",
"cleanup_telemetry_resources", "MinimalTelemetry", "TelemetryCollector"):
try:
from . import telemetry as _telemetry
result = getattr(_telemetry, name)
_lazy_cache[name] = result
return result
except ImportError:
# Provide stub functions
if name == "get_telemetry":
return lambda: None
elif name == "enable_telemetry":
import logging
def _stub(*args, **kwargs):
logging.warning(
"Telemetry not available. Install with: pip install praisonaiagents[telemetry]"
)
return None
return _stub
elif name in ("disable_telemetry", "enable_performance_mode",
"disable_performance_mode", "cleanup_telemetry_resources"):
return lambda: None
return None
# AG-UI support (optional, lazy loaded)
elif name == "AGUI":
try:
from praisonaiagents.ui.agui import AGUI
_lazy_cache[name] = AGUI
return AGUI
except ImportError:
return None
# A2A support (optional, lazy loaded)
elif name == "A2A":
try:
from praisonaiagents.ui.a2a import A2A
_lazy_cache[name] = A2A
return A2A
except ImportError:
return None
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# Initialize telemetry only if explicitly enabled via config
def _init_telemetry():
"""Initialize telemetry if enabled via environment variable."""
if not _config.TELEMETRY_ENABLED:
return
try:
import os
from .telemetry import get_telemetry
from .telemetry.integration import auto_instrument_all
# Check for explicit disable (respects DO_NOT_TRACK and other disable flags)
telemetry_disabled = any([
os.environ.get('PRAISONAI_TELEMETRY_DISABLED', '').lower() in ('true', '1', 'yes'),
os.environ.get('PRAISONAI_DISABLE_TELEMETRY', '').lower() in ('true', '1', 'yes'),
os.environ.get('DO_NOT_TRACK', '').lower() in ('true', '1', 'yes'),
])
# Check for performance mode (minimal overhead with limited events)
performance_mode = os.environ.get('PRAISONAI_PERFORMANCE_MODE', '').lower() in ('true', '1', 'yes')
# Check for full telemetry mode (more detailed tracking)
full_telemetry = os.environ.get('PRAISONAI_FULL_TELEMETRY', '').lower() in ('true', '1', 'yes')
# Legacy explicit auto-instrument option
explicit_auto_instrument = os.environ.get('PRAISONAI_AUTO_INSTRUMENT', '').lower() in ('true', '1', 'yes')
# Enable PostHog by default unless explicitly disabled
if not telemetry_disabled:
_telemetry = get_telemetry()
if _telemetry and _telemetry.enabled:
from .telemetry.integration import auto_instrument_all
# Default: PostHog telemetry is enabled and events are posted
# Performance mode can be explicitly enabled for minimal overhead
use_performance_mode = performance_mode and not (full_telemetry or explicit_auto_instrument)
auto_instrument_all(_telemetry, performance_mode=use_performance_mode)
# Track package import for basic usage analytics
try:
_telemetry.track_feature_usage("package_import")
except Exception:
pass
_telemetry = get_telemetry()
if _telemetry and _telemetry.enabled:
use_performance_mode = _config.PERFORMANCE_MODE and not (
_config.FULL_TELEMETRY or _config.AUTO_INSTRUMENT
)
auto_instrument_all(_telemetry, performance_mode=use_performance_mode)
# Track package import for basic usage analytics
try:
_telemetry.track_feature_usage("package_import")
except Exception:
pass
except Exception:
# Silently fail if there are any issues - never break user applications
pass
# Only initialize telemetry if explicitly enabled
_init_telemetry()
__all__ = [
# Core classes (lazy loaded)
'Agent',
'ImageAgent',
'ContextAgent',
@@ -314,11 +420,8 @@ __all__ = [
'RewriteResult',
'PromptExpanderAgent',
'ExpandStrategy',
'ExpandResult'
]
# Add workflow exports
__all__.extend([
'ExpandResult',
# Workflows
'Workflow',
'WorkflowStep',
'WorkflowContext',
@@ -331,48 +434,33 @@ __all__.extend([
'parallel',
'loop',
'repeat',
'Pipeline'
])
# Add MCP to __all__ if available
if _mcp_available:
__all__.append('MCP')
# Add flow display if available
if FlowDisplay is not None:
__all__.extend(['FlowDisplay', 'track_workflow'])
# Add FastContext exports (lazy loaded)
__all__.extend([
'Pipeline',
# MCP
'MCP',
# Flow display
'FlowDisplay',
'track_workflow',
# FastContext
'FastContext',
'FastContextResult',
'FileMatch',
'LineRange'
])
# Add Agent Skills exports (lazy loaded)
__all__.extend([
'LineRange',
# Agent Skills
'SkillManager',
'SkillProperties',
'SkillMetadata',
'SkillLoader'
])
# AG-UI support (optional, lazy loaded)
try:
from praisonaiagents.ui.agui import AGUI
__all__.append('AGUI')
_agui_available = True
except ImportError:
_agui_available = False
AGUI = None
# A2A support (optional, lazy loaded)
try:
from praisonaiagents.ui.a2a import A2A
__all__.append('A2A')
_a2a_available = True
except ImportError:
_a2a_available = False
A2A = None
'SkillLoader',
# Planning
'Plan',
'PlanStep',
'TodoList',
'TodoItem',
'PlanStorage',
'PlanningAgent',
'ApprovalCallback',
'READ_ONLY_TOOLS',
'RESTRICTED_TOOLS',
# UI
'AGUI',
'A2A',
]
Whitespace-only changes.
@@ -0,0 +1,106 @@
"""
Configuration module for PraisonAI Agents.
Provides feature flags and configuration options that can be controlled
via environment variables for performance tuning and behavior control.
Environment Variables:
PRAISONAI_LAZY_IMPORTS: Enable lazy imports (default: true)
PRAISONAI_TELEMETRY_ENABLED: Enable telemetry (default: false, opt-in)
PRAISONAI_TELEMETRY_DISABLED: Disable telemetry (takes precedence)
DO_NOT_TRACK: Standard opt-out flag (takes precedence)
"""
import os
from typing import Optional
def _str_to_bool(value: Optional[str], default: bool = False) -> bool:
"""Convert string environment variable to boolean."""
if value is None:
return default
return value.lower() in ('true', '1', 'yes', 'on')
# Feature Flags
# -------------
# Lazy imports: When True (default), heavy dependencies like litellm are
# only imported when actually needed, reducing import time significantly.
LAZY_IMPORTS = _str_to_bool(
os.environ.get('PRAISONAI_LAZY_IMPORTS'),
default=True
)
# Telemetry: Opt-in by default (False). Users must explicitly enable.
# Precedence (highest to lowest):
# 1. DO_NOT_TRACK=true -> disabled
# 2. PRAISONAI_TELEMETRY_DISABLED=true -> disabled
# 3. PRAISONAI_DISABLE_TELEMETRY=true -> disabled
# 4. PRAISONAI_TELEMETRY_ENABLED=true -> enabled
# 5. Default: disabled (opt-in model)
def _get_telemetry_enabled() -> bool:
"""Determine if telemetry should be enabled based on env vars."""
# Check disable flags first (highest precedence)
if _str_to_bool(os.environ.get('DO_NOT_TRACK')):
return False
if _str_to_bool(os.environ.get('PRAISONAI_TELEMETRY_DISABLED')):
return False
if _str_to_bool(os.environ.get('PRAISONAI_DISABLE_TELEMETRY')):
return False
# Check explicit enable flag
if _str_to_bool(os.environ.get('PRAISONAI_TELEMETRY_ENABLED')):
return True
# Default: disabled (opt-in model)
return False
TELEMETRY_ENABLED = _get_telemetry_enabled()
# Performance mode: Minimal telemetry overhead when enabled
PERFORMANCE_MODE = _str_to_bool(
os.environ.get('PRAISONAI_PERFORMANCE_MODE'),
default=False
)
# Full telemetry mode: Detailed tracking when enabled
FULL_TELEMETRY = _str_to_bool(
os.environ.get('PRAISONAI_FULL_TELEMETRY'),
default=False
)
# Legacy auto-instrument flag
AUTO_INSTRUMENT = _str_to_bool(
os.environ.get('PRAISONAI_AUTO_INSTRUMENT'),
default=False
)
def missing_dependency_error(
package_name: str,
extra_name: Optional[str] = None,
feature_name: Optional[str] = None
) -> ImportError:
"""
Create a standardized ImportError for missing optional dependencies.
Args:
package_name: The name of the missing package (e.g., 'litellm')
extra_name: The pip extra to install (e.g., 'llm' for [llm])
feature_name: Human-readable feature name (e.g., 'LLM support')
Returns:
ImportError with helpful install instructions
"""
if extra_name:
install_cmd = f"pip install 'praisonaiagents[{extra_name}]'"
else:
install_cmd = f"pip install {package_name}"
feature_desc = f" for {feature_name}" if feature_name else ""
return ImportError(
f"{package_name} is required{feature_desc} but not installed. "
f"Please install with: {install_cmd}"
)
@@ -84,8 +84,18 @@ def _configure_loggers():
# ========================================================================
# LITELLM CONFIGURATION
# ========================================================================
def _configure_litellm():
"""Configure litellm after it's imported."""
_litellm_configured = False
def configure_litellm():
"""Configure litellm after it's imported.
This function should be called lazily when litellm is actually used,
not at module import time, to avoid importing litellm unnecessarily.
"""
global _litellm_configured
if _litellm_configured:
return
try:
import litellm
litellm.telemetry = False
@@ -97,11 +107,17 @@ def _configure_litellm():
if hasattr(litellm, 'set_verbose'):
litellm.set_verbose = False
_litellm_configured = True
except (ImportError, AttributeError):
pass
# Alias for backward compatibility
_configure_litellm = configure_litellm
# ========================================================================
# ROOT LOGGER CONFIGURATION
# ========================================================================
@@ -124,11 +140,16 @@ def configure_root_logger():
# INITIALIZATION
# ========================================================================
def initialize_logging():
"""Initialize all logging configuration."""
"""Initialize all logging configuration.
Note: litellm configuration is NOT done here to avoid importing litellm
at package import time. Call configure_litellm() when litellm is needed.
"""
_configure_environment()
_configure_loggers()
_configure_litellm()
# NOTE: _configure_litellm() is NOT called here to avoid importing litellm
# It will be called lazily when LLM class is instantiated
# Auto-initialize on import
# Auto-initialize on import (but NOT litellm)
initialize_logging()
@@ -4,6 +4,7 @@ import json
import copy
import logging
import asyncio
import threading
from typing import List, Optional, Any, Dict, Union, Literal, TYPE_CHECKING, Callable, Tuple, Generator
from rich.console import Console
from rich.live import Live
@@ -580,9 +581,9 @@ class Agent:
self.embedder_config = embedder_config
self.knowledge = knowledge
self.use_system_prompt = use_system_prompt
# NOTE: chat_history is not thread-safe. If concurrent access is needed,
# consider using threading.Lock or other synchronization mechanisms
# Thread-safe chat_history with lock for concurrent access
self.chat_history = []
self._history_lock = threading.Lock()
self.markdown = markdown
self.stream = stream
self.metrics = metrics
@@ -643,11 +644,11 @@ Your Goal: {self.goal}
self._guardrail_fn = None
self._setup_guardrail()
# Cache for system prompts and formatted tools
# Note: In single-threaded usage (common case), these are safe
# For multi-threaded usage, consider using threading.Lock
# Cache for system prompts and formatted tools with thread-safe lock
# RLock allows re-entrant access from the same thread
self._system_prompt_cache = {}
self._formatted_tools_cache = {}
self._cache_lock = threading.RLock()
# Limit cache size to prevent unbounded growth
self._max_cache_size = 100
@@ -5,13 +5,26 @@ This module uses only stdlib (typing, dataclasses) to define the interface.
No external dependencies are imported here.
Implementations are provided by the wrapper layer (praisonai.db).
Schema Versioning:
- SCHEMA_VERSION tracks the protocol version
- Adapters should implement get_schema_version() to report their version
- Adapters should implement migrate_schema() for upgrades
- Version format: MAJOR.MINOR (e.g., "1.0", "1.1", "2.0")
- MAJOR changes are breaking, MINOR changes are backward compatible
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable, Tuple
import time
# Current schema version for the DbAdapter protocol
# Increment MINOR for backward-compatible additions
# Increment MAJOR for breaking changes
SCHEMA_VERSION = "1.0"
@dataclass
class DbToolCall:
"""A tool call to be persisted."""
@@ -365,6 +378,48 @@ class DbAdapter(Protocol):
def close(self) -> None:
"""Close the database connection."""
...
# --- Schema Versioning (Optional) ---
# These methods are optional for backward compatibility.
# Adapters that don't implement them will work but won't support migrations.
def get_schema_version(self) -> str:
"""
Get the current schema version of this adapter.
Returns:
Schema version string (e.g., "1.0")
Default implementation returns "1.0"
"""
...
def migrate_schema(
self,
from_version: str,
to_version: str,
) -> bool:
"""
Migrate the schema from one version to another.
Args:
from_version: Current schema version
to_version: Target schema version
Returns:
True if migration succeeded, False otherwise
"""
...
def check_schema_compatibility(self) -> Tuple[bool, str]:
"""
Check if the adapter's schema is compatible with the protocol.
Returns:
Tuple of (is_compatible, message)
- is_compatible: True if schema is compatible
- message: Description of compatibility status or required action
"""
...
@runtime_checkable
@@ -0,0 +1,327 @@
"""
PraisonAI Agents Lite - Minimal agent framework without heavy dependencies.
This subpackage provides a lightweight version of PraisonAI Agents that:
- Does NOT import litellm, chromadb, or other heavy dependencies at import time
- Provides core Agent/Task/Tools abstractions
- Allows users to bring their own LLM client (BYO-LLM)
- Has minimal import time (<50ms) and memory footprint (<10MB)
Usage:
from praisonaiagents.lite import LiteAgent, LiteTask, Tools
# Create a lite agent with your own LLM function
def my_llm(messages):
# Your LLM implementation here
return "response"
agent = LiteAgent(
name="Assistant",
llm_fn=my_llm,
tools=[my_tool]
)
response = agent.chat("Hello!")
For full functionality with litellm integration, use the main package:
from praisonaiagents import Agent
"""
from typing import Any, Callable, Dict, List, Optional
from dataclasses import dataclass, field
import threading
@dataclass
class LiteToolResult:
"""Result from a tool execution."""
output: Any
success: bool = True
error: Optional[str] = None
@dataclass
class LiteTask:
"""
Lightweight task definition without heavy dependencies.
Attributes:
description: What the task should accomplish
expected_output: Description of expected output format
agent: Optional agent assigned to this task
context: Additional context for the task
result: Result after task execution
"""
description: str
expected_output: str = ""
agent: Optional['LiteAgent'] = None
context: Dict[str, Any] = field(default_factory=dict)
result: Optional[str] = None
def execute(self, input_text: Optional[str] = None) -> str:
"""Execute the task using the assigned agent."""
if self.agent is None:
raise ValueError("No agent assigned to this task")
prompt = input_text or self.description
if self.expected_output:
prompt += f"\n\nExpected output format: {self.expected_output}"
self.result = self.agent.chat(prompt)
return self.result
class LiteAgent:
"""
Lightweight agent that works with any LLM function.
This agent does NOT import litellm or other heavy dependencies.
Users provide their own LLM function for chat completions.
Args:
name: Agent name
llm_fn: Function that takes messages list and returns response string.
Signature: (messages: List[Dict]) -> str
instructions: System instructions for the agent
tools: List of tool functions the agent can use
verbose: Enable verbose output
Example:
def my_openai_llm(messages):
import openai
client = openai.OpenAI()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages
)
return response.choices[0].message.content
agent = LiteAgent(
name="Assistant",
llm_fn=my_openai_llm,
instructions="You are a helpful assistant."
)
response = agent.chat("Hello!")
"""
def __init__(
self,
name: str = "LiteAgent",
llm_fn: Optional[Callable[[List[Dict]], str]] = None,
instructions: str = "You are a helpful assistant.",
tools: Optional[List[Callable]] = None,
verbose: bool = False,
):
self.name = name
self.llm_fn = llm_fn
self.instructions = instructions
self.tools = tools or []
self.verbose = verbose
# Thread-safe chat history
self.chat_history: List[Dict[str, str]] = []
self._history_lock = threading.Lock()
# Tool registry
self._tool_map: Dict[str, Callable] = {}
for tool_fn in self.tools:
tool_name = getattr(tool_fn, '__name__', str(tool_fn))
self._tool_map[tool_name] = tool_fn
def _build_messages(self, user_message: str) -> List[Dict[str, str]]:
"""Build messages list for LLM call."""
messages = [{"role": "system", "content": self.instructions}]
with self._history_lock:
messages.extend(self.chat_history.copy())
messages.append({"role": "user", "content": user_message})
return messages
def _add_to_history(self, role: str, content: str):
"""Thread-safe addition to chat history."""
with self._history_lock:
self.chat_history.append({"role": role, "content": content})
def chat(self, message: str) -> str:
"""
Send a message to the agent and get a response.
Args:
message: User message
Returns:
Agent's response string
"""
if self.llm_fn is None:
raise ValueError(
"No LLM function provided. Please provide an llm_fn when creating the agent.\n"
"Example:\n"
" def my_llm(messages):\n"
" # Your LLM implementation\n"
" return 'response'\n"
" agent = LiteAgent(llm_fn=my_llm)"
)
messages = self._build_messages(message)
if self.verbose:
print(f"[{self.name}] Processing: {message[:50]}...")
response = self.llm_fn(messages)
# Add to history
self._add_to_history("user", message)
self._add_to_history("assistant", response)
if self.verbose:
print(f"[{self.name}] Response: {response[:100]}...")
return response
def clear_history(self):
"""Clear chat history."""
with self._history_lock:
self.chat_history.clear()
def execute_tool(self, tool_name: str, **kwargs) -> LiteToolResult:
"""
Execute a tool by name.
Args:
tool_name: Name of the tool to execute
**kwargs: Arguments to pass to the tool
Returns:
LiteToolResult with output or error
"""
if tool_name not in self._tool_map:
return LiteToolResult(
output=None,
success=False,
error=f"Tool '{tool_name}' not found"
)
try:
tool_fn = self._tool_map[tool_name]
result = tool_fn(**kwargs)
return LiteToolResult(output=result, success=True)
except Exception as e:
return LiteToolResult(
output=None,
success=False,
error=str(e)
)
def create_openai_llm_fn(
model: str = "gpt-4o-mini",
api_key: Optional[str] = None,
**kwargs
) -> Callable[[List[Dict]], str]:
"""
Create an LLM function using the OpenAI SDK.
This is a convenience function for users who want to use OpenAI
without the full litellm dependency.
Args:
model: OpenAI model name
api_key: OpenAI API key (or use OPENAI_API_KEY env var)
**kwargs: Additional arguments to pass to OpenAI
Returns:
LLM function compatible with LiteAgent
Example:
llm_fn = create_openai_llm_fn(model="gpt-4o-mini")
agent = LiteAgent(llm_fn=llm_fn)
"""
def llm_fn(messages: List[Dict]) -> str:
try:
import openai
except ImportError:
raise ImportError(
"openai package is required for create_openai_llm_fn. "
"Install with: pip install openai"
)
client = openai.OpenAI(api_key=api_key)
response = client.chat.completions.create(
model=model,
messages=messages,
**kwargs
)
return response.choices[0].message.content or ""
return llm_fn
def create_anthropic_llm_fn(
model: str = "claude-3-5-sonnet-20241022",
api_key: Optional[str] = None,
max_tokens: int = 4096,
**kwargs
) -> Callable[[List[Dict]], str]:
"""
Create an LLM function using the Anthropic SDK.
Args:
model: Anthropic model name
api_key: Anthropic API key (or use ANTHROPIC_API_KEY env var)
max_tokens: Maximum tokens in response
**kwargs: Additional arguments to pass to Anthropic
Returns:
LLM function compatible with LiteAgent
"""
def llm_fn(messages: List[Dict]) -> str:
try:
import anthropic
except ImportError:
raise ImportError(
"anthropic package is required for create_anthropic_llm_fn. "
"Install with: pip install anthropic"
)
client = anthropic.Anthropic(api_key=api_key)
# Extract system message
system = ""
chat_messages = []
for msg in messages:
if msg["role"] == "system":
system = msg["content"]
else:
chat_messages.append(msg)
response = client.messages.create(
model=model,
max_tokens=max_tokens,
system=system,
messages=chat_messages,
**kwargs
)
return response.content[0].text
return llm_fn
# Re-export Tools from main package (lightweight, no heavy deps)
from ..tools.tools import Tools
from ..tools.base import BaseTool, ToolResult
from ..tools.decorator import tool
__all__ = [
"LiteAgent",
"LiteTask",
"LiteToolResult",
"Tools",
"BaseTool",
"ToolResult",
"tool",
"create_openai_llm_fn",
"create_anthropic_llm_fn",
]
@@ -1,33 +1,102 @@
"""
LLM Module for PraisonAI Agents.
This module provides language model integrations with lazy loading
to minimize import time when LLM functionality is not immediately needed.
"""
import os
# Ensure litellm telemetry is disabled before imports
# Ensure litellm telemetry is disabled before any imports
os.environ["LITELLM_TELEMETRY"] = "False"
# Import modules
from .llm import LLM, LLMContextLengthExceededException
from .openai_client import (
OpenAIClient,
get_openai_client,
ChatCompletionMessage,
Choice,
CompletionTokensDetails,
PromptTokensDetails,
CompletionUsage,
ChatCompletion,
ToolCall,
process_stream_chunks
)
from .model_capabilities import (
supports_structured_outputs,
supports_streaming_with_tools
)
from .model_router import (
ModelRouter,
ModelProfile,
TaskComplexity,
create_routing_agent
)
from .rate_limiter import RateLimiter
# Module-level cache for lazy-loaded classes
_lazy_cache = {}
def __getattr__(name):
"""Lazy load LLM classes to avoid importing litellm at module load time."""
if name in _lazy_cache:
return _lazy_cache[name]
if name == "LLM":
from .llm import LLM
_lazy_cache[name] = LLM
return LLM
elif name == "LLMContextLengthExceededException":
from .llm import LLMContextLengthExceededException
_lazy_cache[name] = LLMContextLengthExceededException
return LLMContextLengthExceededException
elif name == "OpenAIClient":
from .openai_client import OpenAIClient
_lazy_cache[name] = OpenAIClient
return OpenAIClient
elif name == "get_openai_client":
from .openai_client import get_openai_client
_lazy_cache[name] = get_openai_client
return get_openai_client
elif name == "ChatCompletionMessage":
from .openai_client import ChatCompletionMessage
_lazy_cache[name] = ChatCompletionMessage
return ChatCompletionMessage
elif name == "Choice":
from .openai_client import Choice
_lazy_cache[name] = Choice
return Choice
elif name == "CompletionTokensDetails":
from .openai_client import CompletionTokensDetails
_lazy_cache[name] = CompletionTokensDetails
return CompletionTokensDetails
elif name == "PromptTokensDetails":
from .openai_client import PromptTokensDetails
_lazy_cache[name] = PromptTokensDetails
return PromptTokensDetails
elif name == "CompletionUsage":
from .openai_client import CompletionUsage
_lazy_cache[name] = CompletionUsage
return CompletionUsage
elif name == "ChatCompletion":
from .openai_client import ChatCompletion
_lazy_cache[name] = ChatCompletion
return ChatCompletion
elif name == "ToolCall":
from .openai_client import ToolCall
_lazy_cache[name] = ToolCall
return ToolCall
elif name == "process_stream_chunks":
from .openai_client import process_stream_chunks
_lazy_cache[name] = process_stream_chunks
return process_stream_chunks
elif name == "supports_structured_outputs":
from .model_capabilities import supports_structured_outputs
_lazy_cache[name] = supports_structured_outputs
return supports_structured_outputs
elif name == "supports_streaming_with_tools":
from .model_capabilities import supports_streaming_with_tools
_lazy_cache[name] = supports_streaming_with_tools
return supports_streaming_with_tools
elif name == "ModelRouter":
from .model_router import ModelRouter
_lazy_cache[name] = ModelRouter
return ModelRouter
elif name == "ModelProfile":
from .model_router import ModelProfile
_lazy_cache[name] = ModelProfile
return ModelProfile
elif name == "TaskComplexity":
from .model_router import TaskComplexity
_lazy_cache[name] = TaskComplexity
return TaskComplexity
elif name == "create_routing_agent":
from .model_router import create_routing_agent
_lazy_cache[name] = create_routing_agent
return create_routing_agent
elif name == "RateLimiter":
from .rate_limiter import RateLimiter
_lazy_cache[name] = RateLimiter
return RateLimiter
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"LLM",
@@ -49,4 +118,4 @@ __all__ = [
"TaskComplexity",
"create_routing_agent",
"RateLimiter"
]
]
Whitespace-only changes.
@@ -128,6 +128,10 @@ Respond with ONLY a valid JSON tool call in this format:
@classmethod
def _configure_logging(cls):
"""Configure logging settings once for all LLM instances."""
# First, configure litellm via the centralized logging module
from .._logging import configure_litellm
configure_litellm()
try:
import litellm
# Disable telemetry
@@ -580,7 +580,60 @@ class MCP:
return openai_tools
def __enter__(self):
"""Context manager entry - return self for use in 'with' statements."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit - clean up resources."""
self.shutdown()
return False # Don't suppress exceptions
def shutdown(self):
"""Explicitly shut down MCP resources.
Call this method when done using the MCP instance to ensure
all background threads and connections are properly cleaned up.
"""
# Shutdown stdio runner if present
if hasattr(self, 'runner') and self.runner is not None:
try:
self.runner.shutdown()
except Exception:
pass # Best effort cleanup
# Shutdown SSE client if present
if hasattr(self, 'sse_client') and self.sse_client is not None:
try:
if hasattr(self.sse_client, 'shutdown'):
self.sse_client.shutdown()
except Exception:
pass
# Shutdown HTTP stream client if present
if hasattr(self, 'http_stream_client') and self.http_stream_client is not None:
try:
if hasattr(self.http_stream_client, 'shutdown'):
self.http_stream_client.shutdown()
except Exception:
pass
# Shutdown WebSocket client if present
if hasattr(self, 'websocket_client') and self.websocket_client is not None:
try:
if hasattr(self.websocket_client, 'shutdown'):
self.websocket_client.shutdown()
except Exception:
pass
def __del__(self):
"""Clean up resources when the object is garbage collected."""
if hasattr(self, 'runner'):
self.runner.shutdown()
"""Clean up resources when the object is garbage collected.
Note: __del__ is called during garbage collection and may not
always run. For reliable cleanup, use the context manager
pattern or call shutdown() explicitly.
"""
try:
self.shutdown()
except Exception:
pass # Best effort cleanup in __del__
@@ -27,13 +27,12 @@ Usage:
)
result = agents.start()
This module uses lazy loading to minimize import time.
"""
from .plan import Plan, PlanStep
from .todo import TodoList, TodoItem
from .storage import PlanStorage
from .planner import PlanningAgent
from .approval import ApprovalCallback
# Module-level cache for lazy-loaded classes
_lazy_cache = {}
# Read-only tools that are allowed in plan mode
READ_ONLY_TOOLS = [
@@ -90,6 +89,44 @@ RESEARCH_TOOLS = [
"find_files",
]
def __getattr__(name):
"""Lazy load planning classes to avoid importing heavy dependencies at module load time."""
if name in _lazy_cache:
return _lazy_cache[name]
if name == "Plan":
from .plan import Plan
_lazy_cache[name] = Plan
return Plan
elif name == "PlanStep":
from .plan import PlanStep
_lazy_cache[name] = PlanStep
return PlanStep
elif name == "TodoList":
from .todo import TodoList
_lazy_cache[name] = TodoList
return TodoList
elif name == "TodoItem":
from .todo import TodoItem
_lazy_cache[name] = TodoItem
return TodoItem
elif name == "PlanStorage":
from .storage import PlanStorage
_lazy_cache[name] = PlanStorage
return PlanStorage
elif name == "PlanningAgent":
from .planner import PlanningAgent
_lazy_cache[name] = PlanningAgent
return PlanningAgent
elif name == "ApprovalCallback":
from .approval import ApprovalCallback
_lazy_cache[name] = ApprovalCallback
return ApprovalCallback
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"Plan",
"PlanStep",
Whitespace-only changes.
@@ -4,6 +4,7 @@ This module provides centralized tool management with support for:
- Manual registration
- Auto-discovery via entry_points
- Tool lookup by name
- Thread-safe operations for multi-agent scenarios
Usage:
from praisonaiagents.tools.registry import get_registry
@@ -14,6 +15,7 @@ Usage:
"""
import logging
import threading
from typing import Callable, Dict, List, Optional, Union
from importlib.metadata import entry_points
@@ -38,6 +40,7 @@ class ToolRegistry:
self._tools: Dict[str, BaseTool] = {}
self._functions: Dict[str, Callable] = {} # For backward compat with plain functions
self._discovered: bool = False
self._lock = threading.RLock() # Thread-safe operations for multi-agent scenarios
def register(
self,
@@ -47,6 +50,8 @@ class ToolRegistry:
) -> None:
"""Register a tool with the registry.
Thread-safe: Uses lock for concurrent access in multi-agent scenarios.
Args:
tool: BaseTool instance or callable function
name: Override name (default: tool.name or function.__name__)
@@ -55,83 +60,93 @@ class ToolRegistry:
Raises:
ValueError: If tool with same name exists and overwrite=False
"""
# Handle BaseTool instances
if isinstance(tool, BaseTool):
tool_name = name or tool.name
if tool_name in self._tools and not overwrite:
logging.debug(f"Tool '{tool_name}' already registered, skipping")
with self._lock:
# Handle BaseTool instances
if isinstance(tool, BaseTool):
tool_name = name or tool.name
if tool_name in self._tools and not overwrite:
logging.debug(f"Tool '{tool_name}' already registered, skipping")
return
self._tools[tool_name] = tool
logging.debug(f"Registered tool: {tool_name}")
return
self._tools[tool_name] = tool
logging.debug(f"Registered tool: {tool_name}")
return
# Handle plain callables
if callable(tool):
tool_name = name or getattr(tool, '__name__', str(id(tool)))
if tool_name in self._functions and not overwrite:
logging.debug(f"Function '{tool_name}' already registered, skipping")
# Handle plain callables
if callable(tool):
tool_name = name or getattr(tool, '__name__', str(id(tool)))
if tool_name in self._functions and not overwrite:
logging.debug(f"Function '{tool_name}' already registered, skipping")
return
self._functions[tool_name] = tool
logging.debug(f"Registered function: {tool_name}")
return
self._functions[tool_name] = tool
logging.debug(f"Registered function: {tool_name}")
return
raise TypeError(f"Cannot register {type(tool)}, expected BaseTool or callable")
raise TypeError(f"Cannot register {type(tool)}, expected BaseTool or callable")
def unregister(self, name: str) -> bool:
"""Remove a tool from the registry.
Thread-safe: Uses lock for concurrent access.
Args:
name: Tool name to remove
Returns:
True if tool was removed, False if not found
"""
if name in self._tools:
del self._tools[name]
return True
if name in self._functions:
del self._functions[name]
return True
return False
with self._lock:
if name in self._tools:
del self._tools[name]
return True
if name in self._functions:
del self._functions[name]
return True
return False
def get(self, name: str) -> Optional[Union[BaseTool, Callable]]:
"""Get a tool by name.
Thread-safe: Uses lock for concurrent access.
Args:
name: Tool name
Returns:
BaseTool instance, callable, or None if not found
"""
# Check BaseTool registry first
if name in self._tools:
return self._tools[name]
# Check functions registry
if name in self._functions:
return self._functions[name]
# Try auto-discovery if not found
if not self._discovered:
self.discover_plugins()
with self._lock:
# Check BaseTool registry first
if name in self._tools:
return self._tools[name]
return None
# Check functions registry
if name in self._functions:
return self._functions[name]
# Try auto-discovery if not found
if not self._discovered:
self.discover_plugins()
if name in self._tools:
return self._tools[name]
return None
def list_tools(self) -> List[str]:
"""List all registered tool names."""
return list(self._tools.keys()) + list(self._functions.keys())
"""List all registered tool names. Thread-safe."""
with self._lock:
return list(self._tools.keys()) + list(self._functions.keys())
def list_base_tools(self) -> List[BaseTool]:
"""List all registered BaseTool instances."""
return list(self._tools.values())
"""List all registered BaseTool instances. Thread-safe."""
with self._lock:
return list(self._tools.values())
def get_all(self) -> Dict[str, Union[BaseTool, Callable]]:
"""Get all registered tools as a dict."""
result = dict(self._tools)
result.update(self._functions)
return result
"""Get all registered tools as a dict. Thread-safe."""
with self._lock:
result = dict(self._tools)
result.update(self._functions)
return result
def discover_plugins(self) -> int:
"""Discover and register tools from entry_points.
@@ -185,16 +200,19 @@ class ToolRegistry:
return count
def clear(self) -> None:
"""Clear all registered tools."""
self._tools.clear()
self._functions.clear()
self._discovered = False
"""Clear all registered tools. Thread-safe."""
with self._lock:
self._tools.clear()
self._functions.clear()
self._discovered = False
def __contains__(self, name: str) -> bool:
return name in self._tools or name in self._functions
with self._lock:
return name in self._tools or name in self._functions
def __len__(self) -> int:
return len(self._tools) + len(self._functions)
with self._lock:
return len(self._tools) + len(self._functions)
def __repr__(self) -> str:
return f"ToolRegistry(tools={len(self._tools)}, functions={len(self._functions)})"
@@ -6,8 +6,35 @@ via various frontend protocols.
Available integrations:
- agui: AG-UI (Agent-User Interface) protocol for CopilotKit and compatible frontends
- a2a: Agent-to-Agent protocol
- a2ui: Agent-to-UI protocol
This module uses lazy loading to minimize import time.
"""
from praisonaiagents.ui.agui import AGUI
# Module-level cache for lazy-loaded classes
_lazy_cache = {}
__all__ = ["AGUI"]
def __getattr__(name):
"""Lazy load UI classes to avoid importing heavy dependencies at module load time."""
if name in _lazy_cache:
return _lazy_cache[name]
if name == "AGUI":
from praisonaiagents.ui.agui import AGUI
_lazy_cache[name] = AGUI
return AGUI
elif name == "A2A":
from praisonaiagents.ui.a2a import A2A
_lazy_cache[name] = A2A
return A2A
elif name == "A2UI":
from praisonaiagents.ui.a2ui import A2UI
_lazy_cache[name] = A2UI
return A2UI
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = ["AGUI", "A2A", "A2UI"]
Whitespace-only changes.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.4.1"
version = "0.5.1"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,243 @@
"""
Real API key integration tests for praisonaiagents.
These tests are gated by the RUN_REAL_KEY_TESTS environment variable.
They require valid API keys to be set in the environment.
Usage:
RUN_REAL_KEY_TESTS=1 pytest tests/integration/test_real_api.py -v
Required environment variables:
- OPENAI_API_KEY: OpenAI API key
Optional:
- ANTHROPIC_API_KEY: Anthropic API key (for Claude tests)
- GOOGLE_API_KEY: Google API key (for Gemini tests)
"""
import os
import sys
import pytest
# Gate all tests in this module
pytestmark = pytest.mark.skipif(
os.environ.get('RUN_REAL_KEY_TESTS', '').lower() not in ('1', 'true', 'yes'),
reason="Real API key tests disabled. Set RUN_REAL_KEY_TESTS=1 to enable."
)
def clear_modules():
"""Clear all praisonai and litellm related modules from cache."""
to_remove = [m for m in list(sys.modules.keys())
if 'praison' in m or 'litellm' in m]
for mod in to_remove:
del sys.modules[mod]
class TestAgentRealAPI:
"""Test Agent with real API calls."""
@pytest.fixture(autouse=True)
def setup(self):
"""Set up test fixtures."""
clear_modules()
# Verify API key is set (without printing it)
api_key = os.environ.get('OPENAI_API_KEY')
if not api_key:
pytest.skip("OPENAI_API_KEY not set")
yield
clear_modules()
def test_agent_simple_chat(self):
"""Test basic Agent.chat() with real API."""
from praisonaiagents import Agent
agent = Agent(
name="TestAgent",
instructions="You are a helpful assistant. Keep responses very brief.",
llm="gpt-4o-mini",
verbose=False
)
response = agent.chat("Say 'hello' and nothing else.")
assert response is not None
assert len(response) > 0
assert 'hello' in response.lower()
print(f"[PASS] Agent response received (length: {len(response)})")
def test_agent_with_tool(self):
"""Test Agent with a simple tool."""
from praisonaiagents import Agent
# Define a simple deterministic tool
def add_numbers(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
agent = Agent(
name="MathAgent",
instructions="You are a math assistant. Use the add_numbers tool when asked to add.",
llm="gpt-4o-mini",
tools=[add_numbers],
verbose=False
)
response = agent.chat("What is 5 + 3? Use the add_numbers tool.")
assert response is not None
assert '8' in response
print(f"[PASS] Tool call successful, response contains '8'")
def test_agent_chat_history(self):
"""Test that chat history is maintained."""
from praisonaiagents import Agent
agent = Agent(
name="HistoryAgent",
instructions="You are a helpful assistant. Remember what the user tells you.",
llm="gpt-4o-mini",
verbose=False
)
# First message
agent.chat("My favorite color is blue.")
# Second message referencing first
response = agent.chat("What is my favorite color?")
assert response is not None
assert 'blue' in response.lower()
print(f"[PASS] Chat history maintained correctly")
class TestLiteAgentRealAPI:
"""Test LiteAgent with real API using OpenAI SDK directly."""
@pytest.fixture(autouse=True)
def setup(self):
"""Set up test fixtures."""
clear_modules()
api_key = os.environ.get('OPENAI_API_KEY')
if not api_key:
pytest.skip("OPENAI_API_KEY not set")
yield
clear_modules()
def test_lite_agent_with_openai(self):
"""Test LiteAgent with OpenAI SDK."""
from praisonaiagents.lite import LiteAgent, create_openai_llm_fn
# Create LLM function using OpenAI
llm_fn = create_openai_llm_fn(model="gpt-4o-mini")
agent = LiteAgent(
name="LiteTestAgent",
llm_fn=llm_fn,
instructions="You are a helpful assistant. Keep responses very brief."
)
response = agent.chat("Say 'hello' and nothing else.")
assert response is not None
assert len(response) > 0
print(f"[PASS] LiteAgent with OpenAI response received")
def test_lite_agent_no_litellm_loaded(self):
"""Verify LiteAgent doesn't load litellm."""
from praisonaiagents.lite import LiteAgent, create_openai_llm_fn
llm_fn = create_openai_llm_fn(model="gpt-4o-mini")
agent = LiteAgent(llm_fn=llm_fn)
response = agent.chat("Hi")
assert response is not None
assert 'litellm' not in sys.modules, \
"LiteAgent should not load litellm"
print(f"[PASS] LiteAgent works without litellm")
class TestAnthropicAPI:
"""Test with Anthropic API (optional)."""
@pytest.fixture(autouse=True)
def setup(self):
"""Set up test fixtures."""
clear_modules()
api_key = os.environ.get('ANTHROPIC_API_KEY')
if not api_key:
pytest.skip("ANTHROPIC_API_KEY not set")
yield
clear_modules()
def test_agent_with_claude(self):
"""Test Agent with Claude model."""
from praisonaiagents import Agent
agent = Agent(
name="ClaudeAgent",
instructions="You are a helpful assistant. Keep responses very brief.",
llm="claude-3-5-sonnet-20241022",
verbose=False
)
response = agent.chat("Say 'hello' and nothing else.")
assert response is not None
assert len(response) > 0
print(f"[PASS] Claude response received")
class TestGoogleAPI:
"""Test with Google API (optional)."""
@pytest.fixture(autouse=True)
def setup(self):
"""Set up test fixtures."""
clear_modules()
api_key = os.environ.get('GOOGLE_API_KEY')
if not api_key:
pytest.skip("GOOGLE_API_KEY not set")
yield
clear_modules()
def test_agent_with_gemini(self):
"""Test Agent with Gemini model."""
from praisonaiagents import Agent
agent = Agent(
name="GeminiAgent",
instructions="You are a helpful assistant. Keep responses very brief.",
llm="gemini/gemini-2.0-flash",
verbose=False
)
response = agent.chat("Say 'hello' and nothing else.")
assert response is not None
assert len(response) > 0
print(f"[PASS] Gemini response received")
if __name__ == "__main__":
# Run with real API keys
if os.environ.get('RUN_REAL_KEY_TESTS', '').lower() not in ('1', 'true', 'yes'):
print("Set RUN_REAL_KEY_TESTS=1 to run these tests")
sys.exit(1)
pytest.main([__file__, "-v", "-s"])
@@ -5,11 +5,16 @@ Basic test to verify HTTP Stream implementation is working correctly.
import sys
import os
import pytest
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from praisonaiagents.mcp import MCP
# Skip entire module if mcp package is not installed
try:
from praisonaiagents.mcp import MCP
except ImportError:
pytest.skip("MCP package not installed", allow_module_level=True)
def test_transport_selection():
"""Test that URLs are correctly routed to appropriate transports."""
@@ -18,8 +18,12 @@ from unittest.mock import Mock
# Add the package to path for testing
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from praisonaiagents.mcp.mcp import MCP
from praisonaiagents.agent.agent import Agent
# Skip entire module if mcp package is not installed
try:
from praisonaiagents.mcp.mcp import MCP
from praisonaiagents.agent.agent import Agent
except ImportError:
pytest.skip("MCP package not installed", allow_module_level=True)
# ============================================================================
@@ -0,0 +1,217 @@
"""
Tests for lazy import behavior in praisonaiagents.
These tests verify that heavy dependencies like litellm are NOT loaded
at package import time, ensuring fast startup.
"""
import sys
import pytest
def clear_modules():
"""Clear all praisonai and litellm related modules from cache."""
to_remove = [m for m in list(sys.modules.keys())
if 'praison' in m or 'litellm' in m]
for mod in to_remove:
del sys.modules[mod]
class TestLazyImports:
"""Test that heavy dependencies are lazily loaded."""
def setup_method(self):
"""Clear module cache before each test."""
clear_modules()
def teardown_method(self):
"""Clean up after each test."""
clear_modules()
def test_litellm_not_loaded_on_import(self):
"""litellm should NOT be loaded when importing praisonaiagents."""
import praisonaiagents # noqa: F401
assert 'litellm' not in sys.modules, \
"litellm should not be loaded at import time"
def test_requests_not_loaded_on_import(self):
"""requests should NOT be loaded when importing praisonaiagents."""
import praisonaiagents # noqa: F401
assert 'requests' not in sys.modules, \
"requests should not be loaded at import time"
def test_chromadb_not_loaded_on_import(self):
"""chromadb should NOT be loaded when importing praisonaiagents."""
import praisonaiagents # noqa: F401
assert 'chromadb' not in sys.modules, \
"chromadb should not be loaded at import time"
def test_agent_available_via_getattr(self):
"""Agent should be available via lazy loading."""
import praisonaiagents
# Access Agent - this triggers lazy loading
Agent = praisonaiagents.Agent
assert Agent is not None
assert hasattr(Agent, 'chat')
def test_session_available_via_getattr(self):
"""Session should be available via lazy loading."""
import praisonaiagents
# Access Session - this triggers lazy loading
Session = praisonaiagents.Session
assert Session is not None
def test_knowledge_available_via_getattr(self):
"""Knowledge should be available via lazy loading."""
import praisonaiagents
# Access Knowledge - this triggers lazy loading
Knowledge = praisonaiagents.Knowledge
assert Knowledge is not None
def test_memory_available_via_getattr(self):
"""Memory should be available via lazy loading."""
import praisonaiagents
# Access Memory - this triggers lazy loading
Memory = praisonaiagents.Memory
assert Memory is not None
def test_tools_loaded_eagerly(self):
"""Tools should be available immediately (lightweight)."""
import praisonaiagents
# Tools is lightweight and should be available
assert hasattr(praisonaiagents, 'Tools')
assert praisonaiagents.Tools is not None
def test_all_exports_accessible(self):
"""All items in __all__ should be accessible."""
import praisonaiagents
for name in praisonaiagents.__all__:
# Some items may be None if optional deps not installed
try:
attr = getattr(praisonaiagents, name)
# Just verify it's accessible, may be None
except AttributeError:
pytest.fail(f"'{name}' in __all__ but not accessible")
class TestLitePackage:
"""Test the lite subpackage."""
def setup_method(self):
"""Clear module cache before each test."""
clear_modules()
def test_lite_import_fast(self):
"""Lite package should import quickly without heavy deps."""
import time
start = time.perf_counter()
from praisonaiagents.lite import LiteAgent # noqa: F401
end = time.perf_counter()
import_time_ms = (end - start) * 1000
# 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."""
from praisonaiagents.lite import LiteAgent
# Create agent without LLM function
agent = LiteAgent(name="Test")
assert agent.name == "Test"
assert 'litellm' not in sys.modules
def test_lite_agent_with_custom_llm(self):
"""LiteAgent should work with custom LLM function."""
from praisonaiagents.lite import LiteAgent
# Create a mock LLM function
def mock_llm(messages):
return "Hello from mock LLM!"
agent = LiteAgent(
name="TestAgent",
llm_fn=mock_llm,
instructions="You are a test agent."
)
response = agent.chat("Hi")
assert response == "Hello from mock LLM!"
assert len(agent.chat_history) == 2 # user + assistant
class TestTelemetryConfig:
"""Test telemetry configuration."""
def setup_method(self):
"""Clear module cache and env vars before each test."""
clear_modules()
import os
# Save original env vars
self._orig_env = {}
for key in ['PRAISONAI_TELEMETRY_ENABLED', 'PRAISONAI_TELEMETRY_DISABLED',
'DO_NOT_TRACK', 'PRAISONAI_DISABLE_TELEMETRY']:
self._orig_env[key] = os.environ.get(key)
if key in os.environ:
del os.environ[key]
def teardown_method(self):
"""Restore env vars after each test."""
import os
for key, value in self._orig_env.items():
if value is None:
if key in os.environ:
del os.environ[key]
else:
os.environ[key] = value
clear_modules()
def test_telemetry_disabled_by_default(self):
"""Telemetry should be disabled by default (opt-in model)."""
from praisonaiagents._config import TELEMETRY_ENABLED
assert TELEMETRY_ENABLED is False, \
"Telemetry should be disabled by default"
def test_telemetry_enabled_via_env(self):
"""Telemetry can be enabled via environment variable."""
import os
os.environ['PRAISONAI_TELEMETRY_ENABLED'] = 'true'
# Re-import to pick up new env var
clear_modules()
from praisonaiagents._config import _get_telemetry_enabled
assert _get_telemetry_enabled() is True
def test_do_not_track_takes_precedence(self):
"""DO_NOT_TRACK should disable telemetry even if enabled."""
import os
os.environ['PRAISONAI_TELEMETRY_ENABLED'] = 'true'
os.environ['DO_NOT_TRACK'] = 'true'
clear_modules()
from praisonaiagents._config import _get_telemetry_enabled
assert _get_telemetry_enabled() is False
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,180 @@
"""
Tests for thread safety in praisonaiagents.
These tests verify that Agent chat_history and caches are thread-safe
when accessed concurrently.
"""
import sys
import threading
import time
import pytest
def clear_modules():
"""Clear all praisonai and litellm related modules from cache."""
to_remove = [m for m in list(sys.modules.keys())
if 'praison' in m or 'litellm' in m]
for mod in to_remove:
del sys.modules[mod]
class TestLiteAgentThreadSafety:
"""Test thread safety of LiteAgent (no external deps)."""
def test_concurrent_chat_history_access(self):
"""Multiple threads should safely access chat_history."""
from praisonaiagents.lite import LiteAgent
# Create agent with mock LLM
call_count = [0]
lock = threading.Lock()
def mock_llm(messages):
with lock:
call_count[0] += 1
time.sleep(0.01) # Simulate some work
return f"Response {call_count[0]}"
agent = LiteAgent(llm_fn=mock_llm)
errors = []
def worker(thread_id):
try:
for i in range(5):
response = agent.chat(f"Message from thread {thread_id}, iteration {i}")
assert response is not None
except Exception as e:
errors.append(f"Thread {thread_id}: {e}")
# Start multiple threads
threads = []
for i in range(4):
t = threading.Thread(target=worker, args=(i,))
threads.append(t)
t.start()
# Wait for all threads
for t in threads:
t.join()
# Check for errors
assert len(errors) == 0, f"Thread errors: {errors}"
# Verify history was updated (should have entries from all threads)
assert len(agent.chat_history) > 0
def test_concurrent_clear_history(self):
"""Clearing history while other threads are chatting should be safe."""
from praisonaiagents.lite import LiteAgent
def mock_llm(messages):
return "Response"
agent = LiteAgent(llm_fn=mock_llm)
errors = []
stop_flag = threading.Event()
def chat_worker():
try:
while not stop_flag.is_set():
agent.chat("Hello")
time.sleep(0.001)
except Exception as e:
errors.append(f"Chat error: {e}")
def clear_worker():
try:
for _ in range(10):
time.sleep(0.01)
agent.clear_history()
except Exception as e:
errors.append(f"Clear error: {e}")
# Start chat threads
chat_threads = []
for _ in range(2):
t = threading.Thread(target=chat_worker)
chat_threads.append(t)
t.start()
# Start clear thread
clear_thread = threading.Thread(target=clear_worker)
clear_thread.start()
# Wait for clear thread to finish
clear_thread.join()
# Stop chat threads
stop_flag.set()
for t in chat_threads:
t.join()
assert len(errors) == 0, f"Thread errors: {errors}"
class TestAgentThreadSafety:
"""Test thread safety of main Agent class.
Note: These tests use a mock LLM to avoid network calls.
"""
def setup_method(self):
"""Set up test fixtures."""
clear_modules()
def test_agent_has_history_lock(self):
"""Agent should have a history lock for thread safety."""
from praisonaiagents import Agent
agent = Agent(
name="Test",
instructions="Test agent",
llm="gpt-4o-mini" # Won't actually call
)
assert hasattr(agent, '_history_lock')
assert isinstance(agent._history_lock, type(threading.Lock()))
def test_agent_has_cache_lock(self):
"""Agent should have a cache lock for thread safety."""
from praisonaiagents import Agent
agent = Agent(
name="Test",
instructions="Test agent",
llm="gpt-4o-mini"
)
assert hasattr(agent, '_cache_lock')
# RLock is a different type than Lock
assert agent._cache_lock is not None
class TestMCPCleanup:
"""Test MCP cleanup and context manager support."""
def test_mcp_has_context_manager(self):
"""MCP should support context manager protocol."""
try:
from praisonaiagents.mcp.mcp import MCP
except ImportError:
pytest.skip("MCP package not installed")
assert hasattr(MCP, '__enter__')
assert hasattr(MCP, '__exit__')
assert hasattr(MCP, 'shutdown')
def test_mcp_shutdown_method(self):
"""MCP should have a shutdown method."""
try:
from praisonaiagents.mcp.mcp import MCP
except ImportError:
pytest.skip("MCP package not installed")
assert callable(getattr(MCP, 'shutdown', None))
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.4.1"
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.6.8.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.6.8.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.2.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,21 @@
"""
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
@@ -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,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
@@ -0,0 +1,578 @@
"""
Endpoints CLI Feature Handler
Provides CLI commands for interacting with recipe endpoints:
- list: List available recipe endpoints
- describe: Show endpoint details and schema
- invoke: Call an endpoint
- health: Check endpoint server health
All commands use the canonical `praisonai endpoints` prefix.
Why this feature is valuable:
- DX: Client invocation from any language/script
- Ops: Health checks, monitoring, automation
- Polyglot: Non-Python clients can invoke recipes via HTTP
- Testing: Easy endpoint verification without code
Architecture notes:
- Optional extras only (no server deps in core)
- Lazy imports for all HTTP client code
- No impact on praisonaiagents import time
"""
import json
import os
import sys
from typing import Any, Dict, List, Optional
class EndpointsHandler:
"""
CLI handler for endpoints operations.
Commands:
- list: List available recipe endpoints
- describe: Show endpoint details
- invoke: Call an endpoint
- health: Check server health
"""
# Exit codes
EXIT_SUCCESS = 0
EXIT_GENERAL_ERROR = 1
EXIT_VALIDATION_ERROR = 2
EXIT_RUNTIME_ERROR = 3
EXIT_AUTH_ERROR = 4
EXIT_NOT_FOUND = 7
EXIT_CONNECTION_ERROR = 8
DEFAULT_URL = "http://localhost:8765"
def __init__(self):
"""Initialize the handler."""
self._base_url = None
self._api_key = None
@property
def base_url(self) -> str:
"""Get base URL from env or default."""
if self._base_url:
return self._base_url
return os.environ.get("PRAISONAI_ENDPOINTS_URL", self.DEFAULT_URL)
@property
def api_key(self) -> Optional[str]:
"""Get API key from env."""
if self._api_key:
return self._api_key
return os.environ.get("PRAISONAI_ENDPOINTS_API_KEY")
def handle(self, args: List[str]) -> int:
"""
Handle endpoints subcommand.
Args:
args: Command arguments
Returns:
Exit code
"""
if not args:
self._print_help()
return self.EXIT_SUCCESS
command = args[0]
remaining = args[1:]
commands = {
"list": self.cmd_list,
"describe": self.cmd_describe,
"invoke": self.cmd_invoke,
"health": self.cmd_health,
"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 Endpoints[/bold cyan]
Client CLI for interacting with recipe endpoints.
[bold]Usage:[/bold]
praisonai endpoints <command> [options]
[bold]Commands:[/bold]
list List available recipe endpoints
describe <recipe> Show endpoint details and schema
invoke <recipe> Call an endpoint
health Check server health
[bold]List Options:[/bold]
--format json Output as JSON
--tags <a,b> Filter by tags (comma-separated)
--url <url> Server URL (default: http://localhost:8765)
[bold]Describe Options:[/bold]
--schema Show input/output schema only
--url <url> Server URL
[bold]Invoke Options:[/bold]
--input <path> Input file path
--input-json <j> Input as JSON string
--config k=v Config override (repeatable)
--json Output as JSON
--stream Stream output events (SSE)
--url <url> Server URL
--api-key <key> API key for auth (or set PRAISONAI_ENDPOINTS_API_KEY)
--dry-run Validate without executing
[bold]Health Options:[/bold]
--url <url> Server URL
[bold]Environment Variables:[/bold]
PRAISONAI_ENDPOINTS_URL Default server URL
PRAISONAI_ENDPOINTS_API_KEY API key for authentication
[bold]Examples:[/bold]
praisonai endpoints list
praisonai endpoints list --format json --tags audio,video
praisonai endpoints describe ai-podcast-cleaner
praisonai endpoints describe ai-podcast-cleaner --schema
praisonai endpoints invoke ai-podcast-cleaner --input ./audio.mp3
praisonai endpoints invoke ai-podcast-cleaner --input-json '{"text": "hello"}'
praisonai endpoints invoke ai-podcast-cleaner --input ./audio.mp3 --stream
praisonai endpoints health
praisonai endpoints health --url http://localhost:8000
"""
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 _print_json(self, data: Any):
"""Print JSON output."""
print(json.dumps(data, indent=2, default=str))
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()}
positional_keys = [k for k, v in spec.items() if v.get("positional")]
positional_idx = 0
# Handle repeatable args
for k, v in spec.items():
if v.get("repeatable"):
result[k] = []
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 spec[key].get("repeatable") and i + 1 < len(args):
result[key].append(args[i + 1])
i += 1
elif i + 1 < len(args):
result[key] = args[i + 1]
i += 1
i += 1
elif arg.startswith("-") and len(arg) == 2:
for key, val in spec.items():
if val.get("short") == arg:
if val.get("flag"):
result[key] = True
elif i + 1 < len(args):
result[key] = args[i + 1]
i += 1
break
i += 1
else:
if positional_idx < len(positional_keys):
result[positional_keys[positional_idx]] = arg
positional_idx += 1
i += 1
return result
def _make_request(
self,
method: str,
path: str,
url: Optional[str] = None,
api_key: Optional[str] = None,
json_data: Optional[Dict] = None,
stream: bool = False,
) -> Dict[str, Any]:
"""Make HTTP request to endpoint server."""
try:
import urllib.request
import urllib.error
except ImportError:
return {"error": "urllib not available"}
base = url or self.base_url
full_url = f"{base.rstrip('/')}{path}"
headers = {"Content-Type": "application/json"}
key = api_key or self.api_key
if key:
headers["X-API-Key"] = 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=30) 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)}}
def _stream_request(
self,
path: str,
url: Optional[str] = None,
api_key: Optional[str] = None,
json_data: Optional[Dict] = None,
):
"""Make streaming HTTP request."""
try:
import urllib.request
except ImportError:
yield {"error": "urllib not available"}
return
base = url or self.base_url
full_url = f"{base.rstrip('/')}{path}"
headers = {"Content-Type": "application/json", "Accept": "text/event-stream"}
key = api_key or self.api_key
if key:
headers["X-API-Key"] = key
data = json.dumps(json_data).encode("utf-8") if json_data else None
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"):
# Parse SSE event
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 {"error": str(e)}
def cmd_list(self, args: List[str]) -> int:
"""List available recipe endpoints."""
spec = {
"format": {"default": "table"},
"tags": {"default": None},
"url": {"default": None},
}
parsed = self._parse_args(args, spec)
# Build query params
path = "/v1/recipes"
if parsed["tags"]:
path += f"?tags={parsed['tags']}"
result = self._make_request("GET", path, url=parsed["url"])
if result.get("error"):
self._print_error(result["error"].get("message", str(result["error"])))
return self.EXIT_CONNECTION_ERROR
recipes = result.get("data", {}).get("recipes", [])
if parsed["format"] == "json":
self._print_json(recipes)
return self.EXIT_SUCCESS
if not recipes:
print("No endpoints available.")
return self.EXIT_SUCCESS
try:
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Available Endpoints")
table.add_column("Name", style="cyan")
table.add_column("Version", style="green")
table.add_column("Description")
table.add_column("Tags", style="yellow")
for r in recipes:
tags = r.get("tags", [])
table.add_row(
r.get("name", ""),
r.get("version", ""),
(r.get("description", "")[:50] + "...") if len(r.get("description", "")) > 50 else r.get("description", ""),
", ".join(tags[:3]) if tags else "",
)
console.print(table)
except ImportError:
for r in recipes:
print(f"{r.get('name')} ({r.get('version')}): {r.get('description', '')}")
return self.EXIT_SUCCESS
def cmd_describe(self, args: List[str]) -> int:
"""Describe a recipe endpoint."""
spec = {
"recipe": {"positional": True, "default": ""},
"schema": {"flag": True, "default": False},
"url": {"default": None},
}
parsed = self._parse_args(args, spec)
if not parsed["recipe"]:
self._print_error("Recipe name required")
return self.EXIT_VALIDATION_ERROR
# Get schema if requested
if parsed["schema"]:
path = f"/v1/recipes/{parsed['recipe']}/schema"
else:
path = f"/v1/recipes/{parsed['recipe']}"
result = self._make_request("GET", path, url=parsed["url"])
if result.get("status") == 404:
self._print_error(f"Endpoint not found: {parsed['recipe']}")
return self.EXIT_NOT_FOUND
if result.get("error"):
self._print_error(result["error"].get("message", str(result["error"])))
return self.EXIT_CONNECTION_ERROR
data = result.get("data", {})
self._print_json(data)
return self.EXIT_SUCCESS
def cmd_invoke(self, args: List[str]) -> int:
"""Invoke a recipe endpoint."""
spec = {
"recipe": {"positional": True, "default": ""},
"input": {"short": "-i", "default": None},
"input_json": {"default": None},
"config": {"repeatable": True, "default": []},
"json": {"flag": True, "default": False},
"stream": {"flag": True, "default": False},
"url": {"default": None},
"api_key": {"default": None},
"dry_run": {"flag": True, "default": False},
}
parsed = self._parse_args(args, spec)
if not parsed["recipe"]:
self._print_error("Recipe name required")
return self.EXIT_VALIDATION_ERROR
# Build input data
input_data = {}
if parsed["input_json"]:
try:
input_data = json.loads(parsed["input_json"])
except json.JSONDecodeError:
self._print_error("Invalid JSON in --input-json")
return self.EXIT_VALIDATION_ERROR
elif parsed["input"]:
input_data = {"input": parsed["input"]}
# Build config from --config key=value pairs
config = {}
for cfg in parsed["config"]:
if "=" in cfg:
k, v = cfg.split("=", 1)
config[k] = v
# Build request body
body = {
"recipe": parsed["recipe"],
"input": input_data,
"config": config,
"options": {"dry_run": parsed["dry_run"]},
}
if parsed["stream"]:
return self._invoke_stream(body, parsed)
# Sync invocation
result = self._make_request(
"POST",
"/v1/recipes/run",
url=parsed["url"],
api_key=parsed["api_key"],
json_data=body,
)
if result.get("status") == 401:
self._print_error("Authentication required. Use --api-key or set PRAISONAI_ENDPOINTS_API_KEY")
return self.EXIT_AUTH_ERROR
if result.get("status") == 404:
self._print_error(f"Endpoint not found: {parsed['recipe']}")
return self.EXIT_NOT_FOUND
if result.get("error"):
self._print_error(result["error"].get("message", str(result["error"])))
return self.EXIT_CONNECTION_ERROR
data = result.get("data", {})
if parsed["json"]:
self._print_json(data)
else:
if data.get("ok"):
self._print_success(f"Endpoint '{parsed['recipe']}' invoked successfully")
print(f" Run ID: {data.get('run_id')}")
print(f" Status: {data.get('status')}")
if data.get("output"):
print(f" Output: {data.get('output')}")
else:
self._print_error(f"Invocation failed: {data.get('error')}")
return self.EXIT_RUNTIME_ERROR
return self.EXIT_SUCCESS if data.get("ok") else self.EXIT_RUNTIME_ERROR
def _invoke_stream(self, body: Dict, parsed: Dict) -> int:
"""Handle streaming invocation."""
print("Streaming output...")
for event in self._stream_request(
"/v1/recipes/stream",
url=parsed["url"],
api_key=parsed["api_key"],
json_data=body,
):
if event.get("error"):
self._print_error(event["error"])
return self.EXIT_CONNECTION_ERROR
event_type = event.get("event", "message")
data = event.get("data", {})
if parsed["json"]:
print(json.dumps({"event": event_type, "data": data}))
else:
if event_type == "started":
print(f" Started: {data.get('run_id')}")
elif event_type == "progress":
print(f" [{data.get('step')}] {data.get('message', '')}")
elif event_type == "completed":
self._print_success(f"Completed: {data.get('status')}")
elif event_type == "error":
self._print_error(data.get("message", "Unknown error"))
return self.EXIT_RUNTIME_ERROR
return self.EXIT_SUCCESS
def cmd_health(self, args: List[str]) -> int:
"""Check endpoint server health."""
spec = {
"url": {"default": None},
}
parsed = self._parse_args(args, spec)
result = self._make_request("GET", "/health", url=parsed["url"])
if result.get("error"):
self._print_error(f"Server unhealthy: {result['error'].get('message', str(result['error']))}")
return self.EXIT_CONNECTION_ERROR
data = result.get("data", {})
if data.get("status") == "healthy":
self._print_success("Server healthy")
print(f" Service: {data.get('service')}")
print(f" Version: {data.get('version')}")
return self.EXIT_SUCCESS
else:
self._print_error(f"Server unhealthy: {data}")
return self.EXIT_RUNTIME_ERROR
def handle_endpoints_command(args: List[str]) -> int:
"""Entry point for endpoints command."""
handler = EndpointsHandler()
return handler.handle(args)
@@ -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
@@ -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)."""
+41 -3
View File
@@ -61,11 +61,23 @@ import subprocess
import logging
import importlib
from praisonai.auto import AutoGenerator
from praisonai.agents_generator import AgentsGenerator
# Lazy imports for performance - these are imported when needed, not at module load
# from praisonai.auto import AutoGenerator # Lazy: imported in auto/init commands
# from praisonai.agents_generator import AgentsGenerator # Lazy: imported when running agents
from praisonai.inbuilt_tools import *
from praisonai.inc.config import generate_config
# Lazy import helpers for heavy modules
def _get_auto_generator():
"""Lazy import AutoGenerator to avoid loading heavy deps at CLI startup."""
from praisonai.auto import AutoGenerator
return AutoGenerator
def _get_agents_generator():
"""Lazy import AgentsGenerator to avoid loading heavy deps at CLI startup."""
from praisonai.agents_generator import AgentsGenerator
return AgentsGenerator
# Optional module imports with availability checks
CHAINLIT_AVAILABLE = False
GRADIO_AVAILABLE = False
@@ -564,8 +576,10 @@ class PraisonAI:
self.topic = temp_topic
self.agent_file = "test.yaml"
AutoGenerator = _get_auto_generator()
generator = AutoGenerator(topic=self.topic, framework=self.framework, agent_file=self.agent_file)
self.agent_file = generator.generate(merge=getattr(args, 'merge', False))
AgentsGenerator = _get_agents_generator()
agents_generator = AgentsGenerator(self.agent_file, self.framework, self.config_list)
result = agents_generator.generate_crew_and_kickoff()
print(result)
@@ -577,6 +591,7 @@ class PraisonAI:
self.topic = temp_topic
self.agent_file = "agents.yaml"
AutoGenerator = _get_auto_generator()
generator = AutoGenerator(topic=self.topic, framework=self.framework, agent_file=self.agent_file)
self.agent_file = generator.generate(merge=getattr(args, 'merge', False))
print(f"File {self.agent_file} created successfully")
@@ -589,6 +604,7 @@ class PraisonAI:
self.create_chainlit_interface()
else:
# Modify code to allow default UI
AgentsGenerator = _get_agents_generator()
agents_generator = AgentsGenerator(
self.agent_file,
self.framework,
@@ -626,6 +642,7 @@ class PraisonAI:
except Exception:
pass # Continue even if flow display fails
AgentsGenerator = _get_agents_generator()
agents_generator = AgentsGenerator(
self.agent_file,
self.framework,
@@ -681,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")
@@ -1169,6 +1186,18 @@ 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 - client CLI for recipe endpoints
from .features.endpoints import handle_endpoints_command
exit_code = handle_endpoints_command(unknown_args)
sys.exit(exit_code)
elif args.command == 'agents':
# Agents command - run multiple agents with custom definitions
if not PRAISONAI_AVAILABLE:
@@ -1341,6 +1370,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):
@@ -4284,8 +4320,10 @@ Now, {final_instruction.lower()}:"""
def generate_crew_and_kickoff_interface(auto_args, framework):
self.framework = framework
self.agent_file = "test.yaml"
AutoGenerator = _get_auto_generator()
generator = AutoGenerator(topic=auto_args, framework=self.framework)
self.agent_file = generator.generate()
AgentsGenerator = _get_agents_generator()
agents_generator = AgentsGenerator(self.agent_file, self.framework, self.config_list)
result = agents_generator.generate_crew_and_kickoff()
return result
+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.6.8 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.7.2 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+2 -1
View File
@@ -3,7 +3,8 @@ import os
import logging
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
logging.basicConfig(level=os.environ.get('LOGLEVEL', 'INFO').upper(), format='%(asctime)s - %(levelname)s - %(message)s')
_loglevel = os.environ.get('LOGLEVEL', 'INFO').strip().upper() or 'INFO'
logging.basicConfig(level=_loglevel, format='%(asctime)s - %(levelname)s - %(message)s')
# Constants
LOCAL_SERVER_API_KEY_PLACEHOLDER = "not-needed"
+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}")
+813
View File
@@ -0,0 +1,813 @@
"""
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 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
+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,
}
+366
View File
@@ -0,0 +1,366 @@
"""
Recipe HTTP Server
Provides HTTP endpoints for recipe execution.
Optional dependency - requires: pip install praisonai[serve]
Endpoints:
- GET /health - Health check
- GET /v1/recipes - List recipes
- GET /v1/recipes/{name} - Describe recipe
- GET /v1/recipes/{name}/schema - Get recipe schema
- POST /v1/recipes/run - Run recipe (sync)
- POST /v1/recipes/stream - Run recipe (SSE)
Auth modes:
- none: No authentication (localhost only)
- api-key: X-API-Key header required
- jwt: Bearer token required (future)
Config file (serve.yaml):
```yaml
host: 127.0.0.1
port: 8765
auth: api-key
api_key: your-secret-key # or use PRAISONAI_API_KEY env var
recipes:
- my-recipe
- another-recipe
preload: true
cors_origins: "*"
```
"""
import json
import os
from typing import Any, Dict, Optional
def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
"""
Load configuration from file.
Precedence: CLI flags > env vars > config file > defaults
"""
config = {}
if config_path and os.path.exists(config_path):
try:
import yaml
with open(config_path) as f:
config = yaml.safe_load(f) or {}
except ImportError:
# Fall back to JSON if yaml not available
with open(config_path) as f:
config = json.load(f)
# Apply env var overrides
if os.environ.get("PRAISONAI_API_KEY"):
config["api_key"] = os.environ["PRAISONAI_API_KEY"]
if os.environ.get("PRAISONAI_SERVE_HOST"):
config["host"] = os.environ["PRAISONAI_SERVE_HOST"]
if os.environ.get("PRAISONAI_SERVE_PORT"):
config["port"] = int(os.environ["PRAISONAI_SERVE_PORT"])
return config
def create_auth_middleware(auth_type: str, api_key: Optional[str] = None):
"""Create authentication middleware."""
try:
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
except ImportError:
return None
class APIKeyAuthMiddleware(BaseHTTPMiddleware):
"""API Key authentication middleware."""
async def dispatch(self, request, call_next):
# Skip auth for health endpoint
if request.url.path == "/health":
return await call_next(request)
# Check X-API-Key header
provided_key = request.headers.get("X-API-Key")
expected_key = api_key or os.environ.get("PRAISONAI_API_KEY")
if not expected_key:
# No key configured, allow request
return await call_next(request)
if provided_key != expected_key:
return JSONResponse(
{"error": {"code": "unauthorized", "message": "Invalid or missing API key"}},
status_code=401
)
return await call_next(request)
if auth_type == "api-key":
return APIKeyAuthMiddleware
return None
def create_app(config: Optional[Dict[str, Any]] = None) -> Any:
"""
Create ASGI application for recipe runner.
Args:
config: Optional configuration dict
Returns:
Starlette ASGI application
"""
try:
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.responses import JSONResponse, Response
from starlette.requests import Request
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
except ImportError:
raise ImportError(
"Serve dependencies not installed. Run: pip install praisonai[serve]"
)
config = config or {}
async def health(request: Request) -> JSONResponse:
"""GET /health - Health check."""
return JSONResponse({
"status": "healthy",
"service": "praisonai-recipe-runner",
"version": _get_version(),
})
async def list_recipes(request: Request) -> JSONResponse:
"""GET /v1/recipes - List available recipes."""
from praisonai import recipe
source_filter = request.query_params.get("source")
tags = request.query_params.get("tags")
tags_list = tags.split(",") if tags else None
recipes = recipe.list_recipes(
source_filter=source_filter,
tags=tags_list,
)
return JSONResponse({
"recipes": [r.to_dict() for r in recipes]
})
async def describe_recipe(request: Request) -> JSONResponse:
"""GET /v1/recipes/{name} - Describe a recipe."""
name = request.path_params["name"]
from praisonai import recipe
info = recipe.describe(name)
if info is None:
return JSONResponse(
{"error": {"code": "not_found", "message": f"Recipe not found: {name}"}},
status_code=404
)
return JSONResponse(info.to_dict())
async def get_schema(request: Request) -> JSONResponse:
"""GET /v1/recipes/{name}/schema - Get recipe JSON schema."""
name = request.path_params["name"]
from praisonai import recipe
info = recipe.describe(name)
if info is None:
return JSONResponse(
{"error": {"code": "not_found", "message": f"Recipe not found: {name}"}},
status_code=404
)
return JSONResponse({
"name": info.name,
"version": info.version,
"input_schema": info.config_schema,
"output_schema": info.outputs,
})
async def run_recipe(request: Request) -> JSONResponse:
"""POST /v1/recipes/run - Run a recipe."""
try:
body = await request.json()
except json.JSONDecodeError:
return JSONResponse(
{"error": {"code": "invalid_json", "message": "Invalid JSON body"}},
status_code=400
)
recipe_name = body.get("recipe")
if not recipe_name:
return JSONResponse(
{"error": {"code": "missing_recipe", "message": "Recipe name required"}},
status_code=400
)
input_data = body.get("input", {})
config_data = body.get("config", {})
options = body.get("options", {})
session_id = body.get("session_id")
from praisonai import recipe
result = recipe.run(
recipe_name,
input=input_data,
config=config_data,
session_id=session_id,
options=options,
)
status_code = 200 if result.ok else 500
if result.status == "policy_denied":
status_code = 403
elif result.status == "missing_deps":
status_code = 424 # Failed Dependency
elif result.status == "validation_error":
status_code = 400
return JSONResponse(result.to_dict(), status_code=status_code)
async def stream_recipe(request: Request) -> Response:
"""POST /v1/recipes/stream - Stream recipe execution."""
try:
from sse_starlette.sse import EventSourceResponse
except ImportError:
return JSONResponse(
{"error": {"code": "sse_unavailable", "message": "SSE not available"}},
status_code=501
)
try:
body = await request.json()
except json.JSONDecodeError:
return JSONResponse(
{"error": {"code": "invalid_json", "message": "Invalid JSON body"}},
status_code=400
)
recipe_name = body.get("recipe")
if not recipe_name:
return JSONResponse(
{"error": {"code": "missing_recipe", "message": "Recipe name required"}},
status_code=400
)
input_data = body.get("input", {})
config_data = body.get("config", {})
options = body.get("options", {})
session_id = body.get("session_id")
async def event_generator():
from praisonai import recipe
for event in recipe.run_stream(
recipe_name,
input=input_data,
config=config_data,
session_id=session_id,
options=options,
):
yield {
"event": event.event_type,
"data": json.dumps(event.data),
}
return EventSourceResponse(event_generator())
async def validate_recipe(request: Request) -> JSONResponse:
"""POST /v1/recipes/validate - Validate a recipe."""
try:
body = await request.json()
except json.JSONDecodeError:
return JSONResponse(
{"error": {"code": "invalid_json", "message": "Invalid JSON body"}},
status_code=400
)
recipe_name = body.get("recipe")
if not recipe_name:
return JSONResponse(
{"error": {"code": "missing_recipe", "message": "Recipe name required"}},
status_code=400
)
from praisonai import recipe
result = recipe.validate(recipe_name)
status_code = 200 if result.valid else 400
return JSONResponse(result.to_dict(), status_code=status_code)
# Build routes
routes = [
Route("/health", health, methods=["GET"]),
Route("/v1/recipes", list_recipes, methods=["GET"]),
Route("/v1/recipes/run", run_recipe, methods=["POST"]),
Route("/v1/recipes/stream", stream_recipe, methods=["POST"]),
Route("/v1/recipes/validate", validate_recipe, methods=["POST"]),
Route("/v1/recipes/{name}", describe_recipe, methods=["GET"]),
Route("/v1/recipes/{name}/schema", get_schema, methods=["GET"]),
]
# Add CORS middleware if configured
middleware = []
cors_origins = config.get("cors_origins")
if cors_origins:
middleware.append(
Middleware(
CORSMiddleware,
allow_origins=cors_origins.split(",") if isinstance(cors_origins, str) else cors_origins,
allow_methods=["*"],
allow_headers=["*"],
)
)
# Add auth middleware if configured
auth_type = config.get("auth")
if auth_type and auth_type != "none":
auth_middleware = create_auth_middleware(auth_type, config.get("api_key"))
if auth_middleware:
middleware.append(Middleware(auth_middleware))
return Starlette(routes=routes, middleware=middleware)
def serve(
host: str = "127.0.0.1",
port: int = 8765,
reload: bool = False,
config: Optional[Dict[str, Any]] = None,
):
"""
Start the recipe runner server.
Args:
host: Server host (default: 127.0.0.1)
port: Server port (default: 8765)
reload: Enable hot reload (default: False)
config: Optional configuration dict
"""
try:
import uvicorn
except ImportError:
raise ImportError(
"Serve dependencies not installed. Run: pip install praisonai[serve]"
)
app = create_app(config)
uvicorn.run(app, host=host, port=port, reload=reload)
def _get_version() -> str:
"""Get PraisonAI version."""
try:
from praisonai.version import __version__
return __version__
except ImportError:
return "unknown"
+101 -7
View File
@@ -3,12 +3,14 @@ Template Discovery
Discovers templates from multiple directories with precedence support.
Supports custom user directories, built-in templates, and package templates.
Includes caching to avoid repeated filesystem scans.
"""
import os
import time
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from dataclasses import dataclass, field
@dataclass
@@ -25,6 +27,14 @@ class DiscoveredTemplate:
author: Optional[str] = None
@dataclass
class _CacheEntry:
"""Cache entry for template discovery results."""
templates: Dict[str, 'DiscoveredTemplate']
timestamp: float
mtimes: Dict[str, float] = field(default_factory=dict) # path -> mtime for invalidation
class TemplateDiscovery:
"""
Discovers templates from multiple directories with precedence.
@@ -36,6 +46,12 @@ class TemplateDiscovery:
4. Built-in package templates (agent_recipes)
Templates in higher priority directories override those in lower priority.
Caching:
- Results are cached to avoid repeated filesystem scans
- Cache is invalidated when directory mtimes change
- Use refresh=True to force a fresh scan
- Default TTL is 300 seconds (5 minutes)
"""
# Default search paths (in priority order)
@@ -47,11 +63,18 @@ class TemplateDiscovery:
TEMPLATE_FILE = "TEMPLATE.yaml"
# Cache TTL in seconds (5 minutes default)
CACHE_TTL = 300
# Class-level cache shared across instances with same search paths
_cache: Dict[str, _CacheEntry] = {}
def __init__(
self,
custom_dirs: Optional[List[str]] = None,
include_package: bool = True,
include_defaults: bool = True
include_defaults: bool = True,
cache_ttl: Optional[int] = None
):
"""
Initialize template discovery.
@@ -60,6 +83,7 @@ class TemplateDiscovery:
custom_dirs: Additional custom directories to search (highest priority)
include_package: Whether to include package templates (agent_recipes)
include_defaults: Whether to include default search paths
cache_ttl: Cache time-to-live in seconds (default: 300)
"""
self.search_paths: List[Tuple[Path, str, int]] = []
@@ -78,6 +102,40 @@ class TemplateDiscovery:
self.include_package = include_package
self._package_path: Optional[Path] = None
self._cache_ttl = cache_ttl if cache_ttl is not None else self.CACHE_TTL
# Generate cache key based on search paths configuration
self._cache_key = self._generate_cache_key()
def _generate_cache_key(self) -> str:
"""Generate a unique cache key based on search paths."""
paths_str = "|".join(
f"{p}:{s}:{pr}" for p, s, pr in self.search_paths
)
return f"{paths_str}|pkg={self.include_package}"
def _get_dir_mtime(self, path: Path) -> float:
"""Get directory modification time, or 0 if not accessible."""
try:
if path.exists():
return path.stat().st_mtime
except (OSError, PermissionError):
pass
return 0.0
def _is_cache_valid(self, entry: _CacheEntry) -> bool:
"""Check if cache entry is still valid."""
# Check TTL
if time.time() - entry.timestamp > self._cache_ttl:
return False
# Check if any directory mtimes have changed
for path_str, cached_mtime in entry.mtimes.items():
current_mtime = self._get_dir_mtime(Path(path_str))
if current_mtime != cached_mtime:
return False
return True
@property
def package_templates_path(self) -> Optional[Path]:
@@ -97,15 +155,28 @@ class TemplateDiscovery:
pass
return self._package_path
def discover_all(self) -> Dict[str, DiscoveredTemplate]:
def discover_all(self, refresh: bool = False) -> Dict[str, DiscoveredTemplate]:
"""
Discover all templates from all search paths.
Results are cached to avoid repeated filesystem scans.
Cache is invalidated when directory mtimes change or TTL expires.
Args:
refresh: If True, bypass cache and force a fresh scan
Returns:
Dict mapping template name to DiscoveredTemplate.
When duplicates exist, higher priority wins.
"""
# Check cache first (unless refresh requested)
if not refresh and self._cache_key in self._cache:
entry = self._cache[self._cache_key]
if self._is_cache_valid(entry):
return entry.templates.copy()
templates: Dict[str, DiscoveredTemplate] = {}
mtimes: Dict[str, float] = {}
# Scan search paths in reverse priority order (lowest first)
# so that higher priority overwrites lower
@@ -120,11 +191,21 @@ class TemplateDiscovery:
all_paths.sort(key=lambda x: x[2], reverse=True)
for dir_path, source, priority in all_paths:
# Record mtime for cache invalidation
mtimes[str(dir_path)] = self._get_dir_mtime(dir_path)
if dir_path.exists() and dir_path.is_dir():
discovered = self._scan_directory(dir_path, source, priority)
# Higher priority overwrites lower
templates.update(discovered)
# Store in cache
self._cache[self._cache_key] = _CacheEntry(
templates=templates.copy(),
timestamp=time.time(),
mtimes=mtimes
)
return templates
def _scan_directory(
@@ -191,7 +272,7 @@ class TemplateDiscovery:
except Exception:
pass # Metadata loading is optional
def find_template(self, name: str) -> Optional[DiscoveredTemplate]:
def find_template(self, name: str, refresh: bool = False) -> Optional[DiscoveredTemplate]:
"""
Find a template by name.
@@ -200,11 +281,12 @@ class TemplateDiscovery:
Args:
name: Template name to find
refresh: If True, bypass cache and force a fresh scan
Returns:
DiscoveredTemplate if found, None otherwise
"""
all_templates = self.discover_all()
all_templates = self.discover_all(refresh=refresh)
return all_templates.get(name)
def resolve_template_path(self, name: str) -> Optional[Path]:
@@ -222,24 +304,36 @@ class TemplateDiscovery:
def list_templates(
self,
source_filter: Optional[str] = None
source_filter: Optional[str] = None,
refresh: bool = False
) -> List[DiscoveredTemplate]:
"""
List all discovered templates.
Args:
source_filter: Optional filter by source ('custom', 'package', etc.)
refresh: If True, bypass cache and force a fresh scan
Returns:
List of DiscoveredTemplate objects
"""
templates = self.discover_all()
templates = self.discover_all(refresh=refresh)
if source_filter:
return [t for t in templates.values() if t.source == source_filter]
return list(templates.values())
def clear_cache(self) -> None:
"""Clear the template discovery cache."""
if self._cache_key in self._cache:
del self._cache[self._cache_key]
@classmethod
def clear_all_caches(cls) -> None:
"""Clear all template discovery caches."""
cls._cache.clear()
def get_search_paths(self) -> List[Tuple[str, str, bool]]:
"""
Get all search paths with their status.
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.6.8"
__version__ = "2.7.2"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=0.4.1",
"praisonaiagents>=0.5.1",
"python-dotenv>=0.19.0",
"instructor>=1.3.3",
"PyYAML>=6.0",
@@ -0,0 +1,248 @@
"""Integration tests for doctor CLI command."""
import json
import subprocess
import sys
class TestDoctorCLI:
"""Integration tests for praisonai doctor CLI."""
def test_doctor_help(self):
"""Test doctor --help output."""
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "--help"],
capture_output=True,
text=True,
timeout=30,
)
# Should show help (exit code 0 or 2 for argparse)
assert result.returncode in [0, 2]
# Should have some output
assert len(result.stdout) > 0 or len(result.stderr) > 0
def test_doctor_version(self):
"""Test doctor --version output."""
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "--version"],
capture_output=True,
text=True,
timeout=30,
)
# Should complete successfully
assert result.returncode in [0, 1]
# Should mention doctor or version
output = result.stdout + result.stderr
assert "doctor" in output.lower() or "1.0" in output or "version" in output.lower()
def test_doctor_list_checks(self):
"""Test doctor --list-checks output."""
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "--list-checks"],
capture_output=True,
text=True,
timeout=30,
)
# Should complete successfully
assert result.returncode in [0, 1]
# Should list check IDs
output = result.stdout + result.stderr
assert "python_version" in output or "environment" in output.lower() or "check" in output.lower()
def test_doctor_fast_mode(self):
"""Test doctor in fast mode (default)."""
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor"],
capture_output=True,
text=True,
timeout=60,
)
# Should complete (exit code 0 or 1 depending on environment)
assert result.returncode in [0, 1]
# Should have some output
assert len(result.stdout) > 0 or len(result.stderr) > 0
def test_doctor_json_output(self):
"""Test doctor --json output."""
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "--json"],
capture_output=True,
text=True,
timeout=60,
)
# Should complete
assert result.returncode in [0, 1, 2]
# Output should be valid JSON
try:
data = json.loads(result.stdout)
assert "version" in data
assert "results" in data
assert "summary" in data
except json.JSONDecodeError:
# If stdout is not JSON, check stderr
pass
def test_doctor_env_subcommand(self):
"""Test doctor env subcommand."""
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "env"],
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode in [0, 1]
# Should mention environment-related checks
assert "python" in result.stdout.lower() or "environment" in result.stdout.lower() or len(result.stdout) > 0
def test_doctor_config_subcommand(self):
"""Test doctor config subcommand."""
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "config"],
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode in [0, 1]
def test_doctor_permissions_subcommand(self):
"""Test doctor permissions subcommand."""
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "permissions"],
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode in [0, 1]
def test_doctor_quiet_mode(self):
"""Test doctor --quiet mode."""
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "--quiet"],
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode in [0, 1]
# Quiet mode should have minimal output
# (but still show summary)
def test_doctor_ci_subcommand(self):
"""Test doctor ci subcommand."""
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "ci"],
capture_output=True,
text=True,
timeout=60,
)
# CI mode should return JSON
assert result.returncode in [0, 1, 2, 3]
# Try to parse as JSON
try:
data = json.loads(result.stdout)
assert "results" in data or "summary" in data
except json.JSONDecodeError:
pass # May have other output
def test_doctor_selftest_mock(self):
"""Test doctor selftest --mock subcommand."""
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "selftest", "--mock"],
capture_output=True,
text=True,
timeout=60,
)
# Should complete without making API calls
assert result.returncode in [0, 1]
def test_doctor_only_filter(self):
"""Test doctor --only filter."""
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "--only", "python_version"],
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode in [0, 1]
def test_doctor_skip_filter(self):
"""Test doctor --skip filter."""
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "--skip", "network_dns,network_https"],
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode in [0, 1]
def test_doctor_no_color(self):
"""Test doctor --no-color flag."""
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "--no-color"],
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode in [0, 1]
# Should not contain ANSI escape codes
assert "\033[" not in result.stdout
def test_doctor_strict_mode(self):
"""Test doctor --strict mode."""
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "--strict"],
capture_output=True,
text=True,
timeout=60,
)
# Strict mode may return 1 if there are warnings
assert result.returncode in [0, 1]
class TestDoctorExitCodes:
"""Tests for doctor exit codes."""
def test_exit_code_0_on_pass(self):
"""Test exit code 0 when all checks pass."""
# Run only a check that should always pass
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "--only", "python_version,os_info"],
capture_output=True,
text=True,
timeout=60,
)
# These basic checks should pass (exit 0) or may have warnings (still 0)
# Exit code 1 means failure, which shouldn't happen for these checks
assert result.returncode in [0, 1] # Allow 1 in case of environment differences
def test_exit_code_deterministic(self):
"""Test that exit codes are deterministic."""
results = []
for _ in range(3):
result = subprocess.run(
[sys.executable, "-m", "praisonai", "doctor", "--only", "python_version"],
capture_output=True,
text=True,
timeout=60,
)
results.append(result.returncode)
# All runs should have the same exit code
assert all(r == results[0] for r in results)
@@ -0,0 +1,230 @@
"""
Real API Key Smoke Tests for PraisonAI.
These tests validate end-to-end behavior using real API keys.
They are gated by environment variables and use minimal tokens.
Run with:
RUN_REAL_KEY_TESTS=1 pytest tests/integration/test_real_key_smoke.py -v
Required environment variables:
- OPENAI_API_KEY: For OpenAI provider tests
- RUN_REAL_KEY_TESTS=1: Gate to enable these tests
"""
import os
import sys
import subprocess
import pytest
import time
# Gate: Skip all tests if RUN_REAL_KEY_TESTS is not set
pytestmark = pytest.mark.skipif(
os.environ.get("RUN_REAL_KEY_TESTS") != "1",
reason="Real key tests disabled. Set RUN_REAL_KEY_TESTS=1 to enable."
)
# Paths
PRAISONAI_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
PRAISONAI_AGENTS_PATH = os.path.join(os.path.dirname(PRAISONAI_PATH), "praisonai-agents")
PYTHONPATH = f"{PRAISONAI_PATH}:{PRAISONAI_AGENTS_PATH}"
def run_python_code(code: str, timeout: int = 30) -> subprocess.CompletedProcess:
"""Run Python code in a subprocess with proper PYTHONPATH."""
env = os.environ.copy()
env['PYTHONPATH'] = PYTHONPATH
return subprocess.run(
[sys.executable, '-c', code],
capture_output=True,
text=True,
cwd=PRAISONAI_PATH,
env=env,
timeout=timeout
)
class TestSDKRealKey:
"""Test SDK functionality with real API keys."""
def test_openai_api_key_present(self):
"""Verify OPENAI_API_KEY is set."""
api_key = os.environ.get("OPENAI_API_KEY")
assert api_key is not None, "OPENAI_API_KEY not set"
assert api_key.startswith("sk-"), "OPENAI_API_KEY format invalid"
# Never print the key
print("OPENAI_API_KEY: [REDACTED]")
def test_agent_minimal_chat(self):
"""Test minimal Agent.chat() with real OpenAI API."""
code = '''
import os
from praisonaiagents import Agent
# Minimal agent with low token usage
agent = Agent(
instructions="Reply with exactly one word: OK",
llm="gpt-4o-mini",
verbose=False
)
# Minimal prompt
response = agent.chat("Say OK")
print(f"Response received: {len(response)} chars")
print("SUCCESS" if response else "FAIL")
'''
result = run_python_code(code, timeout=60)
assert result.returncode == 0, f"Agent chat failed: {result.stderr}"
assert "SUCCESS" in result.stdout, f"Agent chat did not succeed: {result.stdout}"
print(f"Agent chat test passed. Output length: {result.stdout}")
def test_agent_with_tool(self):
"""Test Agent with a simple tool."""
code = '''
import os
from praisonaiagents import Agent
def get_time():
"""Returns current time."""
import datetime
return datetime.datetime.now().strftime("%H:%M")
agent = Agent(
instructions="You have access to get_time tool. Use it and report the time briefly.",
llm="gpt-4o-mini",
tools=[get_time],
verbose=False
)
response = agent.chat("What time is it?")
print(f"Response: {len(response)} chars")
print("SUCCESS" if response and ":" in response else "PARTIAL")
'''
result = run_python_code(code, timeout=60)
assert result.returncode == 0, f"Tool test failed: {result.stderr}"
assert "SUCCESS" in result.stdout or "PARTIAL" in result.stdout, f"Tool test output: {result.stdout}"
class TestCLIRealKey:
"""Test CLI functionality with real API keys."""
def test_cli_import(self):
"""Test CLI module imports correctly."""
code = '''
import os
os.environ['LOGLEVEL'] = 'WARNING'
from praisonai.cli.main import PraisonAI
print("CLI_IMPORT_OK")
'''
result = run_python_code(code, timeout=30)
assert result.returncode == 0, f"CLI import failed: {result.stderr}"
assert "CLI_IMPORT_OK" in result.stdout
def test_cli_praisonai_class(self):
"""Test PraisonAI class instantiation."""
code = '''
import os
os.environ['LOGLEVEL'] = 'WARNING'
from praisonai.cli.main import PraisonAI
pai = PraisonAI(agent_file="agents.yaml", framework="praisonai")
print("CLASS_OK")
'''
result = run_python_code(code, timeout=30)
assert result.returncode == 0, f"CLI class failed: {result.stderr}"
assert "CLASS_OK" in result.stdout
class TestPerformanceWithRealKey:
"""Test that performance optimizations work with real usage."""
def test_cli_startup_still_fast(self):
"""Verify CLI startup is still fast after changes."""
code = '''
import time
import os
os.environ['LOGLEVEL'] = 'WARNING'
t = time.time()
from praisonai.cli.main import PraisonAI
elapsed = time.time() - t
print(f"ELAPSED:{elapsed:.3f}")
if elapsed < 1.0:
print("FAST")
else:
print("SLOW")
'''
result = run_python_code(code, timeout=30)
assert result.returncode == 0, f"Startup test failed: {result.stderr}"
assert "FAST" in result.stdout, f"CLI startup too slow: {result.stdout}"
def test_lazy_loading_preserved(self):
"""Verify heavy modules not loaded at startup."""
code = '''
import sys
import os
os.environ['LOGLEVEL'] = 'WARNING'
from praisonai.cli.main import PraisonAI
heavy = ['litellm', 'instructor', 'praisonai.auto']
loaded = [m for m in heavy if m in sys.modules]
if loaded:
print(f"LOADED:{loaded}")
else:
print("LAZY_OK")
'''
result = run_python_code(code, timeout=30)
assert result.returncode == 0, f"Lazy loading test failed: {result.stderr}"
assert "LAZY_OK" in result.stdout, f"Heavy modules loaded: {result.stdout}"
class TestMultiProvider:
"""Test multiple LLM providers if keys available."""
@pytest.mark.skipif(
not os.environ.get("ANTHROPIC_API_KEY"),
reason="ANTHROPIC_API_KEY not set"
)
def test_anthropic_agent(self):
"""Test Agent with Anthropic Claude."""
code = '''
import os
from praisonaiagents import Agent
agent = Agent(
instructions="Reply with exactly: ANTHROPIC_OK",
llm="claude-3-haiku-20240307",
verbose=False
)
response = agent.chat("Respond")
print("SUCCESS" if "ANTHROPIC_OK" in response or response else "FAIL")
'''
result = run_python_code(code, timeout=60)
# May fail if Anthropic not configured, that's OK
print(f"Anthropic test: {result.stdout}")
@pytest.mark.skipif(
not os.environ.get("GOOGLE_API_KEY"),
reason="GOOGLE_API_KEY not set"
)
def test_google_agent(self):
"""Test Agent with Google Gemini."""
code = '''
import os
from praisonaiagents import Agent
agent = Agent(
instructions="Reply with exactly: GOOGLE_OK",
llm="gemini/gemini-1.5-flash",
verbose=False
)
response = agent.chat("Respond")
print("SUCCESS" if response else "FAIL")
'''
result = run_python_code(code, timeout=60)
print(f"Google test: {result.stdout}")
if __name__ == "__main__":
# Allow running directly
if os.environ.get("RUN_REAL_KEY_TESTS") != "1":
print("Set RUN_REAL_KEY_TESTS=1 to run these tests")
sys.exit(1)
pytest.main([__file__, "-v"])
+471
View File
@@ -0,0 +1,471 @@
"""
Tests for PraisonAI Recipe Module
Tests cover:
- Core API (run, run_stream, validate, list_recipes, describe)
- Data models (RecipeResult, RecipeEvent, RecipeConfig)
- CLI commands
- Exit codes
- Error handling
"""
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
# ============================================================================
# Unit Tests for Models
# ============================================================================
class TestRecipeResult:
"""Tests for RecipeResult dataclass."""
def test_success_result(self):
"""Test successful result."""
from praisonai.recipe.models import RecipeResult, RecipeStatus
result = RecipeResult(
run_id="run-abc123",
recipe="test-recipe",
version="1.0.0",
status=RecipeStatus.SUCCESS,
output={"message": "Hello"},
metrics={"duration_sec": 1.5},
trace={"run_id": "run-abc123"},
)
assert result.ok is True
assert result.run_id == "run-abc123"
assert result.status == "success"
assert result.to_exit_code() == 0
def test_failed_result(self):
"""Test failed result."""
from praisonai.recipe.models import RecipeResult, RecipeStatus
result = RecipeResult(
run_id="run-abc123",
recipe="test-recipe",
version="1.0.0",
status=RecipeStatus.FAILED,
error="Something went wrong",
)
assert result.ok is False
assert result.error == "Something went wrong"
assert result.to_exit_code() == 3 # RUNTIME_ERROR
def test_dry_run_result(self):
"""Test dry run result."""
from praisonai.recipe.models import RecipeResult, RecipeStatus
result = RecipeResult(
run_id="run-abc123",
recipe="test-recipe",
version="1.0.0",
status=RecipeStatus.DRY_RUN,
)
assert result.ok is True
assert result.to_exit_code() == 0
def test_to_dict(self):
"""Test to_dict serialization."""
from praisonai.recipe.models import RecipeResult, RecipeStatus
result = RecipeResult(
run_id="run-abc123",
recipe="test-recipe",
version="1.0.0",
status=RecipeStatus.SUCCESS,
output={"key": "value"},
)
d = result.to_dict()
assert d["ok"] is True
assert d["run_id"] == "run-abc123"
assert d["recipe"] == "test-recipe"
assert d["output"] == {"key": "value"}
class TestRecipeEvent:
"""Tests for RecipeEvent dataclass."""
def test_event_creation(self):
"""Test event creation."""
from praisonai.recipe.models import RecipeEvent
event = RecipeEvent(
event_type="started",
data={"run_id": "run-abc123"},
)
assert event.event_type == "started"
assert event.data["run_id"] == "run-abc123"
assert event.timestamp # Should be auto-generated
def test_to_sse(self):
"""Test SSE format conversion."""
from praisonai.recipe.models import RecipeEvent
event = RecipeEvent(
event_type="progress",
data={"step": "loading"},
)
sse = event.to_sse()
assert "event: progress" in sse
assert "data:" in sse
class TestExitCode:
"""Tests for exit codes."""
def test_exit_codes(self):
"""Test exit code values."""
from praisonai.recipe.models import ExitCode
assert ExitCode.SUCCESS == 0
assert ExitCode.GENERAL_ERROR == 1
assert ExitCode.VALIDATION_ERROR == 2
assert ExitCode.RUNTIME_ERROR == 3
assert ExitCode.POLICY_DENIED == 4
assert ExitCode.TIMEOUT == 5
assert ExitCode.MISSING_DEPS == 6
assert ExitCode.NOT_FOUND == 7
class TestRecipeConfig:
"""Tests for RecipeConfig dataclass."""
def test_config_creation(self):
"""Test config creation."""
from praisonai.recipe.models import RecipeConfig
config = RecipeConfig(
name="test-recipe",
version="1.0.0",
description="Test recipe",
requires={"packages": ["openai"], "env": ["OPENAI_API_KEY"]},
tools={"allow": ["web.search"], "deny": ["shell.exec"]},
)
assert config.name == "test-recipe"
assert config.get_required_packages() == ["openai"]
assert config.get_required_env() == ["OPENAI_API_KEY"]
assert config.get_allowed_tools() == ["web.search"]
assert config.get_denied_tools() == ["shell.exec"]
# ============================================================================
# Unit Tests for Core API
# ============================================================================
class TestCoreAPI:
"""Tests for core recipe API."""
def test_list_recipes(self):
"""Test listing recipes."""
from praisonai.recipe import list_recipes
recipes = list_recipes()
assert isinstance(recipes, list)
def test_validate_nonexistent(self):
"""Test validating non-existent recipe."""
from praisonai.recipe import validate
result = validate("nonexistent-recipe-xyz")
assert result.valid is False
assert "not found" in result.errors[0].lower() or len(result.errors) > 0
def test_describe_nonexistent(self):
"""Test describing non-existent recipe."""
from praisonai.recipe import describe
result = describe("nonexistent-recipe-xyz")
assert result is None
def test_run_dry_run(self):
"""Test dry run mode."""
from praisonai.recipe import run, list_recipes
# Use a recipe that exists
recipes = list_recipes()
if recipes:
result = run(
recipes[0].name,
input={},
options={"dry_run": True},
)
assert result.status == "dry_run"
assert result.ok is True
def test_run_nonexistent(self):
"""Test running non-existent recipe."""
from praisonai.recipe import run
result = run("nonexistent-recipe-xyz", input={})
assert result.ok is False
assert "not found" in result.error.lower()
# ============================================================================
# CLI Tests
# ============================================================================
class TestCLI:
"""Tests for CLI commands."""
@pytest.fixture
def cli_runner(self):
"""Get CLI runner path."""
return [sys.executable, "-m", "praisonai.cli.main"]
def test_recipe_help(self, cli_runner):
"""Test recipe help command."""
# Test via direct import instead of subprocess for reliability
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
exit_code = handler.handle(["help"])
assert exit_code == 0
def test_recipe_list(self, cli_runner):
"""Test recipe list command."""
# Test via direct import for reliability
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
exit_code = handler.handle(["list"])
assert exit_code == 0
def test_recipe_validate_nonexistent(self, cli_runner):
"""Test validating non-existent recipe."""
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
exit_code = handler.handle(["validate", "nonexistent-xyz"])
# Should return validation error or not found exit code
assert exit_code in [2, 7] # VALIDATION_ERROR or NOT_FOUND
def test_recipe_init(self, cli_runner):
"""Test recipe init command."""
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
with tempfile.TemporaryDirectory() as tmpdir:
exit_code = handler.handle(["init", "test-recipe", "-o", tmpdir])
assert exit_code == 0
# Check files were created
recipe_dir = Path(tmpdir) / "test-recipe"
assert (recipe_dir / "TEMPLATE.yaml").exists()
assert (recipe_dir / "workflow.yaml").exists()
assert (recipe_dir / "README.md").exists()
def test_recipe_dry_run(self, cli_runner):
"""Test recipe dry run."""
result = subprocess.run(
cli_runner + ["recipe", "list", "--json"],
capture_output=True,
text=True,
cwd=str(Path(__file__).parent.parent),
)
# Get first recipe name
try:
recipes = json.loads(result.stdout)
if recipes:
recipe_name = recipes[0]["name"]
# Run dry run
result = subprocess.run(
cli_runner + ["recipe", "run", recipe_name, "--dry-run", "--json"],
capture_output=True,
text=True,
cwd=str(Path(__file__).parent.parent),
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["status"] == "dry_run"
except (json.JSONDecodeError, KeyError, IndexError):
pass # Skip if no recipes available
# ============================================================================
# Integration Tests
# ============================================================================
class TestIntegration:
"""Integration tests with real API calls."""
@pytest.fixture
def has_openai_key(self):
"""Check if OpenAI API key is available."""
return bool(os.environ.get("OPENAI_API_KEY"))
def test_run_with_api(self, has_openai_key):
"""Test running a recipe with real API."""
if not has_openai_key:
pytest.skip("OPENAI_API_KEY not set")
from praisonai.recipe import run, list_recipes as list_all_recipes
recipes = list_all_recipes()
if not recipes:
pytest.skip("No recipes available")
# Run first available recipe
result = run(
recipes[0].name,
input={},
options={"timeout_sec": 60},
)
# Should complete (success or fail, but not crash)
assert result.run_id.startswith("run-")
assert result.status in ["success", "failed", "missing_deps"]
# ============================================================================
# Exception Tests
# ============================================================================
class TestExceptions:
"""Tests for custom exceptions."""
def test_recipe_error(self):
"""Test RecipeError."""
from praisonai.recipe.exceptions import RecipeError
err = RecipeError("Test error", recipe="test", details={"key": "value"})
assert str(err) == "Test error"
assert err.recipe == "test"
assert err.details == {"key": "value"}
def test_recipe_not_found_error(self):
"""Test RecipeNotFoundError."""
from praisonai.recipe.exceptions import RecipeNotFoundError
err = RecipeNotFoundError("Recipe not found", recipe="test")
assert "Recipe not found" in str(err)
def test_recipe_dependency_error(self):
"""Test RecipeDependencyError."""
from praisonai.recipe.exceptions import RecipeDependencyError
err = RecipeDependencyError(
"Missing deps",
recipe="test",
missing=["openai", "pandas"],
)
assert err.missing == ["openai", "pandas"]
def test_recipe_policy_error(self):
"""Test RecipePolicyError."""
from praisonai.recipe.exceptions import RecipePolicyError
err = RecipePolicyError(
"Policy denied",
recipe="test",
policy="no_shell",
)
assert err.policy == "no_shell"
# ============================================================================
# Endpoints CLI Tests
# ============================================================================
class TestEndpointsCLI:
"""Tests for endpoints CLI commands."""
def test_endpoints_help(self):
"""Test endpoints help command."""
from praisonai.cli.features.endpoints import EndpointsHandler
handler = EndpointsHandler()
exit_code = handler.handle(["help"])
assert exit_code == 0
def test_endpoints_list_no_server(self):
"""Test endpoints list when no server running."""
from praisonai.cli.features.endpoints import EndpointsHandler
handler = EndpointsHandler()
exit_code = handler.handle(["list", "--url", "http://localhost:9999"])
assert exit_code == 8 # CONNECTION_ERROR
def test_endpoints_health_no_server(self):
"""Test endpoints health when no server running."""
from praisonai.cli.features.endpoints import EndpointsHandler
handler = EndpointsHandler()
exit_code = handler.handle(["health", "--url", "http://localhost:9999"])
assert exit_code == 8 # CONNECTION_ERROR
def test_endpoints_invoke_missing_recipe(self):
"""Test endpoints invoke without recipe name."""
from praisonai.cli.features.endpoints import EndpointsHandler
handler = EndpointsHandler()
exit_code = handler.handle(["invoke"])
assert exit_code == 2 # VALIDATION_ERROR
def test_endpoints_describe_missing_recipe(self):
"""Test endpoints describe without recipe name."""
from praisonai.cli.features.endpoints import EndpointsHandler
handler = EndpointsHandler()
exit_code = handler.handle(["describe"])
assert exit_code == 2 # VALIDATION_ERROR
class TestServeConfig:
"""Tests for serve configuration."""
def test_load_config_empty(self):
"""Test loading empty config."""
from praisonai.recipe.serve import load_config
config = load_config(None)
assert config == {}
def test_auth_middleware_creation(self):
"""Test auth middleware creation."""
from praisonai.recipe.serve import create_auth_middleware
# No auth
middleware = create_auth_middleware("none")
assert middleware is None
# API key auth
middleware = create_auth_middleware("api-key", "test-key")
assert middleware is not None
class TestServeHostSafety:
"""Tests for serve host binding safety."""
def test_refuse_public_without_auth(self):
"""Test that 0.0.0.0 binding without auth is refused."""
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
exit_code = handler.handle(["serve", "--host", "0.0.0.0", "--port", "9999"])
assert exit_code == 4 # POLICY_DENIED
def test_allow_localhost_without_auth(self):
"""Test that localhost binding without auth is allowed."""
# Verify the host check logic
spec = {"host": "127.0.0.1", "auth": "none"}
host = spec["host"]
auth = spec["auth"]
# This should NOT trigger the policy denial
should_deny = host != "127.0.0.1" and host != "localhost" and auth == "none"
assert should_deny is False
# ============================================================================
# Run tests
# ============================================================================
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1 @@
"""Unit tests for the doctor CLI module."""
@@ -0,0 +1,309 @@
"""Unit tests for doctor checks."""
import os
from unittest.mock import patch, MagicMock
from praisonai.cli.features.doctor.models import (
CheckCategory,
CheckStatus,
DoctorConfig,
)
from praisonai.cli.features.doctor.registry import CheckRegistry
class TestEnvChecks:
"""Tests for environment checks."""
def setup_method(self):
"""Reset registry before each test."""
CheckRegistry.reset()
# Import checks to register them
from praisonai.cli.features.doctor.checks import env_checks
def test_python_version_check(self):
"""Test Python version check."""
from praisonai.cli.features.doctor.checks.env_checks import check_python_version
config = DoctorConfig()
result = check_python_version(config)
# Should pass on Python 3.9+
assert result.status == CheckStatus.PASS
assert "Python" in result.message
def test_openai_api_key_check_present(self):
"""Test OpenAI API key check when present."""
from praisonai.cli.features.doctor.checks.env_checks import check_openai_api_key
with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test1234567890abcdefghij"}):
config = DoctorConfig()
result = check_openai_api_key(config)
assert result.status == CheckStatus.PASS
assert "configured" in result.message.lower()
def test_openai_api_key_check_missing(self):
"""Test OpenAI API key check when missing."""
from praisonai.cli.features.doctor.checks.env_checks import check_openai_api_key
env = {k: v for k, v in os.environ.items()
if k not in ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY"]}
with patch.dict(os.environ, env, clear=True):
config = DoctorConfig()
result = check_openai_api_key(config)
# Should fail or warn when no API keys are set
assert result.status in [CheckStatus.FAIL, CheckStatus.WARN]
def test_os_info_check(self):
"""Test OS info check."""
from praisonai.cli.features.doctor.checks.env_checks import check_os_info
config = DoctorConfig()
result = check_os_info(config)
assert result.status == CheckStatus.PASS
assert result.metadata.get("os_name") is not None
def test_virtual_env_check(self):
"""Test virtual environment check."""
from praisonai.cli.features.doctor.checks.env_checks import check_virtual_env
config = DoctorConfig()
result = check_virtual_env(config)
# Should pass or warn depending on environment
assert result.status in [CheckStatus.PASS, CheckStatus.WARN]
class TestConfigChecks:
"""Tests for configuration checks."""
def setup_method(self):
"""Reset registry before each test."""
CheckRegistry.reset()
from praisonai.cli.features.doctor.checks import config_checks
def test_agents_yaml_not_found(self):
"""Test agents.yaml check when file doesn't exist."""
from praisonai.cli.features.doctor.checks.config_checks import check_agents_yaml_exists
with patch('pathlib.Path.cwd') as mock_cwd:
mock_cwd.return_value = MagicMock()
mock_cwd.return_value.__truediv__ = lambda self, x: MagicMock(exists=lambda: False)
config = DoctorConfig()
result = check_agents_yaml_exists(config)
# Should skip when not found (optional)
assert result.status == CheckStatus.SKIP
def test_praison_config_dir_check(self):
"""Test .praison config directory check."""
from praisonai.cli.features.doctor.checks.config_checks import check_praison_config_dir
config = DoctorConfig()
result = check_praison_config_dir(config)
# Should pass or skip
assert result.status in [CheckStatus.PASS, CheckStatus.SKIP]
class TestPermissionsChecks:
"""Tests for permissions checks."""
def setup_method(self):
"""Reset registry before each test."""
CheckRegistry.reset()
from praisonai.cli.features.doctor.checks import permissions_checks
def test_temp_dir_check(self):
"""Test temp directory permissions check."""
from praisonai.cli.features.doctor.checks.permissions_checks import check_permissions_temp_dir
config = DoctorConfig()
result = check_permissions_temp_dir(config)
# Should pass on most systems
assert result.status == CheckStatus.PASS
def test_cwd_check(self):
"""Test current working directory check."""
from praisonai.cli.features.doctor.checks.permissions_checks import check_permissions_cwd
config = DoctorConfig()
result = check_permissions_cwd(config)
# Should pass or warn
assert result.status in [CheckStatus.PASS, CheckStatus.WARN]
class TestNetworkChecks:
"""Tests for network checks."""
def setup_method(self):
"""Reset registry before each test."""
CheckRegistry.reset()
from praisonai.cli.features.doctor.checks import network_checks
def test_proxy_check_no_proxy(self):
"""Test proxy check when no proxy is configured."""
from praisonai.cli.features.doctor.checks.network_checks import check_network_proxy
env = {k: v for k, v in os.environ.items()
if k.upper() not in ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"]}
with patch.dict(os.environ, env, clear=True):
config = DoctorConfig()
result = check_network_proxy(config)
assert result.status == CheckStatus.SKIP
def test_ssl_check_default(self):
"""Test SSL configuration check with defaults."""
from praisonai.cli.features.doctor.checks.network_checks import check_network_ssl
config = DoctorConfig()
result = check_network_ssl(config)
# Should pass with default SSL config
assert result.status in [CheckStatus.PASS, CheckStatus.WARN]
def test_openai_base_url_check(self):
"""Test OpenAI base URL check."""
from praisonai.cli.features.doctor.checks.network_checks import check_network_openai_base_url
config = DoctorConfig()
result = check_network_openai_base_url(config)
# Should pass (either default or custom)
assert result.status == CheckStatus.PASS
class TestPerformanceChecks:
"""Tests for performance checks."""
def setup_method(self):
"""Reset registry before each test."""
CheckRegistry.reset()
from praisonai.cli.features.doctor.checks import performance_checks
def test_loaded_modules_check(self):
"""Test loaded modules count check."""
from praisonai.cli.features.doctor.checks.performance_checks import check_performance_loaded_modules
config = DoctorConfig()
result = check_performance_loaded_modules(config)
assert result.status == CheckStatus.PASS
assert result.metadata.get("total") is not None
assert result.metadata["total"] > 0
class TestDbChecks:
"""Tests for database checks."""
def setup_method(self):
"""Reset registry before each test."""
CheckRegistry.reset()
from praisonai.cli.features.doctor.checks import db_checks
def test_db_config_no_dsn(self):
"""Test database config check when no DSN is set."""
from praisonai.cli.features.doctor.checks.db_checks import check_db_config
env = {k: v for k, v in os.environ.items()
if k not in ["DATABASE_URL", "PRAISONAI_DATABASE_URL"]}
with patch.dict(os.environ, env, clear=True):
config = DoctorConfig()
result = check_db_config(config)
assert result.status == CheckStatus.SKIP
def test_sqlite_driver_check(self):
"""Test SQLite driver check."""
from praisonai.cli.features.doctor.checks.db_checks import check_db_driver_sqlite
config = DoctorConfig()
result = check_db_driver_sqlite(config)
# SQLite is built-in, should always pass
assert result.status == CheckStatus.PASS
class TestMcpChecks:
"""Tests for MCP checks."""
def setup_method(self):
"""Reset registry before each test."""
CheckRegistry.reset()
from praisonai.cli.features.doctor.checks import mcp_checks
def test_mcp_config_not_found(self):
"""Test MCP config check when no config exists."""
from praisonai.cli.features.doctor.checks.mcp_checks import check_mcp_config
config = DoctorConfig()
result = check_mcp_config(config)
# Should skip when no config found
assert result.status in [CheckStatus.PASS, CheckStatus.SKIP]
class TestSkillsChecks:
"""Tests for skills checks."""
def setup_method(self):
"""Reset registry before each test."""
CheckRegistry.reset()
from praisonai.cli.features.doctor.checks import skills_checks
def test_skills_dirs_check(self):
"""Test skills directories check."""
from praisonai.cli.features.doctor.checks.skills_checks import check_skills_dirs
config = DoctorConfig()
result = check_skills_dirs(config)
# Should pass or skip
assert result.status in [CheckStatus.PASS, CheckStatus.SKIP]
class TestMemoryChecks:
"""Tests for memory checks."""
def setup_method(self):
"""Reset registry before each test."""
CheckRegistry.reset()
from praisonai.cli.features.doctor.checks import memory_checks
def test_memory_dirs_check(self):
"""Test memory directories check."""
from praisonai.cli.features.doctor.checks.memory_checks import check_memory_dirs
config = DoctorConfig()
result = check_memory_dirs(config)
# Should pass or skip
assert result.status in [CheckStatus.PASS, CheckStatus.SKIP]
class TestObsChecks:
"""Tests for observability checks."""
def setup_method(self):
"""Reset registry before each test."""
CheckRegistry.reset()
from praisonai.cli.features.doctor.checks import obs_checks
def test_obs_config_check(self):
"""Test observability config check."""
from praisonai.cli.features.doctor.checks.obs_checks import check_obs_config
config = DoctorConfig()
result = check_obs_config(config)
# Should pass or skip
assert result.status in [CheckStatus.PASS, CheckStatus.SKIP]
@@ -0,0 +1,239 @@
"""Unit tests for doctor engine."""
from praisonai.cli.features.doctor.models import (
CheckCategory,
CheckResult,
CheckStatus,
DoctorConfig,
)
from praisonai.cli.features.doctor.registry import CheckRegistry, register_check
from praisonai.cli.features.doctor.engine import DoctorEngine, run_doctor
class TestDoctorEngine:
"""Tests for DoctorEngine class."""
def setup_method(self):
"""Reset registry before each test."""
CheckRegistry.reset()
def teardown_method(self):
"""Reset registry after each test."""
CheckRegistry.reset()
def test_get_environment_summary(self):
"""Test getting environment summary."""
engine = DoctorEngine()
summary = engine.get_environment_summary()
assert summary.python_version != ""
assert summary.python_executable != ""
assert summary.os_name != ""
def test_run_check_success(self):
"""Test running a successful check."""
registry = CheckRegistry()
def passing_check(config):
return CheckResult(
id="passing",
title="Passing Check",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="OK",
)
registry.register(
id="passing_check",
title="Passing Check",
description="A passing check",
category=CheckCategory.ENVIRONMENT,
implementation=passing_check,
)
engine = DoctorEngine()
result = engine.run_check("passing_check", passing_check)
assert result.status == CheckStatus.PASS
assert result.duration_ms >= 0
def test_run_check_timeout(self):
"""Test check timeout handling."""
import time
def slow_check(config):
time.sleep(5)
return CheckResult(
id="slow",
title="Slow Check",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="OK",
)
registry = CheckRegistry()
registry.register(
id="slow_check",
title="Slow Check",
description="A slow check",
category=CheckCategory.ENVIRONMENT,
implementation=slow_check,
)
config = DoctorConfig(timeout=0.1)
engine = DoctorEngine(config)
result = engine.run_check("slow_check", slow_check, timeout=0.1)
assert result.status == CheckStatus.ERROR
assert "timed out" in result.message.lower()
def test_run_check_exception(self):
"""Test check exception handling."""
def failing_check(config):
raise ValueError("Test error")
registry = CheckRegistry()
registry.register(
id="failing_check",
title="Failing Check",
description="A failing check",
category=CheckCategory.ENVIRONMENT,
implementation=failing_check,
)
engine = DoctorEngine()
result = engine.run_check("failing_check", failing_check)
assert result.status == CheckStatus.ERROR
assert "ValueError" in result.message
def test_run_checks_filters_by_category(self):
"""Test running checks filtered by category."""
# Import all checks to populate registry
from praisonai.cli.features.doctor.checks import register_all_checks
register_all_checks()
engine = DoctorEngine()
# Run only environment checks
results = engine.run_checks(categories=[CheckCategory.ENVIRONMENT])
# All results should be environment category
for result in results:
assert result.category == CheckCategory.ENVIRONMENT
# Should have at least one result
assert len(results) >= 1
def test_generate_report(self):
"""Test generating a report."""
# Import all checks to populate registry
from praisonai.cli.features.doctor.checks import register_all_checks
register_all_checks()
engine = DoctorEngine()
engine.run_checks()
report = engine.generate_report()
assert report.version == "1.0.0"
assert len(report.results) >= 1
assert report.summary.total >= 1
def test_run_returns_report(self):
"""Test that run() returns a complete report."""
# Import all checks to populate registry
from praisonai.cli.features.doctor.checks import register_all_checks
register_all_checks()
engine = DoctorEngine()
report = engine.run()
assert report is not None
assert report.duration_ms >= 0
assert len(report.results) >= 1
def test_dependency_skip_on_failure(self):
"""Test that dependent checks are skipped when dependency fails."""
registry = CheckRegistry()
def failing_check(config):
return CheckResult(
id="failing",
title="Failing",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.FAIL,
message="Failed",
)
def dependent_check(config):
return CheckResult(
id="dependent",
title="Dependent",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="OK",
)
registry.register(
id="failing_check",
title="Failing Check",
description="Fails",
category=CheckCategory.ENVIRONMENT,
implementation=failing_check,
)
registry.register(
id="dependent_check",
title="Dependent Check",
description="Depends on failing",
category=CheckCategory.ENVIRONMENT,
implementation=dependent_check,
dependencies=["failing_check"],
)
config = DoctorConfig(only=["failing_check", "dependent_check"])
engine = DoctorEngine(config)
results = engine.run_checks()
# Find the dependent check result
dependent_results = [r for r in results if r.id == "dependent_check"]
if dependent_results:
assert dependent_results[0].status == CheckStatus.SKIP
# If not found, the dependency system may have filtered it out entirely
class TestRunDoctor:
"""Tests for run_doctor convenience function."""
def setup_method(self):
"""Reset registry before each test."""
CheckRegistry.reset()
def teardown_method(self):
"""Reset registry after each test."""
CheckRegistry.reset()
def test_run_doctor_basic(self):
"""Test basic run_doctor call."""
registry = CheckRegistry()
def test_check(config):
return CheckResult(
id="test",
title="Test",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="OK",
)
registry.register(
id="test_check",
title="Test Check",
description="Test",
category=CheckCategory.ENVIRONMENT,
implementation=test_check,
)
report = run_doctor()
assert report is not None
assert len(report.results) >= 1 # May have more if other checks registered
@@ -0,0 +1,250 @@
"""Unit tests for doctor formatters."""
import json
from praisonai.cli.features.doctor.models import (
CheckCategory,
CheckResult,
CheckStatus,
DoctorReport,
)
from praisonai.cli.features.doctor.formatters import (
redact_secrets,
redact_dict,
TextFormatter,
JsonFormatter,
get_formatter,
)
class TestRedactSecrets:
"""Tests for secret redaction."""
def test_redact_openai_key(self):
"""Test redacting OpenAI API key."""
text = "Key: sk-1234567890abcdefghijklmnop"
result = redact_secrets(text)
assert "sk-1234567890" not in result
assert "REDACTED" in result
def test_redact_anthropic_key(self):
"""Test redacting Anthropic API key."""
text = "Key: sk-ant-api03-abcdefghijklmnopqrstuvwxyz"
result = redact_secrets(text)
assert "sk-ant-api03" not in result
assert "REDACTED" in result
def test_redact_with_prefix_suffix(self):
"""Test redacting with prefix/suffix shown."""
text = "Key: sk-1234567890abcdefghijklmnop"
result = redact_secrets(text, show_prefix_suffix=True)
# Should show first 4 and last 4 chars
assert "..." in result
def test_no_redaction_for_normal_text(self):
"""Test that normal text is not redacted."""
text = "Hello world, this is a test"
result = redact_secrets(text)
assert result == text
class TestRedactDict:
"""Tests for dictionary redaction."""
def test_redact_api_key_field(self):
"""Test redacting API key fields."""
data = {"api_key": "sk-1234567890abcdefghijklmnop"}
result = redact_dict(data)
assert "REDACTED" in result["api_key"]
def test_redact_nested_dict(self):
"""Test redacting nested dictionaries."""
data = {
"config": {
"secret_token": "mysecrettoken12345678"
}
}
result = redact_dict(data)
assert "REDACTED" in result["config"]["secret_token"]
def test_preserve_non_secret_fields(self):
"""Test that non-secret fields are preserved."""
data = {"name": "test", "value": 123}
result = redact_dict(data)
assert result["name"] == "test"
assert result["value"] == 123
class TestTextFormatter:
"""Tests for TextFormatter."""
def test_format_pass_result(self):
"""Test formatting a passing result."""
formatter = TextFormatter(no_color=True)
result = CheckResult(
id="test",
title="Test Check",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="All good",
)
output = formatter.format_result(result)
assert "Test Check" in output
assert "All good" in output
def test_format_fail_result_with_remediation(self):
"""Test formatting a failing result with remediation."""
formatter = TextFormatter(no_color=True)
result = CheckResult(
id="test",
title="Test Check",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.FAIL,
message="Something wrong",
remediation="Fix it by doing X",
)
output = formatter.format_result(result)
assert "Test Check" in output
assert "Something wrong" in output
assert "Fix it by doing X" in output
def test_format_report(self):
"""Test formatting a complete report."""
formatter = TextFormatter(no_color=True)
report = DoctorReport(
results=[
CheckResult(
id="test1",
title="Test 1",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="OK",
),
CheckResult(
id="test2",
title="Test 2",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.FAIL,
message="Failed",
),
]
)
report.calculate_summary()
output = formatter.format_report(report)
assert "Test 1" in output
assert "Test 2" in output
assert "1 passed" in output
assert "1 failed" in output
def test_quiet_mode(self):
"""Test quiet mode output."""
formatter = TextFormatter(no_color=True, quiet=True)
report = DoctorReport(
results=[
CheckResult(
id="test",
title="Test",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="OK",
),
]
)
report.calculate_summary()
output = formatter.format_report(report)
# Should not have header in quiet mode
assert "PraisonAI Doctor" not in output
class TestJsonFormatter:
"""Tests for JsonFormatter."""
def test_format_result_json(self):
"""Test formatting a result as JSON."""
formatter = JsonFormatter()
result = CheckResult(
id="test",
title="Test Check",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="OK",
)
output = formatter.format_result(result)
data = json.loads(output)
assert data["id"] == "test"
assert data["status"] == "pass"
def test_format_report_json(self):
"""Test formatting a report as JSON."""
formatter = JsonFormatter()
report = DoctorReport(
results=[
CheckResult(
id="test",
title="Test",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="OK",
),
]
)
report.calculate_summary()
output = formatter.format_report(report)
data = json.loads(output)
assert "version" in data
assert "results" in data
assert "summary" in data
assert len(data["results"]) == 1
def test_json_deterministic_ordering(self):
"""Test that JSON output has deterministic ordering."""
formatter = JsonFormatter()
report = DoctorReport(
results=[
CheckResult(
id="b_test",
title="B Test",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="OK",
),
CheckResult(
id="a_test",
title="A Test",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="OK",
),
]
)
report.calculate_summary()
output1 = formatter.format_report(report)
output2 = formatter.format_report(report)
# Should be identical (deterministic)
assert output1 == output2
class TestGetFormatter:
"""Tests for get_formatter function."""
def test_get_text_formatter(self):
"""Test getting text formatter."""
formatter = get_formatter("text")
assert isinstance(formatter, TextFormatter)
def test_get_json_formatter(self):
"""Test getting JSON formatter."""
formatter = get_formatter("json")
assert isinstance(formatter, JsonFormatter)
def test_formatter_options(self):
"""Test formatter options are passed through."""
formatter = get_formatter("text", no_color=True, quiet=True)
assert formatter.no_color is True
assert formatter.quiet is True
@@ -0,0 +1,238 @@
"""Unit tests for doctor models."""
import pytest
from praisonai.cli.features.doctor.models import (
CheckStatus,
CheckCategory,
CheckSeverity,
CheckResult,
CheckDefinition,
DoctorConfig,
DoctorReport,
ReportSummary,
EnvironmentSummary,
)
class TestCheckStatus:
"""Tests for CheckStatus enum."""
def test_status_values(self):
"""Test all status values exist."""
assert CheckStatus.PASS.value == "pass"
assert CheckStatus.WARN.value == "warn"
assert CheckStatus.FAIL.value == "fail"
assert CheckStatus.SKIP.value == "skip"
assert CheckStatus.ERROR.value == "error"
class TestCheckCategory:
"""Tests for CheckCategory enum."""
def test_category_values(self):
"""Test all category values exist."""
assert CheckCategory.ENVIRONMENT.value == "environment"
assert CheckCategory.CONFIG.value == "config"
assert CheckCategory.TOOLS.value == "tools"
assert CheckCategory.DATABASE.value == "database"
assert CheckCategory.MCP.value == "mcp"
assert CheckCategory.OBSERVABILITY.value == "observability"
assert CheckCategory.SKILLS.value == "skills"
assert CheckCategory.MEMORY.value == "memory"
assert CheckCategory.PERMISSIONS.value == "permissions"
assert CheckCategory.NETWORK.value == "network"
assert CheckCategory.PERFORMANCE.value == "performance"
assert CheckCategory.SELFTEST.value == "selftest"
class TestCheckResult:
"""Tests for CheckResult dataclass."""
def test_create_result(self):
"""Test creating a check result."""
result = CheckResult(
id="test_check",
title="Test Check",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="Test passed",
)
assert result.id == "test_check"
assert result.title == "Test Check"
assert result.status == CheckStatus.PASS
assert result.passed is True
def test_result_to_dict(self):
"""Test converting result to dictionary."""
result = CheckResult(
id="test_check",
title="Test Check",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="Test passed",
duration_ms=100.5,
)
d = result.to_dict()
assert d["id"] == "test_check"
assert d["status"] == "pass"
assert d["category"] == "environment"
assert d["duration_ms"] == 100.5
def test_result_passed_property(self):
"""Test passed property for different statuses."""
pass_result = CheckResult(
id="test", title="Test", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS, message="OK"
)
assert pass_result.passed is True
skip_result = CheckResult(
id="test", title="Test", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.SKIP, message="Skipped"
)
assert skip_result.passed is True
fail_result = CheckResult(
id="test", title="Test", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.FAIL, message="Failed"
)
assert fail_result.passed is False
def test_result_is_failure(self):
"""Test is_failure property."""
fail_result = CheckResult(
id="test", title="Test", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.FAIL, message="Failed"
)
assert fail_result.is_failure is True
error_result = CheckResult(
id="test", title="Test", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.ERROR, message="Error"
)
assert error_result.is_failure is True
pass_result = CheckResult(
id="test", title="Test", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS, message="OK"
)
assert pass_result.is_failure is False
class TestDoctorConfig:
"""Tests for DoctorConfig dataclass."""
def test_default_config(self):
"""Test default configuration values."""
config = DoctorConfig()
assert config.deep is False
assert config.timeout == 10.0
assert config.strict is False
assert config.quiet is False
assert config.format == "text"
assert config.mock is True
assert config.live is False
def test_custom_config(self):
"""Test custom configuration."""
config = DoctorConfig(
deep=True,
timeout=30.0,
strict=True,
format="json",
)
assert config.deep is True
assert config.timeout == 30.0
assert config.strict is True
assert config.format == "json"
class TestDoctorReport:
"""Tests for DoctorReport dataclass."""
def test_create_report(self):
"""Test creating a report."""
report = DoctorReport()
assert report.version == "1.0.0"
assert report.results == []
assert report.exit_code == 0
def test_calculate_summary(self):
"""Test calculating summary from results."""
results = [
CheckResult(id="1", title="T1", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS, message="OK"),
CheckResult(id="2", title="T2", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS, message="OK"),
CheckResult(id="3", title="T3", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN, message="Warning"),
CheckResult(id="4", title="T4", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.FAIL, message="Failed"),
CheckResult(id="5", title="T5", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.SKIP, message="Skipped"),
]
report = DoctorReport(results=results)
report.calculate_summary()
assert report.summary.total == 5
assert report.summary.passed == 2
assert report.summary.warnings == 1
assert report.summary.failed == 1
assert report.summary.skipped == 1
def test_calculate_exit_code(self):
"""Test exit code calculation."""
# All pass
report = DoctorReport(results=[
CheckResult(id="1", title="T1", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS, message="OK"),
])
report.calculate_summary()
assert report.calculate_exit_code() == 0
# Has failure
report = DoctorReport(results=[
CheckResult(id="1", title="T1", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.FAIL, message="Failed"),
])
report.calculate_summary()
assert report.calculate_exit_code() == 1
# Has error
report = DoctorReport(results=[
CheckResult(id="1", title="T1", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.ERROR, message="Error"),
])
report.calculate_summary()
assert report.calculate_exit_code() == 2
def test_calculate_exit_code_strict(self):
"""Test exit code with strict mode."""
report = DoctorReport(results=[
CheckResult(id="1", title="T1", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.WARN, message="Warning"),
])
report.calculate_summary()
# Without strict
assert report.calculate_exit_code(strict=False) == 0
# With strict
assert report.calculate_exit_code(strict=True) == 1
def test_report_to_dict(self):
"""Test converting report to dictionary."""
report = DoctorReport(
results=[
CheckResult(id="1", title="T1", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS, message="OK"),
]
)
report.calculate_summary()
d = report.to_dict()
assert "version" in d
assert "timestamp" in d
assert "results" in d
assert "summary" in d
assert len(d["results"]) == 1
@@ -0,0 +1,219 @@
"""Unit tests for doctor registry."""
from praisonai.cli.features.doctor.models import (
CheckCategory,
CheckSeverity,
CheckResult,
CheckStatus,
DoctorConfig,
)
from praisonai.cli.features.doctor.registry import (
CheckRegistry,
register_check,
get_registry,
)
class TestCheckRegistry:
"""Tests for CheckRegistry class."""
def setup_method(self):
"""Reset registry before each test."""
CheckRegistry.reset()
def test_singleton_pattern(self):
"""Test registry is a singleton."""
reg1 = CheckRegistry()
reg2 = CheckRegistry()
assert reg1 is reg2
def test_register_check(self):
"""Test registering a check."""
registry = CheckRegistry()
def dummy_check(config):
return CheckResult(
id="dummy",
title="Dummy",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="OK",
)
registry.register(
id="dummy_check",
title="Dummy Check",
description="A dummy check",
category=CheckCategory.ENVIRONMENT,
implementation=dummy_check,
)
assert "dummy_check" in registry.get_check_ids()
assert registry.get_check("dummy_check") is not None
assert registry.get_implementation("dummy_check") is not None
def test_get_checks_by_category(self):
"""Test getting checks by category."""
registry = CheckRegistry()
def env_check(config):
return CheckResult(
id="env", title="Env", category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS, message="OK"
)
def config_check(config):
return CheckResult(
id="config", title="Config", category=CheckCategory.CONFIG,
status=CheckStatus.PASS, message="OK"
)
registry.register(
id="env_check", title="Env Check", description="Env",
category=CheckCategory.ENVIRONMENT, implementation=env_check
)
registry.register(
id="config_check", title="Config Check", description="Config",
category=CheckCategory.CONFIG, implementation=config_check
)
env_checks = registry.get_checks_by_category(CheckCategory.ENVIRONMENT)
assert len(env_checks) == 1
assert env_checks[0].id == "env_check"
def test_filter_checks_only(self):
"""Test filtering checks with only parameter."""
registry = CheckRegistry()
for i in range(3):
registry.register(
id=f"check_{i}",
title=f"Check {i}",
description=f"Check {i}",
category=CheckCategory.ENVIRONMENT,
implementation=lambda c: None,
)
filtered = registry.filter_checks(only=["check_0", "check_2"])
assert len(filtered) == 2
ids = [c.id for c in filtered]
assert "check_0" in ids
assert "check_2" in ids
assert "check_1" not in ids
def test_filter_checks_skip(self):
"""Test filtering checks with skip parameter."""
registry = CheckRegistry()
for i in range(3):
registry.register(
id=f"check_{i}",
title=f"Check {i}",
description=f"Check {i}",
category=CheckCategory.ENVIRONMENT,
implementation=lambda c: None,
)
filtered = registry.filter_checks(skip=["check_1"])
assert len(filtered) == 2
ids = [c.id for c in filtered]
assert "check_0" in ids
assert "check_2" in ids
assert "check_1" not in ids
def test_filter_checks_deep_mode(self):
"""Test filtering checks by deep mode."""
registry = CheckRegistry()
registry.register(
id="fast_check",
title="Fast Check",
description="Fast",
category=CheckCategory.ENVIRONMENT,
implementation=lambda c: None,
requires_deep=False,
)
registry.register(
id="deep_check",
title="Deep Check",
description="Deep",
category=CheckCategory.ENVIRONMENT,
implementation=lambda c: None,
requires_deep=True,
)
# Without deep mode
filtered = registry.filter_checks(deep_mode=False)
assert len(filtered) == 1
assert filtered[0].id == "fast_check"
# With deep mode
filtered = registry.filter_checks(deep_mode=True)
assert len(filtered) == 2
def test_resolve_dependencies(self):
"""Test dependency resolution."""
registry = CheckRegistry()
registry.register(
id="check_a",
title="Check A",
description="A",
category=CheckCategory.ENVIRONMENT,
implementation=lambda c: None,
dependencies=[],
)
registry.register(
id="check_b",
title="Check B",
description="B",
category=CheckCategory.ENVIRONMENT,
implementation=lambda c: None,
dependencies=["check_a"],
)
registry.register(
id="check_c",
title="Check C",
description="C",
category=CheckCategory.ENVIRONMENT,
implementation=lambda c: None,
dependencies=["check_b"],
)
ordered = registry.resolve_dependencies(["check_c", "check_a", "check_b"])
# check_a should come before check_b, check_b before check_c
assert ordered.index("check_a") < ordered.index("check_b")
assert ordered.index("check_b") < ordered.index("check_c")
class TestRegisterCheckDecorator:
"""Tests for register_check decorator."""
def setup_method(self):
"""Reset registry before each test."""
CheckRegistry.reset()
def test_decorator_registers_check(self):
"""Test that decorator registers the check."""
@register_check(
id="decorated_check",
title="Decorated Check",
description="A decorated check",
category=CheckCategory.ENVIRONMENT,
)
def my_check(config: DoctorConfig) -> CheckResult:
return CheckResult(
id="decorated_check",
title="Decorated Check",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="OK",
)
registry = get_registry()
assert "decorated_check" in registry.get_check_ids()
# Verify the function still works
result = my_check(DoctorConfig())
assert result.status == CheckStatus.PASS
@@ -0,0 +1,357 @@
"""
Performance benchmark tests for PraisonAI.
These tests verify that import times, CLI startup, and memory usage
stay within acceptable bounds. They serve as regression guards.
Run with: pytest tests/unit/test_performance_benchmarks.py -v
"""
import subprocess
import sys
import os
import pytest
# Get the paths for praisonai and praisonaiagents
PRAISONAI_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
PRAISONAI_AGENTS_PATH = os.path.join(os.path.dirname(PRAISONAI_PATH), "praisonai-agents")
# Build PYTHONPATH that includes both packages
PYTHONPATH = f"{PRAISONAI_PATH}:{PRAISONAI_AGENTS_PATH}"
# Thresholds (can be adjusted based on CI environment)
PRAISONAI_WRAPPER_IMPORT_TIME_MS = 500 # Should be very fast (lazy)
CLI_STARTUP_TIME_S = 1.0 # Target < 1s for CLI cold start
CLI_MEMORY_MB = 50 # Target < 50MB for CLI cold start (without heavy deps)
def run_python_code(code: str, cwd: str = None) -> subprocess.CompletedProcess:
"""Run Python code in a subprocess with proper PYTHONPATH."""
env = os.environ.copy()
env['PYTHONPATH'] = PYTHONPATH
return subprocess.run(
[sys.executable, '-c', code],
capture_output=True,
text=True,
cwd=cwd or PRAISONAI_PATH,
env=env
)
class TestImportTimeBenchmarks:
"""Test import time performance."""
def test_praisonai_wrapper_import_time(self):
"""Test that praisonai wrapper imports quickly (lazy loading)."""
code = '''
import time
t = time.time()
import praisonai
elapsed_ms = (time.time() - t) * 1000
print(f"{elapsed_ms:.0f}")
'''
result = run_python_code(code)
assert result.returncode == 0, f"Import failed: {result.stderr}"
elapsed_ms = float(result.stdout.strip())
assert elapsed_ms < PRAISONAI_WRAPPER_IMPORT_TIME_MS, \
f"praisonai import took {elapsed_ms:.0f}ms, expected < {PRAISONAI_WRAPPER_IMPORT_TIME_MS}ms"
def test_cli_main_import_time(self):
"""Test that CLI main module imports quickly (lazy loading)."""
code = '''
import time
import os
os.environ['LOGLEVEL'] = 'WARNING'
t = time.time()
from praisonai.cli.main import PraisonAI
elapsed_ms = (time.time() - t) * 1000
print(f"{elapsed_ms:.0f}")
'''
result = run_python_code(code)
assert result.returncode == 0, f"Import failed: {result.stderr}"
elapsed_ms = float(result.stdout.strip())
# CLI should be fast due to lazy loading
assert elapsed_ms < CLI_STARTUP_TIME_S * 1000, \
f"CLI import took {elapsed_ms:.0f}ms, expected < {CLI_STARTUP_TIME_S * 1000}ms"
class TestMemoryBenchmarks:
"""Test memory usage."""
def test_cli_cold_start_memory(self):
"""Test that CLI cold start uses reasonable memory."""
code = '''
import tracemalloc
import os
os.environ['LOGLEVEL'] = 'WARNING'
tracemalloc.start()
from praisonai.cli.main import PraisonAI
current, peak = tracemalloc.get_traced_memory()
print(f"{peak / 1024 / 1024:.1f}")
'''
result = run_python_code(code)
assert result.returncode == 0, f"Memory test failed: {result.stderr}"
peak_mb = float(result.stdout.strip())
assert peak_mb < CLI_MEMORY_MB, \
f"CLI cold start used {peak_mb:.1f}MB, expected < {CLI_MEMORY_MB}MB"
class TestLazyLoadingVerification:
"""Verify that lazy loading is working correctly."""
def test_auto_generator_not_imported_at_cli_startup(self):
"""Verify AutoGenerator is not imported when CLI starts."""
code = '''
import sys
import os
os.environ['LOGLEVEL'] = 'WARNING'
from praisonai.cli.main import PraisonAI
# Check if heavy modules are NOT loaded
heavy_modules = ['praisonai.auto', 'praisonai.agents_generator', 'litellm', 'instructor']
loaded = [m for m in heavy_modules if m in sys.modules]
if loaded:
print(f"FAIL: {loaded}")
else:
print("OK")
'''
result = run_python_code(code)
assert result.returncode == 0, f"Test failed: {result.stderr}"
output = result.stdout.strip()
assert output == "OK", f"Heavy modules loaded at startup: {output}"
def test_praisonaiagents_not_imported_at_cli_startup(self):
"""Verify praisonaiagents is not imported when CLI starts."""
code = '''
import sys
import os
os.environ['LOGLEVEL'] = 'WARNING'
from praisonai.cli.main import PraisonAI
# Check if praisonaiagents is NOT loaded
if 'praisonaiagents' in sys.modules:
print("FAIL: praisonaiagents loaded")
else:
print("OK")
'''
result = run_python_code(code)
assert result.returncode == 0, f"Test failed: {result.stderr}"
output = result.stdout.strip()
assert output == "OK", f"praisonaiagents loaded at startup: {output}"
class TestThreadSafetyToolRegistry:
"""Test thread safety of tool registry."""
def test_concurrent_tool_registration(self):
"""Test that concurrent tool registration is thread-safe."""
code = '''
import threading
import time
from praisonaiagents.tools.registry import ToolRegistry
registry = ToolRegistry()
errors = []
registered_count = [0]
lock = threading.Lock()
def register_tools(thread_id, count):
try:
for i in range(count):
def tool_func():
pass
tool_func.__name__ = f"tool_{thread_id}_{i}"
registry.register(tool_func)
with lock:
registered_count[0] += 1
except Exception as e:
errors.append(str(e))
# Create multiple threads that register tools concurrently
threads = []
num_threads = 10
tools_per_thread = 50
for i in range(num_threads):
t = threading.Thread(target=register_tools, args=(i, tools_per_thread))
threads.append(t)
# Start all threads
for t in threads:
t.start()
# Wait for all threads to complete
for t in threads:
t.join()
# Verify results
expected_total = num_threads * tools_per_thread
actual_total = len(registry)
if errors:
print(f"FAIL: Errors occurred: {errors}")
elif actual_total != expected_total:
print(f"FAIL: Expected {expected_total} tools, got {actual_total}")
else:
print("OK")
'''
result = run_python_code(code)
assert result.returncode == 0, f"Test failed: {result.stderr}"
output = result.stdout.strip()
assert output == "OK", f"Thread safety test failed: {output}"
def test_concurrent_tool_access(self):
"""Test that concurrent tool access is thread-safe."""
code = '''
import threading
from praisonaiagents.tools.registry import ToolRegistry
registry = ToolRegistry()
errors = []
# Pre-register some tools
for i in range(100):
def tool_func():
pass
tool_func.__name__ = f"tool_{i}"
registry.register(tool_func)
def access_tools(thread_id, iterations):
try:
for _ in range(iterations):
# Mix of read operations
registry.list_tools()
registry.get(f"tool_{thread_id % 100}")
len(registry)
f"tool_{thread_id % 100}" in registry
except Exception as e:
errors.append(str(e))
# Create multiple threads that access tools concurrently
threads = []
num_threads = 20
iterations = 100
for i in range(num_threads):
t = threading.Thread(target=access_tools, args=(i, iterations))
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
if errors:
print(f"FAIL: Errors occurred: {errors}")
else:
print("OK")
'''
result = run_python_code(code)
assert result.returncode == 0, f"Test failed: {result.stderr}"
output = result.stdout.strip()
assert output == "OK", f"Thread safety test failed: {output}"
class TestTemplateCaching:
"""Test template discovery caching."""
def test_template_discovery_caching(self):
"""Test that template discovery results are cached."""
code = '''
import time
from praisonai.templates.discovery import TemplateDiscovery
# Create discovery instance
discovery = TemplateDiscovery(include_package=False)
# First call - should scan filesystem
t1 = time.time()
result1 = discovery.discover_all()
time1 = time.time() - t1
# Second call - should use cache
t2 = time.time()
result2 = discovery.discover_all()
time2 = time.time() - t2
# Third call with refresh - should scan again
t3 = time.time()
result3 = discovery.discover_all(refresh=True)
time3 = time.time() - t3
# Cache hit should be significantly faster
# (at least 2x faster, typically 10x+)
if time2 > 0 and time1 / time2 < 1.5:
# If times are very similar, cache might not be working
# But if both are very fast (<1ms), it's fine
if time1 > 0.001 and time2 > 0.001:
print(f"WARN: Cache may not be working. First: {time1*1000:.2f}ms, Second: {time2*1000:.2f}ms")
else:
print("OK")
else:
print("OK")
'''
result = run_python_code(code)
assert result.returncode == 0, f"Test failed: {result.stderr}"
output = result.stdout.strip()
assert "OK" in output or "WARN" in output, f"Cache test failed: {output}"
def test_template_cache_clear(self):
"""Test that template cache can be cleared."""
code = '''
from praisonai.templates.discovery import TemplateDiscovery
discovery = TemplateDiscovery(include_package=False)
# Populate cache
discovery.discover_all()
# Clear cache
discovery.clear_cache()
# Verify cache is cleared by checking internal state
if discovery._cache_key in TemplateDiscovery._cache:
print("FAIL: Cache not cleared")
else:
print("OK")
'''
result = run_python_code(code)
assert result.returncode == 0, f"Test failed: {result.stderr}"
output = result.stdout.strip()
assert output == "OK", f"Cache clear test failed: {output}"
class TestSchemaVersioning:
"""Test DB adapter schema versioning."""
def test_schema_version_constant_exists(self):
"""Test that SCHEMA_VERSION constant is defined."""
code = '''
from praisonaiagents.db.protocol import SCHEMA_VERSION
if SCHEMA_VERSION and isinstance(SCHEMA_VERSION, str):
print("OK")
else:
print(f"FAIL: Invalid SCHEMA_VERSION: {SCHEMA_VERSION}")
'''
result = run_python_code(code)
assert result.returncode == 0, f"Test failed: {result.stderr}"
output = result.stdout.strip()
assert output == "OK", f"Schema version test failed: {output}"
def test_schema_version_format(self):
"""Test that SCHEMA_VERSION follows MAJOR.MINOR format."""
code = r'''
import re
from praisonaiagents.db.protocol import SCHEMA_VERSION
if re.match(r"^\d+\.\d+$", SCHEMA_VERSION):
print("OK")
else:
print(f"FAIL: Invalid format: {SCHEMA_VERSION}")
'''
result = run_python_code(code)
assert result.returncode == 0, f"Test failed: {result.stderr}"
output = result.stdout.strip()
assert output == "OK", f"Schema version format test failed: {output}"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+4 -4
View File
@@ -4117,7 +4117,7 @@ requires-dist = [
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
{ name = "praisonaiagents", specifier = ">=0.4.1" },
{ name = "praisonaiagents", specifier = ">=0.5.1" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
{ name = "pydantic", marker = "extra == 'chat'", specifier = "<=2.10.1" },
{ name = "pydantic", marker = "extra == 'code'", specifier = "<=2.10.1" },
@@ -4159,7 +4159,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.4.1"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -4168,9 +4168,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/e8/5cf1306785b5e47b278363f58dee68ea381d26acc87ac9fd86b9d7abab0a/praisonaiagents-0.4.1.tar.gz", hash = "sha256:61cf37b3392c8857855176cdae6a758a88b110d103c61e226a4da7ab2ad8d30c", size = 766683, upload-time = "2025-12-28T07:27:21.999Z" }
sdist = { url = "https://files.pythonhosted.org/packages/67/49/1534414aac662df69537784350c90f35f62dfd22950274a875d6924a63c3/praisonaiagents-0.5.1.tar.gz", hash = "sha256:f18cd248197e327a5ae595a9e4151bbc6b2030fcf51e188cc495ea869fb6dcc2", size = 774252, upload-time = "2025-12-29T02:13:02.645Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/26/b1593bf8d360793cc7e82020f7bef080734290370ddbb54658c6d9a0d8e1/praisonaiagents-0.4.1-py3-none-any.whl", hash = "sha256:0cdb9c8514cf042340c093103d9051a4d25dfb6d8126d05757b552f21b8b7085", size = 713637, upload-time = "2025-12-28T07:27:19.944Z" },
{ url = "https://files.pythonhosted.org/packages/68/95/6bc3822fa9396be446155ed7461821a273f02d10fc8e0dfb8186a758f11e/praisonaiagents-0.5.1-py3-none-any.whl", hash = "sha256:cc9c524c7901ce1102a9580b78eb6cc71db6751c8dfe4566c102f4b93ab19a89", size = 721775, upload-time = "2025-12-29T02:13:00.317Z" },
]
[[package]]