mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-09 03:39:37 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a5a432d96 | ||
|
|
8cb5bbb605 | ||
|
|
952514d73c | ||
|
|
6e1dc34864 | ||
|
|
820d5c2995 | ||
|
|
0b1b3d3b3e | ||
|
|
59107addc3 | ||
|
|
b460ded5e0 | ||
|
|
a8967ed200 |
No files matched your search
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.4" \
|
||||
"praisonai>=2.7.2" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.6.4" \
|
||||
"praisonai>=2.7.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.6.4" \
|
||||
"praisonai>=2.7.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Assistants Capabilities Example
|
||||
|
||||
Demonstrates OpenAI Assistants API using PraisonAI capabilities.
|
||||
"""
|
||||
|
||||
from praisonai.capabilities import assistant_list
|
||||
|
||||
print("=== List Assistants ===")
|
||||
try:
|
||||
assistants = assistant_list()
|
||||
print(f"Assistants found: {len(assistants)}")
|
||||
for a in assistants[:5]: # Show first 5
|
||||
print(f" - {a.get('name', 'unnamed')}: {a.get('id', 'unknown')}")
|
||||
except Exception as e:
|
||||
print(f"Note: {e}")
|
||||
|
||||
print("\n=== Assistant Operations ===")
|
||||
print("Available functions:")
|
||||
print(" assistant_create(name, instructions, model='gpt-4o-mini')")
|
||||
print(" assistant_list()")
|
||||
|
||||
print("\nSee CLI: praisonai assistants list")
|
||||
print("See CLI: praisonai assistants create --name 'My Assistant'")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Audio Capabilities Example
|
||||
|
||||
Demonstrates transcription and text-to-speech using PraisonAI capabilities.
|
||||
"""
|
||||
|
||||
from praisonai.capabilities import transcribe, speech
|
||||
|
||||
# Note: Audio transcription requires an audio file
|
||||
# This example shows the API structure
|
||||
|
||||
print("=== Audio Transcription ===")
|
||||
print("To transcribe audio, use:")
|
||||
print(" result = transcribe('audio.mp3', model='whisper-1')")
|
||||
print(" print(result.text)")
|
||||
|
||||
print("\n=== Text-to-Speech ===")
|
||||
print("To generate speech, use:")
|
||||
print(" audio_data = speech('Hello world', model='tts-1', voice='alloy')")
|
||||
print(" # Save to file or play")
|
||||
|
||||
print("\nNote: These operations require audio files or generate audio output.")
|
||||
print("See CLI: praisonai audio transcribe <file>")
|
||||
print("See CLI: praisonai audio speech <text>")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Batches Capabilities Example
|
||||
|
||||
Demonstrates batch processing using PraisonAI capabilities.
|
||||
"""
|
||||
|
||||
from praisonai.capabilities import batch_list
|
||||
|
||||
print("=== Batch List ===")
|
||||
try:
|
||||
batches = batch_list()
|
||||
print(f"Batches found: {len(batches)}")
|
||||
for b in batches[:5]: # Show first 5
|
||||
print(f" - {b.get('id', 'unknown')}: {b.get('status', 'unknown')}")
|
||||
except Exception as e:
|
||||
print(f"Note: {e}")
|
||||
print("Batch operations require batch jobs to be created first.")
|
||||
|
||||
print("\n=== Batch Operations ===")
|
||||
print("Available functions:")
|
||||
print(" batch_create(input_file_id, endpoint, completion_window='24h')")
|
||||
print(" batch_list()")
|
||||
print(" batch_retrieve(batch_id)")
|
||||
print(" batch_cancel(batch_id)")
|
||||
|
||||
print("\nSee CLI: praisonai batches list")
|
||||
print("See CLI: praisonai batches create <input_file_id>")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Files Capabilities Example
|
||||
|
||||
Demonstrates file management using PraisonAI capabilities.
|
||||
"""
|
||||
|
||||
from praisonai.capabilities import file_list
|
||||
|
||||
print("=== File List ===")
|
||||
try:
|
||||
files = file_list()
|
||||
print(f"Files found: {len(files)}")
|
||||
for f in files[:5]: # Show first 5
|
||||
print(f" - {f.get('filename', 'unknown')}: {f.get('id', 'unknown')}")
|
||||
except Exception as e:
|
||||
print(f"Note: {e}")
|
||||
print("File operations require files to be uploaded first.")
|
||||
|
||||
print("\n=== File Operations ===")
|
||||
print("Available functions:")
|
||||
print(" file_create(file_path, purpose='assistants')")
|
||||
print(" file_list(purpose=None)")
|
||||
print(" file_retrieve(file_id)")
|
||||
print(" file_delete(file_id)")
|
||||
print(" file_content(file_id)")
|
||||
|
||||
print("\nSee CLI: praisonai files list")
|
||||
print("See CLI: praisonai files upload <file>")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Rerank Capabilities Example
|
||||
|
||||
Demonstrates document reranking using PraisonAI capabilities.
|
||||
"""
|
||||
|
||||
from praisonai.capabilities import rerank
|
||||
|
||||
print("=== Document Reranking ===")
|
||||
try:
|
||||
documents = [
|
||||
"Python is a programming language",
|
||||
"Machine learning uses algorithms",
|
||||
"Python is great for data science",
|
||||
"JavaScript runs in browsers"
|
||||
]
|
||||
|
||||
results = rerank(
|
||||
query="Python programming",
|
||||
documents=documents,
|
||||
model="cohere/rerank-english-v3.0"
|
||||
)
|
||||
|
||||
print(f"Query: 'Python programming'")
|
||||
print(f"Reranked results:")
|
||||
for r in results:
|
||||
print(f" Score {r.get('relevance_score', 0):.4f}: {r.get('document', {}).get('text', '')[:50]}")
|
||||
except Exception as e:
|
||||
print(f"Note: Reranking requires Cohere API key")
|
||||
print(f"Error: {e}")
|
||||
|
||||
print("\nSee CLI: praisonai rerank <query> --documents 'doc1' 'doc2'")
|
||||
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
Vector Stores Capabilities Example
|
||||
|
||||
Demonstrates vector store management using PraisonAI capabilities.
|
||||
"""
|
||||
|
||||
from praisonai.capabilities import vector_store_create, vector_store_search
|
||||
|
||||
print("=== Vector Store Operations ===")
|
||||
print("Available functions:")
|
||||
print(" vector_store_create(name)")
|
||||
print(" vector_store_search(vector_store_id, query)")
|
||||
|
||||
print("\n=== Create Vector Store ===")
|
||||
try:
|
||||
store = vector_store_create(name="test-store")
|
||||
print(f"Created: {store}")
|
||||
except Exception as e:
|
||||
print(f"Note: {e}")
|
||||
|
||||
print("\nSee CLI: praisonai vector-stores create --name 'my-store'")
|
||||
print("See CLI: praisonai vector-stores search <store_id> <query>")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
```
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PraisonAI Endpoints Example
|
||||
|
||||
This example demonstrates how to:
|
||||
1. Start the recipe server
|
||||
2. Use the endpoints CLI to interact with it
|
||||
3. Invoke recipes via HTTP
|
||||
|
||||
Prerequisites:
|
||||
- pip install praisonai[serve]
|
||||
- Set OPENAI_API_KEY environment variable
|
||||
|
||||
Usage:
|
||||
# Terminal 1: Start server
|
||||
praisonai recipe serve --port 8765
|
||||
|
||||
# Terminal 2: Run this example
|
||||
python endpoints_example.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
# Configuration
|
||||
SERVER_URL = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
|
||||
|
||||
|
||||
def check_server_health():
|
||||
"""Check if the server is running."""
|
||||
try:
|
||||
req = urllib.request.Request(f"{SERVER_URL}/health")
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
return data.get("status") == "healthy"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def list_endpoints():
|
||||
"""List available endpoints using CLI."""
|
||||
print("\n=== Listing Endpoints (CLI) ===")
|
||||
result = subprocess.run(
|
||||
["python", "-m", "praisonai.cli.main", "endpoints", "list", "--format", "json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
recipes = json.loads(result.stdout)
|
||||
print(f"Found {len(recipes)} endpoints:")
|
||||
for r in recipes:
|
||||
print(f" - {r['name']} ({r['version']}): {r.get('description', '')[:50]}")
|
||||
return recipes
|
||||
else:
|
||||
print(f"Error: {result.stderr}")
|
||||
return []
|
||||
|
||||
|
||||
def describe_endpoint(name: str):
|
||||
"""Describe an endpoint using CLI."""
|
||||
print(f"\n=== Describing Endpoint: {name} ===")
|
||||
result = subprocess.run(
|
||||
["python", "-m", "praisonai.cli.main", "endpoints", "describe", name, "--url", SERVER_URL],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
info = json.loads(result.stdout)
|
||||
print(f"Name: {info.get('name')}")
|
||||
print(f"Version: {info.get('version')}")
|
||||
print(f"Description: {info.get('description')}")
|
||||
return info
|
||||
else:
|
||||
print(f"Error: {result.stderr}")
|
||||
return None
|
||||
|
||||
|
||||
def invoke_endpoint_cli(name: str, input_data: dict):
|
||||
"""Invoke an endpoint using CLI."""
|
||||
print(f"\n=== Invoking Endpoint (CLI): {name} ===")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"python", "-m", "praisonai.cli.main", "endpoints", "invoke", name,
|
||||
"--url", SERVER_URL,
|
||||
"--input-json", json.dumps(input_data),
|
||||
"--json",
|
||||
"--dry-run", # Use dry-run for example
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
response = json.loads(result.stdout)
|
||||
print(f"Run ID: {response.get('run_id')}")
|
||||
print(f"Status: {response.get('status')}")
|
||||
return response
|
||||
else:
|
||||
print(f"Error: {result.stderr}")
|
||||
return None
|
||||
|
||||
|
||||
def invoke_endpoint_http(name: str, input_data: dict):
|
||||
"""Invoke an endpoint using HTTP directly."""
|
||||
print(f"\n=== Invoking Endpoint (HTTP): {name} ===")
|
||||
|
||||
body = json.dumps({
|
||||
"recipe": name,
|
||||
"input": input_data,
|
||||
"options": {"dry_run": True},
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{SERVER_URL}/v1/recipes/run",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
print(f"Run ID: {data.get('run_id')}")
|
||||
print(f"Status: {data.get('status')}")
|
||||
print(f"Output: {data.get('output')}")
|
||||
return data
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"HTTP Error: {e.code} - {e.read().decode()}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
"""Main example function."""
|
||||
print("=" * 60)
|
||||
print("PraisonAI Endpoints Example")
|
||||
print("=" * 60)
|
||||
|
||||
# Check if server is running
|
||||
print("\n=== Checking Server Health ===")
|
||||
if not check_server_health():
|
||||
print(f"Server not running at {SERVER_URL}")
|
||||
print("\nPlease start the server first:")
|
||||
print(" praisonai recipe serve --port 8765")
|
||||
sys.exit(1)
|
||||
print("✓ Server is healthy")
|
||||
|
||||
# List endpoints
|
||||
recipes = list_endpoints()
|
||||
if not recipes:
|
||||
print("No recipes available")
|
||||
sys.exit(1)
|
||||
|
||||
# Use first available recipe
|
||||
recipe_name = recipes[0]["name"]
|
||||
|
||||
# Describe endpoint
|
||||
describe_endpoint(recipe_name)
|
||||
|
||||
# Invoke via CLI
|
||||
invoke_endpoint_cli(recipe_name, {"query": "Hello from CLI"})
|
||||
|
||||
# Invoke via HTTP
|
||||
invoke_endpoint_http(recipe_name, {"query": "Hello from HTTP"})
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,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'}")
|
||||
@@ -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!")
|
||||
@@ -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!")
|
||||
@@ -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,128 @@
|
||||
"""
|
||||
Example: Using pandas as a custom tool with PraisonAI Agent
|
||||
|
||||
This example shows how to wrap pandas functions as tools
|
||||
that can be used with Agent().
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
from io import StringIO
|
||||
|
||||
# Define pandas wrapper tools
|
||||
def analyze_csv_data(csv_data: str) -> str:
|
||||
"""Analyze CSV data using pandas DataFrame.
|
||||
|
||||
Args:
|
||||
csv_data: CSV-formatted string data
|
||||
|
||||
Returns:
|
||||
Analysis summary including shape, columns, and statistics
|
||||
"""
|
||||
df = pd.read_csv(StringIO(csv_data))
|
||||
summary = f"""DataFrame Analysis:
|
||||
- Shape: {df.shape[0]} rows, {df.shape[1]} columns
|
||||
- Columns: {list(df.columns)}
|
||||
- Data Types: {dict(df.dtypes)}
|
||||
- Summary Statistics:
|
||||
{df.describe().to_string()}"""
|
||||
return summary
|
||||
|
||||
|
||||
def filter_dataframe(csv_data: str, column: str, condition: str, value: str) -> str:
|
||||
"""Filter a DataFrame based on a condition.
|
||||
|
||||
Args:
|
||||
csv_data: CSV-formatted string data
|
||||
column: Column name to filter on
|
||||
condition: Condition type ('equals', 'greater', 'less', 'contains')
|
||||
value: Value to compare against
|
||||
|
||||
Returns:
|
||||
Filtered data as CSV string
|
||||
"""
|
||||
df = pd.read_csv(StringIO(csv_data))
|
||||
|
||||
if condition == 'equals':
|
||||
filtered = df[df[column] == value]
|
||||
elif condition == 'greater':
|
||||
filtered = df[df[column] > float(value)]
|
||||
elif condition == 'less':
|
||||
filtered = df[df[column] < float(value)]
|
||||
elif condition == 'contains':
|
||||
filtered = df[df[column].str.contains(value, na=False)]
|
||||
else:
|
||||
return f"Unknown condition: {condition}"
|
||||
|
||||
return filtered.to_csv(index=False)
|
||||
|
||||
|
||||
def calculate_statistics(csv_data: str, column: str) -> str:
|
||||
"""Calculate statistics for a specific column.
|
||||
|
||||
Args:
|
||||
csv_data: CSV-formatted string data
|
||||
column: Column name to analyze
|
||||
|
||||
Returns:
|
||||
Statistics summary for the column
|
||||
"""
|
||||
df = pd.read_csv(StringIO(csv_data))
|
||||
|
||||
if column not in df.columns:
|
||||
return f"Column '{column}' not found. Available: {list(df.columns)}"
|
||||
|
||||
col = df[column]
|
||||
|
||||
if pd.api.types.is_numeric_dtype(col):
|
||||
stats = f"""Statistics for '{column}':
|
||||
- Count: {col.count()}
|
||||
- Mean: {col.mean():.2f}
|
||||
- Std: {col.std():.2f}
|
||||
- Min: {col.min()}
|
||||
- Max: {col.max()}
|
||||
- Median: {col.median()}"""
|
||||
else:
|
||||
stats = f"""Statistics for '{column}':
|
||||
- Count: {col.count()}
|
||||
- Unique: {col.nunique()}
|
||||
- Top: {col.mode().iloc[0] if len(col.mode()) > 0 else 'N/A'}
|
||||
- Sample values: {list(col.head(3))}"""
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test data
|
||||
test_csv = """name,age,salary,department
|
||||
Alice,30,50000,Engineering
|
||||
Bob,25,45000,Marketing
|
||||
Charlie,35,60000,Engineering
|
||||
Diana,28,52000,Sales
|
||||
Eve,32,55000,Marketing"""
|
||||
|
||||
print("=== Test: analyze_csv_data ===")
|
||||
print(analyze_csv_data(test_csv))
|
||||
print()
|
||||
|
||||
print("=== Test: filter_dataframe ===")
|
||||
print(filter_dataframe(test_csv, "age", "greater", "28"))
|
||||
print()
|
||||
|
||||
print("=== Test: calculate_statistics ===")
|
||||
print(calculate_statistics(test_csv, "salary"))
|
||||
print()
|
||||
|
||||
# Example with Agent (requires praisonaiagents)
|
||||
print("=== Agent Usage Example ===")
|
||||
print("""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="data_analyst",
|
||||
role="Data Analyst",
|
||||
goal="Analyze data using pandas tools",
|
||||
tools=[analyze_csv_data, filter_dataframe, calculate_statistics]
|
||||
)
|
||||
|
||||
result = agent.start("Analyze this data and find the average salary")
|
||||
""")
|
||||
@@ -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!")
|
||||
@@ -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.
@@ -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"])
|
||||
Generated
+1
-1
@@ -2422,7 +2422,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.4.1"
|
||||
version = "0.5.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Test file for simplified TS SDK APIs
|
||||
* Run with: npx tsx examples/simplified-api-test.ts
|
||||
*/
|
||||
|
||||
import { Agent, db, Router, PlanningAgent, TaskAgent, AgentTelemetry } from '../src';
|
||||
|
||||
async function testSimpleAgent() {
|
||||
console.log('\n=== Test 1: Simple Agent ===');
|
||||
const agent = new Agent({
|
||||
instructions: 'You are a helpful assistant. Be brief.',
|
||||
verbose: false
|
||||
});
|
||||
|
||||
const response = await agent.chat('What is 2+2? Answer in one word.');
|
||||
console.log('Response:', response.slice(0, 100));
|
||||
console.log('✅ Simple Agent works');
|
||||
}
|
||||
|
||||
async function testAgentWithTools() {
|
||||
console.log('\n=== Test 2: Agent with Tools ===');
|
||||
|
||||
const getWeather = (city: string) => `Weather in ${city}: 22°C, sunny`;
|
||||
const calculate = (expr: string) => {
|
||||
try {
|
||||
return String(eval(expr));
|
||||
} catch {
|
||||
return 'Error';
|
||||
}
|
||||
};
|
||||
|
||||
const agent = new Agent({
|
||||
instructions: 'You help with weather and calculations. Be brief.',
|
||||
tools: [getWeather, calculate],
|
||||
verbose: false
|
||||
});
|
||||
|
||||
const response = await agent.chat('What is 5 + 3?');
|
||||
console.log('Response:', response.slice(0, 100));
|
||||
console.log('✅ Agent with Tools works');
|
||||
}
|
||||
|
||||
async function testAgentWithPersistence() {
|
||||
console.log('\n=== Test 3: Agent with Persistence (Memory) ===');
|
||||
|
||||
const agent = new Agent({
|
||||
instructions: 'You remember conversations. Be brief.',
|
||||
db: db('memory:'), // In-memory for testing
|
||||
sessionId: 'test-session-123',
|
||||
verbose: false
|
||||
});
|
||||
|
||||
await agent.chat('My name is Alice.');
|
||||
const response = await agent.chat('What is my name?');
|
||||
console.log('Response:', response.slice(0, 100));
|
||||
console.log('History length:', agent.getHistory().length);
|
||||
console.log('✅ Agent with Persistence works');
|
||||
}
|
||||
|
||||
async function testRouter() {
|
||||
console.log('\n=== Test 4: Simplified Router ===');
|
||||
|
||||
const mathAgent = new Agent({
|
||||
instructions: 'You are a math expert. Answer math questions briefly.',
|
||||
verbose: false
|
||||
});
|
||||
|
||||
const codeAgent = new Agent({
|
||||
instructions: 'You are a coding expert. Answer coding questions briefly.',
|
||||
verbose: false
|
||||
});
|
||||
|
||||
const router = new Router({
|
||||
math: { agent: mathAgent, keywords: ['calculate', 'math', 'sum', '+', '-', '*', '/'] },
|
||||
code: { agent: codeAgent, keywords: ['code', 'program', 'function', 'javascript'] }
|
||||
});
|
||||
|
||||
const response = await router.chat('Calculate 10 + 5');
|
||||
console.log('Response:', response.slice(0, 100));
|
||||
console.log('✅ Simplified Router works');
|
||||
}
|
||||
|
||||
async function testPlanningAgent() {
|
||||
console.log('\n=== Test 5: Planning Agent ===');
|
||||
|
||||
const agent = new PlanningAgent({
|
||||
verbose: false,
|
||||
maxSteps: 3 // Limit steps for testing
|
||||
});
|
||||
|
||||
const plan = await agent.createPlan('Make a cup of tea');
|
||||
console.log('Plan created with', plan.steps.length, 'steps');
|
||||
console.log('First step:', plan.steps[0]?.description || 'N/A');
|
||||
console.log('✅ Planning Agent works');
|
||||
}
|
||||
|
||||
async function testTaskAgent() {
|
||||
console.log('\n=== Test 6: Task Agent ===');
|
||||
|
||||
const agent = new TaskAgent({ verbose: false });
|
||||
|
||||
agent.addTask('Fix critical bug', 'high');
|
||||
agent.addTask('Write documentation', 'medium');
|
||||
agent.addTask('Review PR', 'low');
|
||||
|
||||
console.log('Pending tasks:', agent.getPendingTasks().length);
|
||||
|
||||
agent.completeTask('bug');
|
||||
console.log('After completing bug task:', agent.getPendingTasks().length);
|
||||
console.log('Progress:', agent.getProgress().percentage + '%');
|
||||
console.log('✅ Task Agent works');
|
||||
}
|
||||
|
||||
async function testAgentTelemetry() {
|
||||
console.log('\n=== Test 7: Agent Telemetry ===');
|
||||
|
||||
const agent = new Agent({
|
||||
instructions: 'Be brief.',
|
||||
verbose: false
|
||||
});
|
||||
|
||||
const telemetry = new AgentTelemetry('TestAgent');
|
||||
|
||||
await telemetry.trackChat(async () => {
|
||||
return await agent.chat('Hello!');
|
||||
});
|
||||
|
||||
await telemetry.trackChat(async () => {
|
||||
return await agent.chat('How are you?');
|
||||
});
|
||||
|
||||
const stats = telemetry.getStats();
|
||||
console.log('Total chats:', stats.totalChats);
|
||||
console.log('Success rate:', telemetry.getSuccessRate() + '%');
|
||||
console.log('✅ Agent Telemetry works');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 Testing Simplified TS SDK APIs\n');
|
||||
|
||||
try {
|
||||
await testSimpleAgent();
|
||||
await testAgentWithTools();
|
||||
await testAgentWithPersistence();
|
||||
await testRouter();
|
||||
await testPlanningAgent();
|
||||
await testTaskAgent();
|
||||
await testAgentTelemetry();
|
||||
|
||||
console.log('\n✅ All tests passed!');
|
||||
} catch (error) {
|
||||
console.error('\n❌ Test failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "praisonai",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"description": "PraisonAI TypeScript AI Agents Framework - Node.js, npm, and Javascript AI Agents Framework",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* The primary exports are:
|
||||
* - Agent: Single agent with instructions, tools, and optional persistence
|
||||
* - Agents: Multi-agent orchestration (alias for PraisonAIAgents)
|
||||
* - Router: Simplified keyword/pattern-based routing
|
||||
* - Workflow: Step-based workflow execution (from workflows module)
|
||||
*/
|
||||
|
||||
@@ -11,6 +12,10 @@
|
||||
export { Agent, PraisonAIAgents, Agents } from './simple';
|
||||
export type { SimpleAgentConfig, PraisonAIAgentsConfig } from './simple';
|
||||
|
||||
// Router exports
|
||||
export { Router, RouterAgent, createRouter, routeConditions } from './router';
|
||||
export type { RouterConfig, RouteConfig, RouteContext, SimpleRouterConfig, SimpleRouteConfig } from './router';
|
||||
|
||||
// Task support (for advanced use cases)
|
||||
export { Task } from './types';
|
||||
export type { TaskConfig, AgentConfig as TaskAgentConfig } from './types';
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
/**
|
||||
* Router Agent - Route requests to specialized agents
|
||||
*
|
||||
* @example Simple usage (5 lines)
|
||||
* ```typescript
|
||||
* import { Agent, Router } from 'praisonai';
|
||||
*
|
||||
* const router = new Router({
|
||||
* math: { agent: new Agent({ instructions: 'Math expert' }), keywords: ['calculate', 'math'] },
|
||||
* code: { agent: new Agent({ instructions: 'Code expert' }), keywords: ['code', 'program'] }
|
||||
* });
|
||||
* await router.chat('Calculate 2+2'); // Routes to math agent
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { EnhancedAgent } from './enhanced';
|
||||
import { Agent } from './simple';
|
||||
|
||||
// Support both Agent and EnhancedAgent
|
||||
type AnyAgent = Agent | EnhancedAgent;
|
||||
|
||||
export interface RouteConfig {
|
||||
agent: EnhancedAgent;
|
||||
agent: AnyAgent;
|
||||
condition: (input: string, context?: RouteContext) => boolean | Promise<boolean>;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
/** Simplified route definition */
|
||||
export interface SimpleRouteConfig {
|
||||
agent: AnyAgent;
|
||||
keywords?: string[];
|
||||
pattern?: RegExp;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface RouteContext {
|
||||
history?: string[];
|
||||
metadata?: Record<string, any>;
|
||||
@@ -18,17 +41,20 @@ export interface RouteContext {
|
||||
export interface RouterConfig {
|
||||
name?: string;
|
||||
routes: RouteConfig[];
|
||||
defaultAgent?: EnhancedAgent;
|
||||
defaultAgent?: AnyAgent;
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
/** Simplified router config - just a map of route name to config */
|
||||
export type SimpleRouterConfig = Record<string, SimpleRouteConfig>;
|
||||
|
||||
/**
|
||||
* Router Agent - Routes requests to the most appropriate agent
|
||||
*/
|
||||
export class RouterAgent {
|
||||
readonly name: string;
|
||||
private routes: RouteConfig[];
|
||||
private defaultAgent?: EnhancedAgent;
|
||||
private defaultAgent?: AnyAgent;
|
||||
private verbose: boolean;
|
||||
|
||||
constructor(config: RouterConfig) {
|
||||
@@ -41,7 +67,7 @@ export class RouterAgent {
|
||||
/**
|
||||
* Route a request to the appropriate agent
|
||||
*/
|
||||
async route(input: string, context?: RouteContext): Promise<{ agent: EnhancedAgent; response: string } | null> {
|
||||
async route(input: string, context?: RouteContext): Promise<{ agent: AnyAgent; response: string } | null> {
|
||||
for (const route of this.routes) {
|
||||
const matches = await route.condition(input, context);
|
||||
if (matches) {
|
||||
@@ -49,7 +75,9 @@ export class RouterAgent {
|
||||
console.log(`[Router] Routing to: ${route.agent.name}`);
|
||||
}
|
||||
const response = await route.agent.chat(input);
|
||||
return { agent: route.agent, response: response.text };
|
||||
// Handle both Agent (returns string) and EnhancedAgent (returns ChatResult)
|
||||
const responseText = typeof response === 'string' ? response : response.text;
|
||||
return { agent: route.agent, response: responseText };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,12 +86,24 @@ export class RouterAgent {
|
||||
console.log(`[Router] Using default agent: ${this.defaultAgent.name}`);
|
||||
}
|
||||
const response = await this.defaultAgent.chat(input);
|
||||
return { agent: this.defaultAgent, response: response.text };
|
||||
const responseText = typeof response === 'string' ? response : response.text;
|
||||
return { agent: this.defaultAgent, response: responseText };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified chat method - routes and returns just the response
|
||||
*/
|
||||
async chat(input: string, context?: RouteContext): Promise<string> {
|
||||
const result = await this.route(input, context);
|
||||
if (!result) {
|
||||
throw new Error('No matching route found and no default agent configured');
|
||||
}
|
||||
return result.response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a route
|
||||
*/
|
||||
@@ -139,8 +179,89 @@ export const routeConditions = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a router agent
|
||||
* Create a router agent (legacy API)
|
||||
*/
|
||||
export function createRouter(config: RouterConfig): RouterAgent {
|
||||
return new RouterAgent(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified Router class - uses keyword/pattern-based routing
|
||||
*
|
||||
* @example Simple usage (5 lines)
|
||||
* ```typescript
|
||||
* import { Agent, Router } from 'praisonai';
|
||||
*
|
||||
* const router = new Router({
|
||||
* math: { agent: new Agent({ instructions: 'Math expert' }), keywords: ['calculate', 'math'] },
|
||||
* code: { agent: new Agent({ instructions: 'Code expert' }), keywords: ['code', 'program'] }
|
||||
* });
|
||||
* await router.chat('Calculate 2+2'); // Routes to math agent
|
||||
* ```
|
||||
*/
|
||||
export class Router {
|
||||
private routerAgent: RouterAgent;
|
||||
private agentMap: Map<string, AnyAgent> = new Map();
|
||||
|
||||
constructor(config: SimpleRouterConfig, options?: { default?: string; verbose?: boolean }) {
|
||||
const routes: RouteConfig[] = [];
|
||||
let defaultAgent: AnyAgent | undefined;
|
||||
|
||||
for (const [name, routeConfig] of Object.entries(config)) {
|
||||
this.agentMap.set(name, routeConfig.agent);
|
||||
|
||||
// Build condition from keywords or pattern
|
||||
let condition: (input: string) => boolean;
|
||||
if (routeConfig.keywords) {
|
||||
condition = routeConditions.keywords(routeConfig.keywords);
|
||||
} else if (routeConfig.pattern) {
|
||||
condition = routeConditions.pattern(routeConfig.pattern);
|
||||
} else {
|
||||
condition = routeConditions.always();
|
||||
}
|
||||
|
||||
routes.push({
|
||||
agent: routeConfig.agent,
|
||||
condition,
|
||||
priority: routeConfig.priority
|
||||
});
|
||||
|
||||
// Set default agent
|
||||
if (options?.default === name) {
|
||||
defaultAgent = routeConfig.agent;
|
||||
}
|
||||
}
|
||||
|
||||
// If no default specified, use first agent
|
||||
if (!defaultAgent && routes.length > 0) {
|
||||
defaultAgent = routes[0].agent;
|
||||
}
|
||||
|
||||
this.routerAgent = new RouterAgent({
|
||||
routes,
|
||||
defaultAgent,
|
||||
verbose: options?.verbose
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Route and get response
|
||||
*/
|
||||
async chat(input: string): Promise<string> {
|
||||
return this.routerAgent.chat(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route and get full result with agent info
|
||||
*/
|
||||
async route(input: string): Promise<{ agent: AnyAgent; response: string } | null> {
|
||||
return this.routerAgent.route(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent by name
|
||||
*/
|
||||
getAgent(name: string): AnyAgent | undefined {
|
||||
return this.agentMap.get(name);
|
||||
}
|
||||
}
|
||||
@@ -67,10 +67,22 @@ export interface SimpleAgentConfig {
|
||||
toolFunctions?: Record<string, Function>;
|
||||
/** Database adapter for persistence */
|
||||
db?: DbAdapter;
|
||||
/** Session ID for conversation persistence */
|
||||
/** Session ID for conversation persistence (auto-generated if not provided) */
|
||||
sessionId?: string;
|
||||
/** Run ID for tracing (auto-generated if not provided) */
|
||||
runId?: string;
|
||||
/** Max messages to restore from history (default: 100) */
|
||||
historyLimit?: number;
|
||||
/** Auto-restore conversation history from db (default: true) */
|
||||
autoRestore?: boolean;
|
||||
/** Auto-persist messages to db (default: true) */
|
||||
autoPersist?: boolean;
|
||||
/** Enable caching of responses */
|
||||
cache?: boolean;
|
||||
/** Cache TTL in seconds (default: 3600) */
|
||||
cacheTTL?: number;
|
||||
/** Enable telemetry tracking (default: false, opt-in) */
|
||||
telemetry?: boolean;
|
||||
|
||||
// Advanced mode (role/goal/backstory) - for compatibility
|
||||
/** Agent role (advanced mode) */
|
||||
@@ -96,6 +108,14 @@ export class Agent {
|
||||
private sessionId: string;
|
||||
private runId: string;
|
||||
private messages: Array<{ role: string; content: string | null; tool_calls?: any[]; tool_call_id?: string }> = [];
|
||||
private dbInitialized: boolean = false;
|
||||
private historyLimit: number;
|
||||
private autoRestore: boolean;
|
||||
private autoPersist: boolean;
|
||||
private cache: boolean;
|
||||
private cacheTTL: number;
|
||||
private responseCache: Map<string, { response: string; timestamp: number }> = new Map();
|
||||
private telemetryEnabled: boolean;
|
||||
|
||||
constructor(config: SimpleAgentConfig) {
|
||||
// Build instructions from either simple or advanced mode
|
||||
@@ -120,8 +140,14 @@ export class Agent {
|
||||
this.stream = config.stream ?? true;
|
||||
this.tools = config.tools;
|
||||
this.dbAdapter = config.db;
|
||||
this.sessionId = config.sessionId || randomUUID();
|
||||
this.sessionId = config.sessionId || this.generateSessionId();
|
||||
this.runId = config.runId || randomUUID();
|
||||
this.historyLimit = config.historyLimit ?? 100;
|
||||
this.autoRestore = config.autoRestore ?? true;
|
||||
this.autoPersist = config.autoPersist ?? true;
|
||||
this.cache = config.cache ?? false;
|
||||
this.cacheTTL = config.cacheTTL ?? 3600;
|
||||
this.telemetryEnabled = config.telemetry ?? false;
|
||||
this.llmService = new OpenAIService(this.llm);
|
||||
|
||||
// Configure logging
|
||||
@@ -180,6 +206,70 @@ export class Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a session ID based on current hour and agent name (like Python SDK)
|
||||
*/
|
||||
private generateSessionId(): string {
|
||||
const now = new Date();
|
||||
const hourStr = now.toISOString().slice(0, 13).replace(/[-T:]/g, '');
|
||||
const hash = this.name ? this.name.slice(0, 6) : 'agent';
|
||||
return `${hourStr}-${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize DB session - restore history on first chat (lazy)
|
||||
*/
|
||||
private async initDbSession(): Promise<void> {
|
||||
if (this.dbInitialized || !this.dbAdapter || !this.autoRestore) return;
|
||||
|
||||
try {
|
||||
// Restore previous messages from DB
|
||||
const history = await this.dbAdapter.getMessages(this.sessionId, this.historyLimit);
|
||||
if (history.length > 0) {
|
||||
this.messages = history.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content
|
||||
}));
|
||||
Logger.debug(`Restored ${history.length} messages from session ${this.sessionId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn('Failed to initialize DB session:', error);
|
||||
}
|
||||
|
||||
this.dbInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached response if available and not expired
|
||||
*/
|
||||
private getCachedResponse(prompt: string): string | null {
|
||||
if (!this.cache) return null;
|
||||
|
||||
const cacheKey = `${this.sessionId}:${prompt}`;
|
||||
const cached = this.responseCache.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
const age = (Date.now() - cached.timestamp) / 1000;
|
||||
if (age < this.cacheTTL) {
|
||||
Logger.debug('Cache hit for prompt');
|
||||
return cached.response;
|
||||
}
|
||||
// Expired, remove it
|
||||
this.responseCache.delete(cacheKey);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a response
|
||||
*/
|
||||
private cacheResponse(prompt: string, response: string): void {
|
||||
if (!this.cache) return;
|
||||
|
||||
const cacheKey = `${this.sessionId}:${prompt}`;
|
||||
this.responseCache.set(cacheKey, { response, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
private createSystemPrompt(): string {
|
||||
let prompt = this.instructions;
|
||||
if (this.markdown) {
|
||||
@@ -325,27 +415,32 @@ export class Agent {
|
||||
prompt = prompt.replace('{{previous}}', previousResult);
|
||||
}
|
||||
|
||||
// Initialize messages array
|
||||
// Initialize messages array with system prompt and conversation history
|
||||
const messages: Array<any> = [
|
||||
{ role: 'system', content: this.createSystemPrompt() },
|
||||
{ role: 'user', content: prompt }
|
||||
{ role: 'system', content: this.createSystemPrompt() }
|
||||
];
|
||||
|
||||
// Add conversation history (excluding the current prompt which will be added below)
|
||||
for (const msg of this.messages) {
|
||||
if (msg.role && msg.content) {
|
||||
messages.push({ role: msg.role, content: msg.content });
|
||||
}
|
||||
}
|
||||
|
||||
// Add current user prompt
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
let finalResponse = '';
|
||||
|
||||
if (this.stream && !this.tools) {
|
||||
// Use streaming without tools
|
||||
let fullResponse = '';
|
||||
await this.llmService.streamText(
|
||||
prompt,
|
||||
this.createSystemPrompt(),
|
||||
// Use streaming with full conversation history
|
||||
finalResponse = await this.llmService.streamChat(
|
||||
messages,
|
||||
0.7,
|
||||
(token: string) => {
|
||||
process.stdout.write(token);
|
||||
fullResponse += token;
|
||||
}
|
||||
);
|
||||
finalResponse = fullResponse;
|
||||
} else if (this.tools) {
|
||||
// Use tools (non-streaming for now to simplify implementation)
|
||||
let continueConversation = true;
|
||||
@@ -402,18 +497,36 @@ export class Agent {
|
||||
}
|
||||
|
||||
async chat(prompt: string, previousResult?: string): Promise<string> {
|
||||
// Persist user message if db is configured
|
||||
if (this.dbAdapter) {
|
||||
// Lazy init: restore history on first chat (like Python SDK)
|
||||
await this.initDbSession();
|
||||
|
||||
// Check cache first
|
||||
const cached = this.getCachedResponse(prompt);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Add user message to conversation history
|
||||
this.messages.push({ role: 'user', content: prompt });
|
||||
|
||||
// Persist user message if db is configured and autoPersist is enabled
|
||||
if (this.dbAdapter && this.autoPersist) {
|
||||
await this.persistMessage('user', prompt);
|
||||
}
|
||||
|
||||
const response = await this.start(prompt, previousResult);
|
||||
|
||||
// Persist assistant response if db is configured
|
||||
if (this.dbAdapter) {
|
||||
// Add assistant response to history
|
||||
this.messages.push({ role: 'assistant', content: response });
|
||||
|
||||
// Persist assistant response if db is configured and autoPersist is enabled
|
||||
if (this.dbAdapter && this.autoPersist) {
|
||||
await this.persistMessage('assistant', response);
|
||||
}
|
||||
|
||||
// Cache the response
|
||||
this.cacheResponse(prompt, response);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -464,6 +577,35 @@ export class Agent {
|
||||
getInstructions(): string {
|
||||
return this.instructions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation history
|
||||
*/
|
||||
getHistory(): Array<{ role: string; content: string | null }> {
|
||||
return [...this.messages];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear conversation history (in memory and optionally in DB)
|
||||
*/
|
||||
async clearHistory(clearDb: boolean = true): Promise<void> {
|
||||
this.messages = [];
|
||||
if (clearDb && this.dbAdapter) {
|
||||
try {
|
||||
await this.dbAdapter.deleteMessages(this.sessionId);
|
||||
Logger.debug(`Cleared history for session ${this.sessionId}`);
|
||||
} catch (error) {
|
||||
Logger.warn('Failed to clear DB history:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear response cache
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.responseCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
// ============================================================================
|
||||
|
||||
// Agent - Single agent with instructions, tools, and optional persistence
|
||||
export { Agent, Agents, PraisonAIAgents } from './agent';
|
||||
export type { SimpleAgentConfig, PraisonAIAgentsConfig } from './agent';
|
||||
export { Agent, Agents, PraisonAIAgents, Router } from './agent';
|
||||
export type { SimpleAgentConfig, PraisonAIAgentsConfig, SimpleRouterConfig, SimpleRouteConfig } from './agent';
|
||||
|
||||
// Workflow - Step-based workflow execution
|
||||
export { Workflow, parallel, route, loop, repeat } from './workflows';
|
||||
@@ -124,8 +124,12 @@ export {
|
||||
type VectorStoreAdapter as AutoMemoryVectorStore, type KnowledgeBaseAdapter as AutoMemoryKnowledgeBase
|
||||
} from './memory/auto-memory';
|
||||
|
||||
// Export Telemetry
|
||||
export { TelemetryCollector, getTelemetry, enableTelemetry, disableTelemetry, cleanupTelemetry, type TelemetryEvent, type TelemetryConfig } from './telemetry';
|
||||
// Export Telemetry (Agent-focused)
|
||||
export {
|
||||
TelemetryCollector, AgentTelemetry,
|
||||
getTelemetry, enableTelemetry, disableTelemetry, cleanupTelemetry, createAgentTelemetry,
|
||||
type TelemetryEvent, type TelemetryConfig, type AgentStats
|
||||
} from './telemetry';
|
||||
|
||||
// Export AutoAgents
|
||||
export { AutoAgents, createAutoAgents, type AgentConfig, type TaskConfig, type TeamStructure, type AutoAgentsConfig } from './auto';
|
||||
@@ -146,8 +150,14 @@ export { PromptExpanderAgent, createPromptExpanderAgent, type PromptExpanderConf
|
||||
// Export LLMGuardrail
|
||||
export { LLMGuardrail, createLLMGuardrail, type LLMGuardrailConfig, type LLMGuardrailResult } from './guardrails/llm-guardrail';
|
||||
|
||||
// Export Planning
|
||||
export { Plan, PlanStep, TodoList, TodoItem, PlanStorage, createPlan, createTodoList, createPlanStorage, type PlanConfig, type PlanStepConfig, type TodoItemConfig, type PlanStatus, type TodoStatus } from './planning';
|
||||
// Export Planning (simplified API)
|
||||
export {
|
||||
Plan, PlanStep, TodoList, TodoItem, PlanStorage,
|
||||
PlanningAgent, TaskAgent,
|
||||
createPlan, createTodoList, createPlanStorage, createPlanningAgent, createTaskAgent,
|
||||
type PlanConfig, type PlanStepConfig, type TodoItemConfig, type PlanStatus, type TodoStatus,
|
||||
type PlanningAgentConfig, type PlanResult
|
||||
} from './planning';
|
||||
|
||||
// Export Cache
|
||||
export { BaseCache, MemoryCache, FileCache, createMemoryCache, createFileCache, type CacheConfig, type CacheEntry } from './cache';
|
||||
|
||||
@@ -322,6 +322,47 @@ export class OpenAIService {
|
||||
}
|
||||
}
|
||||
|
||||
async streamChat(
|
||||
messages: ChatMessage[],
|
||||
temperature: number = 0.7,
|
||||
onToken: (token: string) => void
|
||||
): Promise<string> {
|
||||
await Logger.debug('Starting chat stream with messages...', {
|
||||
model: this.model,
|
||||
messageCount: messages.length
|
||||
});
|
||||
|
||||
try {
|
||||
const openAIMessages = messages.map(convertToOpenAIMessage);
|
||||
|
||||
const stream = await this.getClient().then(client =>
|
||||
client.chat.completions.create({
|
||||
model: this.model,
|
||||
temperature,
|
||||
messages: openAIMessages,
|
||||
stream: true
|
||||
})
|
||||
);
|
||||
|
||||
let fullResponse = '';
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta;
|
||||
if (delta?.content) {
|
||||
const token = delta.content;
|
||||
fullResponse += token;
|
||||
onToken(token);
|
||||
}
|
||||
}
|
||||
|
||||
await Logger.debug('Chat stream completed');
|
||||
return fullResponse;
|
||||
} catch (error) {
|
||||
await Logger.error('Error in chat stream', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async chatCompletion(
|
||||
messages: ChatMessage[],
|
||||
temperature: number = 0.7,
|
||||
|
||||
@@ -305,3 +305,278 @@ export function createTodoList(name?: string): TodoList {
|
||||
export function createPlanStorage(): PlanStorage {
|
||||
return new PlanStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* PlanningAgent - Agent with built-in planning capabilities
|
||||
*
|
||||
* @example Simple usage (4 lines)
|
||||
* ```typescript
|
||||
* import { PlanningAgent } from 'praisonai';
|
||||
*
|
||||
* const agent = new PlanningAgent({ instructions: 'You break down tasks into steps' });
|
||||
* const result = await agent.planAndExecute('Build a web scraper');
|
||||
* console.log(result.plan); // The plan that was created
|
||||
* console.log(result.results); // Results from each step
|
||||
* ```
|
||||
*/
|
||||
export interface PlanningAgentConfig {
|
||||
instructions?: string;
|
||||
name?: string;
|
||||
llm?: string;
|
||||
verbose?: boolean;
|
||||
maxSteps?: number;
|
||||
}
|
||||
|
||||
export interface PlanResult {
|
||||
plan: Plan;
|
||||
results: string[];
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
// Import Agent dynamically to avoid circular dependency
|
||||
let AgentClass: any = null;
|
||||
|
||||
async function getAgentClass() {
|
||||
if (!AgentClass) {
|
||||
const { Agent } = await import('../agent/simple');
|
||||
AgentClass = Agent;
|
||||
}
|
||||
return AgentClass;
|
||||
}
|
||||
|
||||
export class PlanningAgent {
|
||||
private config: PlanningAgentConfig;
|
||||
private agent: any = null;
|
||||
private currentPlan: Plan | null = null;
|
||||
|
||||
constructor(config: PlanningAgentConfig = {}) {
|
||||
this.config = {
|
||||
instructions: config.instructions || 'You are a planning agent that breaks down complex tasks into steps.',
|
||||
name: config.name || 'PlanningAgent',
|
||||
llm: config.llm,
|
||||
verbose: config.verbose ?? true,
|
||||
maxSteps: config.maxSteps ?? 10
|
||||
};
|
||||
}
|
||||
|
||||
private async getAgent() {
|
||||
if (!this.agent) {
|
||||
const Agent = await getAgentClass();
|
||||
this.agent = new Agent({
|
||||
name: this.config.name,
|
||||
instructions: this.config.instructions,
|
||||
llm: this.config.llm,
|
||||
verbose: this.config.verbose
|
||||
});
|
||||
}
|
||||
return this.agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a plan for a task
|
||||
*/
|
||||
async createPlan(task: string): Promise<Plan> {
|
||||
const agent = await this.getAgent();
|
||||
|
||||
const planPrompt = `Create a step-by-step plan for: ${task}
|
||||
|
||||
Return ONLY a numbered list of steps, one per line. Example:
|
||||
1. First step
|
||||
2. Second step
|
||||
3. Third step`;
|
||||
|
||||
const response = await agent.chat(planPrompt);
|
||||
|
||||
// Parse the response into steps
|
||||
const plan = new Plan({ name: task });
|
||||
const lines = response.split('\n').filter((l: string) => l.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
// Extract step description (remove numbering)
|
||||
const match = line.match(/^\d+[\.\)]\s*(.+)/);
|
||||
if (match) {
|
||||
plan.addStep(new PlanStep({ description: match[1].trim() }));
|
||||
}
|
||||
}
|
||||
|
||||
this.currentPlan = plan;
|
||||
return plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single step
|
||||
*/
|
||||
async executeStep(step: PlanStep): Promise<string> {
|
||||
const agent = await this.getAgent();
|
||||
step.start();
|
||||
|
||||
try {
|
||||
const response = await agent.chat(`Execute this step: ${step.description}`);
|
||||
step.complete();
|
||||
return response;
|
||||
} catch (error) {
|
||||
step.fail();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan and execute a task in one call
|
||||
*/
|
||||
async planAndExecute(task: string): Promise<PlanResult> {
|
||||
const plan = await this.createPlan(task);
|
||||
const results: string[] = [];
|
||||
let success = true;
|
||||
|
||||
plan.start();
|
||||
|
||||
for (const step of plan.steps.slice(0, this.config.maxSteps)) {
|
||||
try {
|
||||
if (this.config.verbose) {
|
||||
console.log(`[${plan.getProgress().percentage}%] Executing: ${step.description}`);
|
||||
}
|
||||
const result = await this.executeStep(step);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
success = false;
|
||||
results.push(`Error: ${error}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
plan.complete();
|
||||
}
|
||||
|
||||
return { plan, results, success };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current plan
|
||||
*/
|
||||
getPlan(): Plan | null {
|
||||
return this.currentPlan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple chat (without planning)
|
||||
*/
|
||||
async chat(message: string): Promise<string> {
|
||||
const agent = await this.getAgent();
|
||||
return agent.chat(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a planning agent
|
||||
*/
|
||||
export function createPlanningAgent(config?: PlanningAgentConfig): PlanningAgent {
|
||||
return new PlanningAgent(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskAgent - Agent with built-in todo list management
|
||||
*
|
||||
* @example Simple usage (4 lines)
|
||||
* ```typescript
|
||||
* import { TaskAgent } from 'praisonai';
|
||||
*
|
||||
* const agent = new TaskAgent();
|
||||
* await agent.addTask('Fix critical bug', 'high');
|
||||
* await agent.addTask('Write docs', 'medium');
|
||||
* console.log(agent.getPendingTasks());
|
||||
* ```
|
||||
*/
|
||||
export class TaskAgent {
|
||||
private todos: TodoList;
|
||||
private agent: any = null;
|
||||
private config: { name?: string; llm?: string; verbose?: boolean };
|
||||
|
||||
constructor(config?: { name?: string; llm?: string; verbose?: boolean }) {
|
||||
this.todos = new TodoList('Agent Tasks');
|
||||
this.config = config || {};
|
||||
}
|
||||
|
||||
private async getAgent() {
|
||||
if (!this.agent) {
|
||||
const Agent = await getAgentClass();
|
||||
this.agent = new Agent({
|
||||
name: this.config.name || 'TaskAgent',
|
||||
instructions: 'You are a task management assistant.',
|
||||
llm: this.config.llm,
|
||||
verbose: this.config.verbose ?? false
|
||||
});
|
||||
}
|
||||
return this.agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a task
|
||||
*/
|
||||
addTask(content: string, priority: 'low' | 'medium' | 'high' = 'medium'): TodoItem {
|
||||
const item = new TodoItem({ content, priority });
|
||||
this.todos.add(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a task by content (partial match)
|
||||
*/
|
||||
completeTask(contentMatch: string): boolean {
|
||||
const item = this.todos.items.find(t =>
|
||||
t.content.toLowerCase().includes(contentMatch.toLowerCase()) && t.status !== 'completed'
|
||||
);
|
||||
if (item) {
|
||||
item.complete();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending tasks
|
||||
*/
|
||||
getPendingTasks(): TodoItem[] {
|
||||
return this.todos.getPending();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tasks
|
||||
*/
|
||||
getAllTasks(): TodoItem[] {
|
||||
return [...this.todos.items];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress
|
||||
*/
|
||||
getProgress(): { completed: number; total: number; percentage: number } {
|
||||
return this.todos.getProgress();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all tasks
|
||||
*/
|
||||
clearTasks(): void {
|
||||
this.todos.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat with AI about tasks
|
||||
*/
|
||||
async chat(message: string): Promise<string> {
|
||||
const agent = await this.getAgent();
|
||||
const context = `Current tasks:\n${this.todos.items.map(t =>
|
||||
`- [${t.status}] ${t.content} (${t.priority})`
|
||||
).join('\n') || 'No tasks'}`;
|
||||
|
||||
return agent.chat(`${context}\n\nUser: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a task agent
|
||||
*/
|
||||
export function createTaskAgent(config?: { name?: string; llm?: string; verbose?: boolean }): TaskAgent {
|
||||
return new TaskAgent(config);
|
||||
}
|
||||
@@ -225,3 +225,154 @@ export function cleanupTelemetry(): void {
|
||||
globalTelemetry.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentTelemetry - Agent-focused telemetry wrapper
|
||||
*
|
||||
* @example Simple usage (3 lines)
|
||||
* ```typescript
|
||||
* import { Agent } from 'praisonai';
|
||||
*
|
||||
* // Enable telemetry on agent
|
||||
* const agent = new Agent({
|
||||
* instructions: 'You are helpful',
|
||||
* telemetry: true // Opt-in telemetry
|
||||
* });
|
||||
* await agent.chat('Hello!'); // Automatically tracked
|
||||
* ```
|
||||
*
|
||||
* @example Manual tracking
|
||||
* ```typescript
|
||||
* import { AgentTelemetry } from 'praisonai';
|
||||
*
|
||||
* const telemetry = new AgentTelemetry('MyAgent');
|
||||
* const result = await telemetry.trackChat(async () => {
|
||||
* return await agent.chat('Hello!');
|
||||
* });
|
||||
* console.log(telemetry.getStats());
|
||||
* ```
|
||||
*/
|
||||
export interface AgentStats {
|
||||
totalChats: number;
|
||||
successfulChats: number;
|
||||
failedChats: number;
|
||||
totalDuration: number;
|
||||
avgDuration: number;
|
||||
totalTokens: number;
|
||||
toolCalls: number;
|
||||
}
|
||||
|
||||
export class AgentTelemetry {
|
||||
private agentName: string;
|
||||
private collector: TelemetryCollector;
|
||||
private stats: AgentStats = {
|
||||
totalChats: 0,
|
||||
successfulChats: 0,
|
||||
failedChats: 0,
|
||||
totalDuration: 0,
|
||||
avgDuration: 0,
|
||||
totalTokens: 0,
|
||||
toolCalls: 0
|
||||
};
|
||||
|
||||
constructor(agentName: string, config?: TelemetryConfig) {
|
||||
this.agentName = agentName;
|
||||
this.collector = config ? new TelemetryCollector(config) : getTelemetry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a chat execution
|
||||
*/
|
||||
async trackChat<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const startTime = Date.now();
|
||||
this.stats.totalChats++;
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.stats.successfulChats++;
|
||||
this.stats.totalDuration += duration;
|
||||
this.stats.avgDuration = this.stats.totalDuration / this.stats.totalChats;
|
||||
|
||||
this.collector.trackAgentExecution(this.agentName, duration, true);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.stats.failedChats++;
|
||||
this.stats.totalDuration += duration;
|
||||
this.stats.avgDuration = this.stats.totalDuration / this.stats.totalChats;
|
||||
|
||||
this.collector.trackAgentExecution(this.agentName, duration, false);
|
||||
this.collector.trackError(String(error), { agent: this.agentName });
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a tool call
|
||||
*/
|
||||
trackToolCall(toolName: string, duration: number, success: boolean): void {
|
||||
this.stats.toolCalls++;
|
||||
this.collector.trackToolCall(toolName, duration, success);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track token usage
|
||||
*/
|
||||
trackTokens(tokens: number): void {
|
||||
this.stats.totalTokens += tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent statistics
|
||||
*/
|
||||
getStats(): AgentStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset statistics
|
||||
*/
|
||||
resetStats(): void {
|
||||
this.stats = {
|
||||
totalChats: 0,
|
||||
successfulChats: 0,
|
||||
failedChats: 0,
|
||||
totalDuration: 0,
|
||||
avgDuration: 0,
|
||||
totalTokens: 0,
|
||||
toolCalls: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get success rate
|
||||
*/
|
||||
getSuccessRate(): number {
|
||||
if (this.stats.totalChats === 0) return 0;
|
||||
return (this.stats.successfulChats / this.stats.totalChats) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print summary
|
||||
*/
|
||||
printSummary(): void {
|
||||
console.log(`\n📊 Agent Telemetry: ${this.agentName}`);
|
||||
console.log(` Total chats: ${this.stats.totalChats}`);
|
||||
console.log(` Success rate: ${this.getSuccessRate().toFixed(1)}%`);
|
||||
console.log(` Avg duration: ${this.stats.avgDuration.toFixed(0)}ms`);
|
||||
console.log(` Tool calls: ${this.stats.toolCalls}`);
|
||||
console.log(` Total tokens: ${this.stats.totalTokens}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create agent telemetry
|
||||
*/
|
||||
export function createAgentTelemetry(agentName: string, config?: TelemetryConfig): AgentTelemetry {
|
||||
return new AgentTelemetry(agentName, config);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.4.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.6.4.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]"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -59,7 +59,8 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
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')
|
||||
|
||||
# =============================================================================
|
||||
# Available Tools List (shared between generators) - Legacy for praisonai_tools
|
||||
|
||||
@@ -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
@@ -82,6 +82,12 @@ class TemplatesHandler:
|
||||
"cache": self.cmd_cache,
|
||||
"run": self.cmd_run,
|
||||
"init": self.cmd_init,
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -109,6 +115,13 @@ class TemplatesHandler:
|
||||
cache list List cached templates
|
||||
run <template> Run a template directly
|
||||
init <name> Initialize a project from template
|
||||
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)
|
||||
@@ -122,6 +135,8 @@ class TemplatesHandler:
|
||||
praisonai templates info transcript-generator
|
||||
praisonai templates install github:MervinPraison/agent-recipes/transcript-generator
|
||||
praisonai templates run transcript-generator ./audio.mp3
|
||||
praisonai templates add github:user/repo/my-template
|
||||
praisonai templates add-sources github:MervinPraison/Agent-Recipes
|
||||
praisonai init my-project --template transcript-generator
|
||||
"""
|
||||
try:
|
||||
@@ -744,6 +759,616 @@ class TemplatesHandler:
|
||||
i += 1
|
||||
|
||||
return config
|
||||
|
||||
def _get_templates_config_path(self):
|
||||
"""Get the path to the templates config file."""
|
||||
config_dir = Path.home() / ".praison"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
return config_dir / "templates_sources.yaml"
|
||||
|
||||
def _load_templates_config(self) -> Dict[str, Any]:
|
||||
"""Load templates config from file."""
|
||||
import yaml
|
||||
config_path = self._get_templates_config_path()
|
||||
if config_path.exists():
|
||||
with open(config_path, 'r') as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
return {"sources": []}
|
||||
|
||||
def _save_templates_config(self, config: Dict[str, Any]):
|
||||
"""Save templates config to file."""
|
||||
import yaml
|
||||
config_path = self._get_templates_config_path()
|
||||
with open(config_path, 'w') as f:
|
||||
yaml.dump(config, f, default_flow_style=False)
|
||||
|
||||
def cmd_add(self, args: List[str]) -> int:
|
||||
"""
|
||||
Add template from GitHub or local path.
|
||||
|
||||
Args:
|
||||
args: [source] - github:user/repo/template or local path
|
||||
"""
|
||||
if not args:
|
||||
print("[red]Usage: praisonai templates add <source>[/red]")
|
||||
print(" source: github:user/repo/template or local path")
|
||||
return 1
|
||||
|
||||
source = args[0]
|
||||
|
||||
# Check if it's a local directory
|
||||
if source.startswith("./") or source.startswith("/"):
|
||||
path = Path(source).resolve()
|
||||
if path.exists() and path.is_dir():
|
||||
# Check for TEMPLATE.yaml
|
||||
template_yaml = path / "TEMPLATE.yaml"
|
||||
if not template_yaml.exists():
|
||||
print(f"[red]Not a valid template: {path} (missing TEMPLATE.yaml)[/red]")
|
||||
return 1
|
||||
|
||||
# Copy to ~/.praison/templates/
|
||||
templates_dir = Path.home() / ".praison" / "templates"
|
||||
templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = templates_dir / path.name
|
||||
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
shutil.copytree(path, dest)
|
||||
|
||||
print(f"\n✅ Added template: {path.name}")
|
||||
print(f" Copied to: {dest}")
|
||||
return 0
|
||||
else:
|
||||
print(f"[red]Directory not found: {source}[/red]")
|
||||
return 1
|
||||
|
||||
# Check if it's a GitHub reference
|
||||
elif source.startswith("github:"):
|
||||
github_path = source[7:] # Remove "github:"
|
||||
parts = github_path.split("/")
|
||||
if len(parts) < 3:
|
||||
print("[red]Invalid GitHub format. Use: github:user/repo/template-name[/red]")
|
||||
return 1
|
||||
|
||||
user, repo = parts[0], parts[1]
|
||||
template_path = "/".join(parts[2:])
|
||||
|
||||
# Download from GitHub
|
||||
import urllib.request
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
# Download repo as zip
|
||||
zip_url = f"https://github.com/{user}/{repo}/archive/refs/heads/main.zip"
|
||||
print(f"Downloading from: {zip_url}")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zip_path = Path(tmpdir) / "repo.zip"
|
||||
urllib.request.urlretrieve(zip_url, zip_path)
|
||||
|
||||
# Extract
|
||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(tmpdir)
|
||||
|
||||
# Find the template
|
||||
extracted_dir = Path(tmpdir) / f"{repo}-main"
|
||||
template_src = extracted_dir / template_path
|
||||
|
||||
if not template_src.exists():
|
||||
# Try common paths
|
||||
for alt_path in [
|
||||
extracted_dir / "agent_recipes" / "templates" / template_path,
|
||||
extracted_dir / "templates" / template_path,
|
||||
]:
|
||||
if alt_path.exists():
|
||||
template_src = alt_path
|
||||
break
|
||||
|
||||
if not template_src.exists():
|
||||
print(f"[red]Template not found: {template_path}[/red]")
|
||||
return 1
|
||||
|
||||
# Copy to ~/.praison/templates/
|
||||
templates_dir = Path.home() / ".praison" / "templates"
|
||||
templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = templates_dir / template_src.name
|
||||
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
shutil.copytree(template_src, dest)
|
||||
|
||||
print(f"\n✅ Added template from GitHub: {user}/{repo}/{template_path}")
|
||||
print(f" Saved to: {dest}")
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"[red]Failed to download from GitHub: {e}[/red]")
|
||||
return 1
|
||||
|
||||
else:
|
||||
print(f"[red]Unknown source format: {source}[/red]")
|
||||
print("Use: github:user/repo/template or ./local-path")
|
||||
return 1
|
||||
|
||||
def cmd_add_sources(self, args: List[str]) -> int:
|
||||
"""
|
||||
Add a template source to persistent config.
|
||||
|
||||
Args:
|
||||
args: [source] - github:user/repo or URL
|
||||
"""
|
||||
if not args:
|
||||
print("[red]Usage: praisonai templates add-sources <source>[/red]")
|
||||
return 1
|
||||
|
||||
source = args[0]
|
||||
config = self._load_templates_config()
|
||||
|
||||
if "sources" not in config:
|
||||
config["sources"] = []
|
||||
|
||||
if source in config["sources"]:
|
||||
print(f"[yellow]Source '{source}' already in config[/yellow]")
|
||||
return 0
|
||||
|
||||
config["sources"].append(source)
|
||||
self._save_templates_config(config)
|
||||
|
||||
print(f"\n✅ Added template source: {source}")
|
||||
print(f" Config saved to: {self._get_templates_config_path()}")
|
||||
return 0
|
||||
|
||||
def cmd_remove_sources(self, args: List[str]) -> int:
|
||||
"""
|
||||
Remove a template source from persistent config.
|
||||
|
||||
Args:
|
||||
args: [source] - source to remove
|
||||
"""
|
||||
if not args:
|
||||
print("[red]Usage: praisonai templates remove-sources <source>[/red]")
|
||||
return 1
|
||||
|
||||
source = args[0]
|
||||
config = self._load_templates_config()
|
||||
|
||||
if "sources" not in config or source not in config["sources"]:
|
||||
print(f"[yellow]Source '{source}' not found in config[/yellow]")
|
||||
return 1
|
||||
|
||||
config["sources"].remove(source)
|
||||
self._save_templates_config(config)
|
||||
|
||||
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():
|
||||
|
||||
@@ -31,7 +31,7 @@ class ToolsHandler(CommandHandler):
|
||||
return "tools"
|
||||
|
||||
def get_actions(self) -> List[str]:
|
||||
return ["list", "info", "search", "doctor", "resolve", "discover", "show-sources", "help"]
|
||||
return ["list", "info", "search", "doctor", "resolve", "discover", "show-sources", "add", "add-sources", "remove-sources", "help"]
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return """
|
||||
@@ -44,6 +44,14 @@ Tools Commands:
|
||||
praisonai tools resolve <name> - Resolve a tool name to its source
|
||||
praisonai tools discover - Discover tools from installed packages
|
||||
praisonai tools show-sources - Show all tool sources for a template
|
||||
praisonai tools add <source> - Add tools from package, file, or GitHub
|
||||
praisonai tools add-sources <source> - Add a tool source to persistent config
|
||||
praisonai tools remove-sources <source> - Remove a tool source from config
|
||||
|
||||
Add Examples:
|
||||
praisonai tools add pandas - Wrap pandas functions as tools
|
||||
praisonai tools add ./my_tools.py - Add local tools file
|
||||
praisonai tools add github:user/repo - Add tools from GitHub
|
||||
|
||||
Built-in tools include: internet_search, calculator, file operations, etc.
|
||||
"""
|
||||
@@ -129,6 +137,13 @@ Built-in tools include: internet_search, calculator, file operations, etc.
|
||||
for name in uncategorized[:10]: # Limit to 10
|
||||
self.print_status(f" • {name}", "info")
|
||||
|
||||
# Show added tool sources from config
|
||||
config = self._load_config()
|
||||
if config.get("sources"):
|
||||
self.print_status("\n Added Sources (use with Agent):", "info")
|
||||
for source in config["sources"]:
|
||||
self.print_status(f" • {source}", "info")
|
||||
|
||||
return list(tools.keys())
|
||||
|
||||
def action_info(self, args: List[str], **kwargs) -> Dict[str, Any]:
|
||||
@@ -401,6 +416,11 @@ Built-in tools include: internet_search, calculator, file operations, etc.
|
||||
else:
|
||||
i += 1
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Check if tools.py exists in current directory
|
||||
cwd_tools_py = Path.cwd() / "tools.py"
|
||||
|
||||
sources = {
|
||||
"built_in": "praisonaiagents.tools.TOOL_MAPPINGS",
|
||||
"package_discovery": ["praisonai_tools"],
|
||||
@@ -408,6 +428,7 @@ Built-in tools include: internet_search, calculator, file operations, etc.
|
||||
"~/.praison/tools",
|
||||
"~/.config/praison/tools",
|
||||
],
|
||||
"cwd_tools_py": str(cwd_tools_py) if cwd_tools_py.exists() else "(not found: ./tools.py)",
|
||||
}
|
||||
|
||||
if template_name:
|
||||
@@ -441,9 +462,219 @@ Built-in tools include: internet_search, calculator, file operations, etc.
|
||||
|
||||
return sources
|
||||
|
||||
def _get_config_path(self):
|
||||
"""Get the path to the tools config file."""
|
||||
from pathlib import Path
|
||||
config_dir = Path.home() / ".praison"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
return config_dir / "tools_sources.yaml"
|
||||
|
||||
def _load_config(self) -> Dict[str, Any]:
|
||||
"""Load tools config from file."""
|
||||
config_path = self._get_config_path()
|
||||
if config_path.exists():
|
||||
import yaml
|
||||
with open(config_path, 'r') as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
return {"sources": []}
|
||||
|
||||
def _save_config(self, config: Dict[str, Any]):
|
||||
"""Save tools config to file."""
|
||||
import yaml
|
||||
config_path = self._get_config_path()
|
||||
with open(config_path, 'w') as f:
|
||||
yaml.dump(config, f, default_flow_style=False)
|
||||
|
||||
def action_add(self, args: List[str], **kwargs) -> Dict[str, Any]:
|
||||
"""
|
||||
Add tools from package, file, or GitHub.
|
||||
|
||||
Args:
|
||||
args: [source] - package name, file path, or github:user/repo
|
||||
|
||||
Examples:
|
||||
praisonai tools add pandas
|
||||
praisonai tools add ./my_tools.py
|
||||
praisonai tools add github:user/repo/tools
|
||||
"""
|
||||
if not args:
|
||||
self.print_status("Usage: praisonai tools add <source>", "error")
|
||||
self.print_status(" source: package name, file path, or github:user/repo", "info")
|
||||
return {"success": False, "error": "No source provided"}
|
||||
|
||||
source = args[0]
|
||||
result = {"source": source, "success": False, "tools": []}
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Check if it's a local file
|
||||
if source.startswith("./") or source.startswith("/") or source.endswith(".py"):
|
||||
path = Path(source).resolve()
|
||||
if path.exists():
|
||||
# Copy to ~/.praison/tools/
|
||||
tools_dir = Path.home() / ".praison" / "tools"
|
||||
tools_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = tools_dir / path.name
|
||||
|
||||
import shutil
|
||||
shutil.copy(path, dest)
|
||||
|
||||
self.print_status(f"\n✅ Added tools file: {path.name}", "success")
|
||||
self.print_status(f" Copied to: {dest}", "info")
|
||||
|
||||
# Discover tools in the file
|
||||
try:
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("tools_module", dest)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
tools = [n for n in dir(module) if not n.startswith('_') and callable(getattr(module, n, None))]
|
||||
result["tools"] = tools
|
||||
self.print_status(f" Found {len(tools)} tools: {', '.join(tools[:5])}", "info")
|
||||
except Exception as e:
|
||||
self.print_status(f" Warning: Could not inspect tools: {e}", "warning")
|
||||
|
||||
result["success"] = True
|
||||
return result
|
||||
else:
|
||||
self.print_status(f"File not found: {source}", "error")
|
||||
return result
|
||||
|
||||
# Check if it's a GitHub reference
|
||||
elif source.startswith("github:"):
|
||||
github_path = source[7:] # Remove "github:"
|
||||
parts = github_path.split("/")
|
||||
if len(parts) < 2:
|
||||
self.print_status("Invalid GitHub format. Use: github:user/repo/path", "error")
|
||||
return result
|
||||
|
||||
user, repo = parts[0], parts[1]
|
||||
path = "/".join(parts[2:]) if len(parts) > 2 else ""
|
||||
|
||||
# Download from GitHub
|
||||
import urllib.request
|
||||
|
||||
if path:
|
||||
raw_url = f"https://raw.githubusercontent.com/{user}/{repo}/main/{path}"
|
||||
if not path.endswith(".py"):
|
||||
raw_url += "/tools.py"
|
||||
else:
|
||||
raw_url = f"https://raw.githubusercontent.com/{user}/{repo}/main/tools.py"
|
||||
|
||||
try:
|
||||
self.print_status(f"Downloading from: {raw_url}", "info")
|
||||
|
||||
tools_dir = Path.home() / ".praison" / "tools"
|
||||
tools_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = f"{user}_{repo}_{path.replace('/', '_')}.py" if path else f"{user}_{repo}_tools.py"
|
||||
dest = tools_dir / filename
|
||||
|
||||
urllib.request.urlretrieve(raw_url, dest)
|
||||
|
||||
self.print_status(f"\n✅ Added tools from GitHub: {user}/{repo}", "success")
|
||||
self.print_status(f" Saved to: {dest}", "info")
|
||||
|
||||
result["success"] = True
|
||||
return result
|
||||
except Exception as e:
|
||||
self.print_status(f"Failed to download from GitHub: {e}", "error")
|
||||
return result
|
||||
|
||||
# Assume it's a package name - but warn that packages need wrapper tools
|
||||
else:
|
||||
try:
|
||||
import importlib
|
||||
module = importlib.import_module(source)
|
||||
|
||||
# Get callable functions from the module
|
||||
tools = [n for n in dir(module) if not n.startswith('_') and callable(getattr(module, n, None))]
|
||||
|
||||
self.print_status(f"\n⚠️ Package '{source}' is installed but NOT directly usable as tools", "warning")
|
||||
self.print_status(f" Found {len(tools)} callable items in package", "info")
|
||||
self.print_status("", "info")
|
||||
self.print_status(" To use this package with agents, create wrapper tools:", "info")
|
||||
self.print_status(" 1. Create a file: ~/.praison/tools/my_tools.py", "info")
|
||||
self.print_status(" 2. Define wrapper functions with docstrings", "info")
|
||||
self.print_status(" 3. Tools will be auto-discovered", "info")
|
||||
self.print_status("", "info")
|
||||
self.print_status(" Example wrapper:", "info")
|
||||
self.print_status(f" def my_{source}_tool(data: str) -> str:", "info")
|
||||
self.print_status(f' """Use {source} to process data."""', "info")
|
||||
self.print_status(f" import {source}", "info")
|
||||
self.print_status(" # Your logic here", "info")
|
||||
self.print_status(" return result", "info")
|
||||
|
||||
result["success"] = True
|
||||
result["tools"] = tools[:10]
|
||||
result["note"] = "Package requires wrapper tools"
|
||||
return result
|
||||
except ImportError:
|
||||
self.print_status(f"Package '{source}' not found. Install with: pip install {source}", "error")
|
||||
return result
|
||||
|
||||
def action_add_sources(self, args: List[str], **kwargs) -> Dict[str, Any]:
|
||||
"""
|
||||
Add a tool source to persistent config.
|
||||
|
||||
Args:
|
||||
args: [source] - package name, file path, or github:user/repo
|
||||
"""
|
||||
if not args:
|
||||
self.print_status("Usage: praisonai tools add-sources <source>", "error")
|
||||
return {"success": False, "error": "No source provided"}
|
||||
|
||||
source = args[0]
|
||||
config = self._load_config()
|
||||
|
||||
if "sources" not in config:
|
||||
config["sources"] = []
|
||||
|
||||
if source in config["sources"]:
|
||||
self.print_status(f"Source '{source}' already in config", "warning")
|
||||
return {"success": True, "already_exists": True}
|
||||
|
||||
config["sources"].append(source)
|
||||
self._save_config(config)
|
||||
|
||||
self.print_status(f"\n✅ Added tool source: {source}", "success")
|
||||
self.print_status(f" Config saved to: {self._get_config_path()}", "info")
|
||||
|
||||
return {"success": True, "source": source}
|
||||
|
||||
def action_remove_sources(self, args: List[str], **kwargs) -> Dict[str, Any]:
|
||||
"""
|
||||
Remove a tool source from persistent config.
|
||||
|
||||
Args:
|
||||
args: [source] - source to remove
|
||||
"""
|
||||
if not args:
|
||||
self.print_status("Usage: praisonai tools remove-sources <source>", "error")
|
||||
return {"success": False, "error": "No source provided"}
|
||||
|
||||
source = args[0]
|
||||
config = self._load_config()
|
||||
|
||||
if "sources" not in config or source not in config["sources"]:
|
||||
self.print_status(f"Source '{source}' not found in config", "warning")
|
||||
return {"success": False, "not_found": True}
|
||||
|
||||
config["sources"].remove(source)
|
||||
self._save_config(config)
|
||||
|
||||
self.print_status(f"\n✅ Removed tool source: {source}", "success")
|
||||
|
||||
return {"success": True, "source": source}
|
||||
|
||||
def execute(self, action: str, action_args: List[str], **kwargs) -> Any:
|
||||
"""Execute tools command action."""
|
||||
# Handle hyphenated action names
|
||||
if action == "show-sources":
|
||||
return self.action_show_sources(action_args, **kwargs)
|
||||
elif action == "add-sources":
|
||||
return self.action_add_sources(action_args, **kwargs)
|
||||
elif action == "remove-sources":
|
||||
return self.action_remove_sources(action_args, **kwargs)
|
||||
return super().execute(action, action_args, **kwargs)
|
||||
@@ -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']
|
||||
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:
|
||||
@@ -1308,7 +1337,8 @@ class PraisonAI:
|
||||
elif args.command in ['audio', 'embed', 'images', 'moderate', 'files', 'batches',
|
||||
'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning',
|
||||
'completions', 'messages', 'guardrails', 'rag', 'videos',
|
||||
'a2a', 'containers', 'passthrough', 'responses', 'search']:
|
||||
'a2a', 'containers', 'passthrough', 'responses', 'search',
|
||||
'realtime-api']:
|
||||
from .features.capabilities import CapabilitiesHandler
|
||||
|
||||
cmd_map = {
|
||||
@@ -1333,12 +1363,20 @@ class PraisonAI:
|
||||
'passthrough': CapabilitiesHandler.handle_passthrough,
|
||||
'responses': CapabilitiesHandler.handle_responses,
|
||||
'search': CapabilitiesHandler.handle_search,
|
||||
'realtime-api': CapabilitiesHandler.handle_realtime,
|
||||
}
|
||||
|
||||
handler = cmd_map.get(args.command)
|
||||
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):
|
||||
@@ -4282,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
|
||||
|
||||
@@ -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.4 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')
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user