mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-09 03:39:37 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
397874ccfc | ||
|
|
2016902e9a | ||
|
|
d2d19511da | ||
|
|
bc9579b497 | ||
|
|
3161389341 | ||
|
|
b01eb2b8fa | ||
|
|
e609fb7425 | ||
|
|
168ef98473 | ||
|
|
ce2c2926f9 | ||
|
|
0a569e2b46 | ||
|
|
054aea8f66 | ||
|
|
34f1293cfc | ||
|
|
7c1e32afc0 | ||
|
|
1125c835ec |
No files matched your search
+4
-1
@@ -77,4 +77,7 @@ agents/praisonaiagents/praisonaiagents.egg-info
|
||||
.qodo
|
||||
CopilotKit*
|
||||
.gemini
|
||||
issue_details.json
|
||||
issue_details.json
|
||||
|
||||
# MCP Registry tokens
|
||||
.mcpregistry_*
|
||||
@@ -6,10 +6,13 @@
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<!-- mcp-name: io.github.MervinPraison/praisonai -->
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/MervinPraison/PraisonAI"><img src="https://static.pepy.tech/badge/PraisonAI" alt="Total Downloads" /></a>
|
||||
<a href="https://github.com/MervinPraison/PraisonAI"><img src="https://img.shields.io/github/v/release/MervinPraison/PraisonAI" alt="Latest Stable Version" /></a>
|
||||
<a href="https://github.com/MervinPraison/PraisonAI"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License" /></a>
|
||||
<a href="https://registry.modelcontextprotocol.io/servers/io.github.MervinPraison/praisonai"><img src="https://img.shields.io/badge/MCP-Registry-blue" alt="MCP Registry" /></a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
@@ -188,8 +191,14 @@ npm install praisonai
|
||||
| 📚 Add Custom Knowledge | [Example](examples/python/concepts/knowledge-agents.py) | [📖](https://docs.praison.ai/features/knowledge) |
|
||||
| 🧠 Memory (Short & Long Term) | [Example](examples/python/general/memory_example.py) | [📖](https://docs.praison.ai/concepts/memory) |
|
||||
| 📄 Chat with PDF Agents | [Example](examples/python/concepts/chat-with-pdf.py) | [📖](https://docs.praison.ai/features/chat-with-pdf) |
|
||||
| 💻 Code Interpreter Agents | [Example](examples/python/agents/code-agent.py) | [📖](https://docs.praison.ai/features/codeagent) |
|
||||
| ✏️ AI Code Editing Tools | [Example](#ai-code-editing) | [📖](https://docs.praison.ai/features/code) |
|
||||
| **💻 Code** | | |
|
||||
| ↳ Code Interpreter Agents | [Example](examples/python/agents/code-agent.py) | [📖](https://docs.praison.ai/features/codeagent) |
|
||||
| ↳ AI Code Editing Tools | [Example](examples/python/code/code_editing_example.py) | [📖](https://docs.praison.ai/code/editing) |
|
||||
| ↳ External Agents (All) | [Example](examples/python/code/external_agents_example.py) | [📖](https://docs.praison.ai/code/external-agents) |
|
||||
| ↳ Claude Code CLI | [Example](examples/python/code/claude_code_example.py) | [📖](https://docs.praison.ai/code/claude-code) |
|
||||
| ↳ Gemini CLI | [Example](examples/python/code/gemini_cli_example.py) | [📖](https://docs.praison.ai/code/gemini-cli) |
|
||||
| ↳ Codex CLI | [Example](examples/python/code/codex_cli_example.py) | [📖](https://docs.praison.ai/code/codex-cli) |
|
||||
| ↳ Cursor CLI | [Example](examples/python/code/cursor_cli_example.py) | [📖](https://docs.praison.ai/code/cursor-cli) |
|
||||
| 📚 RAG Agents | [Example](examples/python/concepts/rag-agents.py) | [📖](https://docs.praison.ai/features/rag) |
|
||||
| 🤔 Async & Parallel Processing | [Example](examples/python/general/async_example.py) | [📖](https://docs.praison.ai/features/async) |
|
||||
| 🔢 Math Agents | [Example](examples/python/agents/math-agent.py) | [📖](https://docs.praison.ai/features/mathagent) |
|
||||
@@ -207,6 +216,7 @@ npm install praisonai
|
||||
| 🧠 Claude Memory Tool | [Example](#claude-memory-tool-cli) | [📖](https://docs.praison.ai/features/claude-memory-tool) |
|
||||
| 💾 File-Based Memory | [Example](examples/python/general/memory_example.py) | [📖](https://docs.praison.ai/concepts/memory) |
|
||||
| 🔍 Built-in Search Tools | [Example](examples/python/agents/websearch-agent.py) | [📖](https://docs.praison.ai/tools/tavily) |
|
||||
| 🔎 Unified Web Search | [Example](src/praisonai-agents/examples/web_search_example.py) | [📖](https://docs.praison.ai/tools/web-search) |
|
||||
| 📋 Planning Mode | [Example](examples/python/agents/planning-agent.py) | [📖](https://docs.praison.ai/features/planning-mode) |
|
||||
| 🔧 Planning Tools | [Example](#3-agent-with-planning-mode) | [📖](https://docs.praison.ai/features/planning-mode) |
|
||||
| 🧠 Planning Reasoning | [Example](#3-agent-with-planning-mode) | [📖](https://docs.praison.ai/features/planning-mode) |
|
||||
@@ -226,7 +236,7 @@ npm install praisonai
|
||||
| 💻 Programming Agent | [Example](examples/python/agents/programming-agent.py) | [📖](https://docs.praison.ai/agents/programming) |
|
||||
| 📝 Markdown Agent | [Example](examples/python/agents/markdown-agent.py) | [📖](https://docs.praison.ai/agents/markdown) |
|
||||
| 📝 Prompt Expander Agent | [Example](#prompt-expansion) | [📖](https://docs.praison.ai/agents/prompt-expander) |
|
||||
| 🔀 Router Agent | [Example](examples/python/agents/router-agent-cost-optimization.py) | [📖](https://docs.praison.ai/features/routing) |
|
||||
| 🔀 Model Router | [Example](examples/python/agents/router-agent-cost-optimization.py) | [📖](https://docs.praison.ai/features/model-router) |
|
||||
| ⛓️ Prompt Chaining | [Example](examples/python/general/prompt_chaining.py) | [📖](https://docs.praison.ai/features/promptchaining) |
|
||||
| 🔍 Evaluator Optimiser | [Example](examples/python/general/evaluator-optimiser.py) | [📖](https://docs.praison.ai/features/evaluator-optimiser) |
|
||||
| 👷 Orchestrator Workers | [Example](examples/python/general/orchestrator-workers.py) | [📖](https://docs.praison.ai/features/orchestrator-worker) |
|
||||
@@ -248,6 +258,14 @@ npm install praisonai
|
||||
| @ @Mentions in Prompts | [Example](#mentions-in-prompts) | [📖](https://docs.praison.ai/cli/mentions) |
|
||||
| 💾 Auto-Save Sessions | [Example](#session-management-python) | [📖](https://docs.praison.ai/cli/session) |
|
||||
| 📜 History in Context | [Example](#session-management-python) | [📖](https://docs.praison.ai/cli/session) |
|
||||
| **🖥️ CLI Features** | | |
|
||||
| ↳ Slash Commands | [Example](examples/python/cli/slash_commands_example.py) | [📖](https://docs.praison.ai/cli/slash-commands) |
|
||||
| ↳ Autonomy Modes | [Example](examples/python/cli/autonomy_modes_example.py) | [📖](https://docs.praison.ai/cli/autonomy-modes) |
|
||||
| ↳ Cost Tracking | [Example](examples/python/cli/cost_tracking_example.py) | [📖](https://docs.praison.ai/cli/cost-tracking) |
|
||||
| ↳ Repository Map | [Example](examples/python/cli/repo_map_example.py) | [📖](https://docs.praison.ai/cli/repo-map) |
|
||||
| ↳ Interactive TUI | [Example](examples/python/cli/interactive_tui_example.py) | [📖](https://docs.praison.ai/cli/interactive-tui) |
|
||||
| ↳ Git Integration | [Example](examples/python/cli/git_integration_example.py) | [📖](https://docs.praison.ai/cli/git-integration) |
|
||||
| ↳ Sandbox Execution | [Example](examples/python/cli/sandbox_execution_example.py) | [📖](https://docs.praison.ai/cli/sandbox-execution) |
|
||||
|
||||
---
|
||||
|
||||
@@ -1120,6 +1138,15 @@ praisonai workflow auto "Research AI trends" --pattern parallel
|
||||
# Generate routing workflow (classifier routes to specialists)
|
||||
praisonai workflow auto "Build a chatbot" --pattern routing
|
||||
|
||||
# Generate orchestrator-workers workflow (central orchestrator delegates)
|
||||
praisonai workflow auto "Comprehensive market analysis" --pattern orchestrator-workers
|
||||
|
||||
# Generate evaluator-optimizer workflow (iterative refinement)
|
||||
praisonai workflow auto "Write and refine article" --pattern evaluator-optimizer
|
||||
|
||||
# Specify output file
|
||||
praisonai workflow auto "Build a chatbot" --pattern routing
|
||||
|
||||
# Specify output file
|
||||
praisonai workflow auto "Research AI" --pattern sequential --output my_workflow.yaml
|
||||
```
|
||||
@@ -1129,7 +1156,7 @@ praisonai workflow auto "Research AI" --pattern sequential --output my_workflow.
|
||||
|------|-------------|
|
||||
| `--workflow-var key=value` | Set workflow variable (can be repeated) |
|
||||
| `--var key=value` | Set variable for YAML workflows |
|
||||
| `--pattern <pattern>` | Pattern for auto-generation (sequential, parallel, routing, loop) |
|
||||
| `--pattern <pattern>` | Pattern for auto-generation (sequential, parallel, routing, loop, orchestrator-workers, evaluator-optimizer) |
|
||||
| `--output <file>` | Output file for auto-generation |
|
||||
| `--llm <model>` | LLM model (e.g., openai/gpt-4o-mini) |
|
||||
| `--tools <tools>` | Tools (comma-separated, e.g., tavily) |
|
||||
@@ -1361,8 +1388,13 @@ praisonai "Complex analysis" --router --router-provider anthropic
|
||||
# Router automatically selects:
|
||||
# - Simple tasks → gpt-4o-mini, claude-3-haiku
|
||||
# - Complex tasks → gpt-4-turbo, claude-3-opus
|
||||
|
||||
# Create workflow with model routing template
|
||||
praisonai workflow create --template model-routing --output my_workflow.yaml
|
||||
```
|
||||
|
||||
Custom models can be configured in `agents.yaml`. See [Model Router Docs](https://docs.praison.ai/features/model-router) for details.
|
||||
|
||||
### Flow Display CLI:
|
||||
```bash
|
||||
# Enable visual workflow tracking
|
||||
@@ -1451,6 +1483,55 @@ export N8N_API_KEY="your-api-key"
|
||||
praisonai agents.yaml --n8n
|
||||
```
|
||||
|
||||
### External Agents CLI:
|
||||
|
||||
Use external AI coding CLI tools (Claude Code, Gemini CLI, Codex CLI, Cursor CLI) as agent tools:
|
||||
|
||||
```bash
|
||||
# Use Claude Code for coding tasks
|
||||
praisonai "Refactor the auth module" --external-agent claude
|
||||
|
||||
# Use Gemini CLI for code analysis
|
||||
praisonai "Analyze codebase architecture" --external-agent gemini
|
||||
|
||||
# Use OpenAI Codex CLI
|
||||
praisonai "Fix all bugs in src/" --external-agent codex
|
||||
|
||||
# Use Cursor CLI
|
||||
praisonai "Add comprehensive tests" --external-agent cursor
|
||||
```
|
||||
|
||||
**Python API:**
|
||||
```python
|
||||
from praisonai.integrations import (
|
||||
ClaudeCodeIntegration,
|
||||
GeminiCLIIntegration,
|
||||
CodexCLIIntegration,
|
||||
CursorCLIIntegration
|
||||
)
|
||||
|
||||
# Create integration
|
||||
claude = ClaudeCodeIntegration(workspace="/project")
|
||||
|
||||
# Execute a coding task
|
||||
result = await claude.execute("Refactor the auth module")
|
||||
|
||||
# Use as agent tool
|
||||
from praisonai import Agent
|
||||
tool = claude.as_tool()
|
||||
agent = Agent(tools=[tool])
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=your-key # Claude Code
|
||||
export GEMINI_API_KEY=your-key # Gemini CLI
|
||||
export OPENAI_API_KEY=your-key # Codex CLI
|
||||
export CURSOR_API_KEY=your-key # Cursor CLI
|
||||
```
|
||||
|
||||
See [External Agents Documentation](https://docs.praison.ai/code/external-agents) for more details.
|
||||
|
||||
### @Mentions in Prompts:
|
||||
```bash
|
||||
# Include file content in prompt
|
||||
@@ -2253,7 +2334,7 @@ PraisonAI provides zero-dependency persistent memory for agents. For detailed ex
|
||||
- 📖 **Wikipedia Agent** - Search and extract Wikipedia information
|
||||
- 💻 **Programming Agent** - Code development and analysis
|
||||
- 📝 **Markdown Agent** - Generate and format Markdown content
|
||||
- 🔀 **Router Agent** - Dynamic task routing with cost optimisation
|
||||
- 🔀 **Model Router** - Smart model selection based on task complexity
|
||||
|
||||
### MCP Protocol
|
||||
|
||||
|
||||
@@ -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.3.40" \
|
||||
"praisonai>=2.3.45" \
|
||||
"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.3.40" \
|
||||
"praisonai>=2.3.45" \
|
||||
"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.3.40" \
|
||||
"praisonai>=2.3.45" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# PraisonAI Examples
|
||||
|
||||
This folder contains examples for PraisonAI. For detailed documentation, visit [docs.praison.ai](https://docs.praison.ai).
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
examples/
|
||||
├── python/ # Python examples
|
||||
│ ├── agents/ # Agent examples (single, multi, router, etc.)
|
||||
│ ├── workflows/ # Workflow patterns (routing, parallel, loop)
|
||||
│ ├── tools/ # Custom tools examples
|
||||
│ ├── mcp/ # MCP protocol examples
|
||||
│ ├── memory/ # Memory and sessions
|
||||
│ ├── code/ # Code editing and external CLI tools
|
||||
│ └── ...
|
||||
├── yaml/ # YAML workflow examples
|
||||
└── cookbooks/ # Complete use-case examples
|
||||
```
|
||||
|
||||
## Quick Links
|
||||
|
||||
| Category | Examples | Docs |
|
||||
|----------|----------|------|
|
||||
| **Agents** | [python/agents/](python/agents/) | [📖](https://docs.praison.ai/concepts/agents) |
|
||||
| **Workflows** | [python/workflows/](python/workflows/) | [📖](https://docs.praison.ai/features/workflows) |
|
||||
| **Model Router** | [python/agents/router-agent-cost-optimization.py](python/agents/router-agent-cost-optimization.py) | [📖](https://docs.praison.ai/features/model-router) |
|
||||
| **MCP** | [python/mcp/](python/mcp/) | [📖](https://docs.praison.ai/mcp) |
|
||||
| **Memory** | [python/memory/](python/memory/) | [📖](https://docs.praison.ai/concepts/memory) |
|
||||
| **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) |
|
||||
|
||||
## Running Examples
|
||||
|
||||
```bash
|
||||
# Install PraisonAI
|
||||
pip install praisonai
|
||||
|
||||
# Set API key
|
||||
export OPENAI_API_KEY=your_key_here
|
||||
|
||||
# Run an example
|
||||
python examples/python/agents/single-agent.py
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
See the main [README.md](../README.md#-cli--no-code-interface) for all CLI commands.
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
AutoAgents Workflow Patterns Example
|
||||
|
||||
This example demonstrates the new workflow auto-generation features:
|
||||
- Dynamic agent count based on task complexity
|
||||
- Multiple workflow patterns (sequential, parallel, routing, orchestrator-workers, evaluator-optimizer)
|
||||
- Pattern recommendation based on task keywords
|
||||
|
||||
Documentation: https://docs.praison.ai/features/autoagents
|
||||
CLI Reference: https://docs.praison.ai/nocode/auto
|
||||
"""
|
||||
|
||||
from praisonai.auto import AutoGenerator, WorkflowAutoGenerator
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
# Ensure API key is set
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("Please set OPENAI_API_KEY environment variable")
|
||||
exit(1)
|
||||
|
||||
print("=" * 60)
|
||||
print("AutoAgents Workflow Patterns Demo")
|
||||
print("=" * 60)
|
||||
|
||||
# =============================================================================
|
||||
# Example 1: Dynamic Agent Count (Simple vs Complex Tasks)
|
||||
# =============================================================================
|
||||
print("\n📊 Example 1: Dynamic Agent Count")
|
||||
print("-" * 40)
|
||||
|
||||
# Simple task - should create 1-2 agents
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
||||
simple_file = f.name
|
||||
|
||||
simple_generator = AutoGenerator(
|
||||
topic="Write a haiku about spring",
|
||||
agent_file=simple_file,
|
||||
framework="praisonai"
|
||||
)
|
||||
|
||||
print("Simple task: 'Write a haiku about spring'")
|
||||
result = simple_generator.generate()
|
||||
print(f"Generated: {result}")
|
||||
|
||||
# Complex task - should create 3-4 agents
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
||||
complex_file = f.name
|
||||
|
||||
complex_generator = AutoGenerator(
|
||||
topic="Research AI trends, analyze market data, write a comprehensive report with executive summary",
|
||||
agent_file=complex_file,
|
||||
framework="praisonai"
|
||||
)
|
||||
|
||||
print("\nComplex task: 'Research AI trends, analyze market data...'")
|
||||
result = complex_generator.generate()
|
||||
print(f"Generated: {result}")
|
||||
|
||||
# =============================================================================
|
||||
# Example 2: Pattern Recommendation
|
||||
# =============================================================================
|
||||
print("\n🎯 Example 2: Pattern Recommendation")
|
||||
print("-" * 40)
|
||||
|
||||
test_tasks = [
|
||||
("Write a blog post", "sequential"),
|
||||
("Research from multiple sources simultaneously", "parallel"),
|
||||
("Classify and route customer requests", "routing"),
|
||||
("Comprehensive analysis and break down the problem", "orchestrator-workers"),
|
||||
("Refine and improve content quality", "evaluator-optimizer"),
|
||||
]
|
||||
|
||||
for task, expected in test_tasks:
|
||||
generator = WorkflowAutoGenerator(topic=task)
|
||||
recommended = generator.recommend_pattern()
|
||||
status = "✅" if recommended == expected else "⚠️"
|
||||
print(f"{status} Task: '{task[:40]}...'")
|
||||
print(f" Recommended: {recommended}")
|
||||
|
||||
# =============================================================================
|
||||
# Example 3: Generate Workflow with Specific Pattern
|
||||
# =============================================================================
|
||||
print("\n🔄 Example 3: Generate Workflows with Patterns")
|
||||
print("-" * 40)
|
||||
|
||||
patterns = ["sequential", "parallel", "orchestrator-workers", "evaluator-optimizer"]
|
||||
|
||||
for pattern in patterns:
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
||||
workflow_file = f.name
|
||||
|
||||
generator = WorkflowAutoGenerator(
|
||||
topic="Create a market analysis report",
|
||||
workflow_file=workflow_file
|
||||
)
|
||||
|
||||
print(f"\n📋 Pattern: {pattern}")
|
||||
result = generator.generate(pattern=pattern)
|
||||
print(f" Generated: {result}")
|
||||
|
||||
# Read and show structure
|
||||
import yaml
|
||||
with open(result, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
print(f" Agents: {list(data.get('agents', {}).keys())}")
|
||||
print(f" Steps: {len(data.get('steps', []))}")
|
||||
|
||||
# =============================================================================
|
||||
# Example 4: CLI Usage (for reference)
|
||||
# =============================================================================
|
||||
print("\n💻 Example 4: CLI Commands (for reference)")
|
||||
print("-" * 40)
|
||||
print("""
|
||||
# Auto-generate agents (dynamic count)
|
||||
praisonai --auto "Write a haiku about spring"
|
||||
|
||||
# Auto-generate workflow with pattern
|
||||
praisonai workflow auto "Research and write a report" --pattern sequential
|
||||
praisonai workflow auto "Research from multiple sources" --pattern parallel
|
||||
praisonai workflow auto "Comprehensive market analysis" --pattern orchestrator-workers
|
||||
praisonai workflow auto "Refine content quality" --pattern evaluator-optimizer
|
||||
|
||||
# With output file
|
||||
praisonai workflow auto "Customer support routing" --pattern routing --output support.yaml
|
||||
""")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ AutoAgents Workflow Patterns Demo Complete!")
|
||||
print("=" * 60)
|
||||
print("\nDocumentation: https://docs.praison.ai/features/autoagents")
|
||||
print("CLI Reference: https://docs.praison.ai/nocode/auto")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Autonomy Modes Example for PraisonAI CLI.
|
||||
|
||||
Control AI autonomy: suggest, auto_edit, full_auto.
|
||||
Docs: https://docs.praison.ai/cli/autonomy-modes
|
||||
"""
|
||||
|
||||
from praisonai.cli.features import AutonomyModeHandler
|
||||
from praisonai.cli.features.autonomy_mode import ActionRequest, ActionType
|
||||
|
||||
# Initialize with suggest mode (safest)
|
||||
handler = AutonomyModeHandler()
|
||||
manager = handler.initialize(mode="suggest")
|
||||
|
||||
print(f"Current mode: {handler.get_mode()}")
|
||||
print("Available modes: suggest, auto_edit, full_auto")
|
||||
|
||||
# Create an action request
|
||||
action = ActionRequest(
|
||||
action_type=ActionType.FILE_WRITE,
|
||||
description="Edit src/main.py to add logging",
|
||||
details={"file": "src/main.py"}
|
||||
)
|
||||
|
||||
# In suggest mode, this would require approval
|
||||
# For demo, we'll just show the action
|
||||
print(f"\nAction: {action.description}")
|
||||
print(f"Type: {action.action_type.value}")
|
||||
print("Would require approval in 'suggest' mode")
|
||||
|
||||
# Change to auto_edit mode
|
||||
handler.set_mode("auto_edit")
|
||||
print(f"\nChanged to: {handler.get_mode()}")
|
||||
print("File edits now auto-approved, shell commands still require approval")
|
||||
|
||||
# Get statistics
|
||||
stats = handler.get_stats()
|
||||
print(f"\nStats: {stats}")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Cost Tracking Example for PraisonAI CLI.
|
||||
|
||||
Real-time token usage and cost monitoring.
|
||||
Docs: https://docs.praison.ai/cli/cost-tracking
|
||||
"""
|
||||
|
||||
from praisonai.cli.features import CostTrackerHandler
|
||||
|
||||
# Initialize tracker
|
||||
handler = CostTrackerHandler()
|
||||
tracker = handler.initialize(session_id="demo-session")
|
||||
|
||||
# Track some requests
|
||||
handler.track_request("gpt-4o", input_tokens=1000, output_tokens=500)
|
||||
handler.track_request("gpt-4o-mini", input_tokens=2000, output_tokens=800)
|
||||
handler.track_request("claude-3-5-sonnet", input_tokens=1500, output_tokens=600)
|
||||
|
||||
# Get totals
|
||||
print(f"Total tokens: {handler.get_tokens():,}")
|
||||
print(f"Total cost: ${handler.get_cost():.4f}")
|
||||
|
||||
# Get detailed summary
|
||||
summary = handler.get_summary()
|
||||
print("\n=== Session Summary ===")
|
||||
print(f"Session ID: {summary['session_id']}")
|
||||
print(f"Total requests: {summary['total_requests']}")
|
||||
print(f"Input tokens: {summary['total_input_tokens']:,}")
|
||||
print(f"Output tokens: {summary['total_output_tokens']:,}")
|
||||
print(f"Total cost: ${summary['total_cost']:.4f}")
|
||||
print(f"Avg cost/request: ${summary['avg_cost_per_request']:.4f}")
|
||||
|
||||
# Display formatted (uses Rich if available)
|
||||
handler.display_summary()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Git Integration Example for PraisonAI CLI.
|
||||
|
||||
Auto-commit with AI messages, diff viewing, undo.
|
||||
Docs: https://docs.praison.ai/cli/git-integration
|
||||
"""
|
||||
|
||||
from praisonai.cli.features import GitIntegrationHandler
|
||||
|
||||
# Initialize
|
||||
handler = GitIntegrationHandler(verbose=True)
|
||||
git = handler.initialize(repo_path=".")
|
||||
|
||||
# Check if it's a git repo
|
||||
if not git.is_repo:
|
||||
print("Not a git repository!")
|
||||
exit(1)
|
||||
|
||||
# Show status
|
||||
print("=== Git Status ===")
|
||||
status = handler.show_status()
|
||||
print(f"Branch: {status.branch}")
|
||||
print(f"Staged: {len(status.staged_files)}")
|
||||
print(f"Modified: {len(status.modified_files)}")
|
||||
print(f"Untracked: {len(status.untracked_files)}")
|
||||
|
||||
# Show diff (if any changes)
|
||||
if status.has_changes:
|
||||
print("\n=== Diff ===")
|
||||
diff = handler.show_diff()
|
||||
if diff:
|
||||
print(diff[:300] + "..." if len(diff) > 300 else diff)
|
||||
|
||||
# Show recent commits
|
||||
print("\n=== Recent Commits ===")
|
||||
commits = handler.show_log(count=5)
|
||||
for c in commits:
|
||||
print(f" {c.short_hash} {c.message[:50]}")
|
||||
|
||||
# To commit with AI message:
|
||||
# commit = handler.commit() # Auto-generates message
|
||||
# commit = handler.commit(message="Custom message")
|
||||
|
||||
# To undo last commit:
|
||||
# handler.undo(soft=True) # Keeps changes staged
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Interactive TUI Example for PraisonAI CLI.
|
||||
|
||||
Rich terminal interface with completions and history.
|
||||
Docs: https://docs.praison.ai/cli/interactive-tui
|
||||
"""
|
||||
|
||||
from praisonai.cli.features import InteractiveTUIHandler
|
||||
from praisonai.cli.features.interactive_tui import InteractiveConfig
|
||||
|
||||
# Custom configuration
|
||||
config = InteractiveConfig(
|
||||
prompt="ai> ",
|
||||
multiline=True,
|
||||
enable_completions=True,
|
||||
show_status_bar=True
|
||||
)
|
||||
|
||||
# Define callbacks
|
||||
def on_input(text):
|
||||
"""Handle regular input."""
|
||||
return f"You said: {text}"
|
||||
|
||||
def on_command(cmd):
|
||||
"""Handle slash commands."""
|
||||
if cmd == "/exit":
|
||||
return {"type": "exit"}
|
||||
return {"type": "command", "message": f"Executed: {cmd}"}
|
||||
|
||||
# Initialize
|
||||
handler = InteractiveTUIHandler()
|
||||
session = handler.initialize(
|
||||
config=config,
|
||||
on_input=on_input,
|
||||
on_command=on_command
|
||||
)
|
||||
|
||||
# Add commands for completion
|
||||
session.add_commands(["help", "exit", "cost", "model", "plan"])
|
||||
|
||||
# Add symbols from codebase
|
||||
session.add_symbols(["MyClass", "my_function"])
|
||||
|
||||
print("Interactive TUI initialized!")
|
||||
print("Commands available: /help, /exit, /cost, /model, /plan")
|
||||
print("Run handler.run() to start interactive session")
|
||||
|
||||
# Uncomment to run interactive session:
|
||||
# handler.run()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Repository Map Example for PraisonAI CLI.
|
||||
|
||||
Intelligent codebase mapping with symbol extraction.
|
||||
Docs: https://docs.praison.ai/cli/repo-map
|
||||
"""
|
||||
|
||||
from praisonai.cli.features import RepoMapHandler
|
||||
|
||||
# Initialize with current directory
|
||||
handler = RepoMapHandler(verbose=True)
|
||||
repo_map = handler.initialize(root=".")
|
||||
|
||||
# Get the repository map
|
||||
print("=== Repository Map ===")
|
||||
map_str = handler.get_map()
|
||||
print(map_str[:500] if len(map_str) > 500 else map_str)
|
||||
print("..." if len(map_str) > 500 else "")
|
||||
|
||||
# Refresh after file changes
|
||||
handler.refresh()
|
||||
|
||||
# Get symbols for a specific file (if exists)
|
||||
# symbols = handler.get_context("MyClass")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Sandbox Execution Example for PraisonAI CLI.
|
||||
|
||||
Secure isolated command execution (--sandbox flag).
|
||||
Docs: https://docs.praison.ai/cli/sandbox-execution
|
||||
"""
|
||||
|
||||
from praisonai.cli.features import SandboxExecutorHandler
|
||||
from praisonai.cli.features.sandbox_executor import SandboxMode
|
||||
|
||||
# Initialize with basic sandbox
|
||||
handler = SandboxExecutorHandler(verbose=True)
|
||||
sandbox = handler.initialize(mode="basic")
|
||||
|
||||
print(f"Sandbox mode: {handler.get_mode()}")
|
||||
print(f"Sandbox enabled: {handler.is_enabled}")
|
||||
|
||||
# Execute a safe command
|
||||
print("\n=== Safe Command ===")
|
||||
result = handler.execute("echo 'Hello from sandbox!'")
|
||||
print(f"Success: {result.success}")
|
||||
print(f"Output: {result.stdout.strip()}")
|
||||
print(f"Sandboxed: {result.was_sandboxed}")
|
||||
|
||||
# Try a blocked command
|
||||
print("\n=== Blocked Command ===")
|
||||
result = handler.execute("rm -rf /")
|
||||
print(f"Success: {result.success}")
|
||||
print(f"Violations: {result.policy_violations}")
|
||||
|
||||
# Validate command before execution
|
||||
print("\n=== Command Validation ===")
|
||||
violations = handler.validate_command("sudo apt install something")
|
||||
if violations:
|
||||
print(f"Command blocked: {violations}")
|
||||
else:
|
||||
print("Command allowed")
|
||||
|
||||
# Available modes
|
||||
print("\n=== Available Modes ===")
|
||||
for mode in SandboxMode:
|
||||
print(f" {mode.value}")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Slash Commands Example for PraisonAI CLI.
|
||||
|
||||
Demonstrates interactive slash commands like /help, /cost, /model.
|
||||
Docs: https://docs.praison.ai/cli/slash-commands
|
||||
"""
|
||||
|
||||
from praisonai.cli.features import SlashCommandHandler
|
||||
from praisonai.cli.features.slash_commands import SlashCommand, CommandKind
|
||||
|
||||
# Create handler
|
||||
handler = SlashCommandHandler()
|
||||
|
||||
# Execute commands
|
||||
print("=== /help ===")
|
||||
result = handler.execute("/help")
|
||||
print(result.get("message", ""))
|
||||
|
||||
print("\n=== /cost ===")
|
||||
result = handler.execute("/cost")
|
||||
print(result.get("message", ""))
|
||||
|
||||
print("\n=== /model ===")
|
||||
result = handler.execute("/model")
|
||||
print(result.get("message", ""))
|
||||
|
||||
# Check if input is a command
|
||||
print("\n=== Command Detection ===")
|
||||
print(f"/help is command: {handler.is_command('/help')}")
|
||||
print(f"hello is command: {handler.is_command('hello')}")
|
||||
|
||||
# Get completions for auto-complete
|
||||
print("\n=== Completions for '/he' ===")
|
||||
completions = handler.get_completions("/he")
|
||||
print(completions)
|
||||
|
||||
# Register custom command
|
||||
def my_custom_handler(args, context):
|
||||
return {"type": "custom", "message": f"Custom command with args: {args}"}
|
||||
|
||||
custom_cmd = SlashCommand(
|
||||
name="mycommand",
|
||||
description="My custom command",
|
||||
handler=my_custom_handler,
|
||||
kind=CommandKind.ACTION,
|
||||
aliases=["mc"]
|
||||
)
|
||||
|
||||
handler.register(custom_cmd)
|
||||
result = handler.execute("/mycommand arg1 arg2")
|
||||
print(f"\n=== Custom Command ===\n{result.get('message', '')}")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Claude Code CLI Integration Example
|
||||
|
||||
Demonstrates how to use Claude Code CLI as an agent tool in PraisonAI.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from praisonai.integrations import ClaudeCodeIntegration
|
||||
|
||||
async def main():
|
||||
# Create Claude Code integration
|
||||
claude = ClaudeCodeIntegration(
|
||||
workspace=".",
|
||||
output_format="json",
|
||||
skip_permissions=True,
|
||||
system_prompt="Be concise and follow best practices",
|
||||
allowed_tools=["Read", "Write", "Bash"]
|
||||
)
|
||||
|
||||
# Check availability
|
||||
print(f"Claude CLI available: {claude.is_available}")
|
||||
print(f"Claude SDK available: {claude.sdk_available}")
|
||||
|
||||
# Execute a coding task
|
||||
result = await claude.execute("List all Python files in the current directory")
|
||||
print(f"Result: {result}")
|
||||
|
||||
# Stream output
|
||||
print("\nStreaming output:")
|
||||
async for event in claude.stream("Explain the project structure"):
|
||||
print(event)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Code Editing Tools Example
|
||||
|
||||
Demonstrates how to use PraisonAI's code editing tools with SEARCH/REPLACE diffs.
|
||||
"""
|
||||
|
||||
from praisonai.code import (
|
||||
set_workspace,
|
||||
code_read_file,
|
||||
code_write_file,
|
||||
code_apply_diff,
|
||||
code_list_files,
|
||||
code_execute_command,
|
||||
CODE_TOOLS
|
||||
)
|
||||
|
||||
# Set workspace
|
||||
set_workspace(".")
|
||||
|
||||
# List files
|
||||
print("Files in current directory:")
|
||||
files = code_list_files(".", recursive=False)
|
||||
for f in files:
|
||||
print(f" {f}")
|
||||
|
||||
# Read a file
|
||||
print("\nReading example file:")
|
||||
try:
|
||||
content = code_read_file("README.md", start_line=1, end_line=10)
|
||||
print(content)
|
||||
except Exception as e:
|
||||
print(f" File not found: {e}")
|
||||
|
||||
# Write a file
|
||||
print("\nWriting test file:")
|
||||
code_write_file("test_output.txt", "Hello from PraisonAI Code Tools!")
|
||||
print(" Created test_output.txt")
|
||||
|
||||
# Apply a diff
|
||||
print("\nApplying diff:")
|
||||
diff = """
|
||||
<<<<<<< SEARCH
|
||||
Hello from PraisonAI Code Tools!
|
||||
=======
|
||||
Hello from PraisonAI Code Tools!
|
||||
This line was added by a diff.
|
||||
>>>>>>> REPLACE
|
||||
"""
|
||||
result = code_apply_diff("test_output.txt", diff)
|
||||
print(f" {result}")
|
||||
|
||||
# Execute command
|
||||
print("\nExecuting command:")
|
||||
output = code_execute_command("cat test_output.txt")
|
||||
print(f" {output}")
|
||||
|
||||
# Cleanup
|
||||
import os
|
||||
os.remove("test_output.txt")
|
||||
print("\nCleanup complete.")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Codex CLI Integration Example
|
||||
|
||||
Demonstrates how to use OpenAI Codex CLI as an agent tool in PraisonAI.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from praisonai.integrations import CodexCLIIntegration
|
||||
|
||||
async def main():
|
||||
# Create Codex CLI integration
|
||||
codex = CodexCLIIntegration(
|
||||
workspace=".",
|
||||
full_auto=True,
|
||||
sandbox="danger-full-access",
|
||||
json_output=True
|
||||
)
|
||||
|
||||
# Check availability
|
||||
print(f"Codex CLI available: {codex.is_available}")
|
||||
print(f"Full Auto: {codex.full_auto}")
|
||||
print(f"Sandbox: {codex.sandbox}")
|
||||
|
||||
# Execute a coding task
|
||||
result = await codex.execute("List files in the current directory")
|
||||
print(f"Result: {result}")
|
||||
|
||||
# Stream JSON events
|
||||
print("\nStreaming events:")
|
||||
async for event in codex.stream("Explain the project"):
|
||||
event_type = event.get("type", "")
|
||||
if event_type == "item.completed":
|
||||
print(f" {event}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Cursor CLI Integration Example
|
||||
|
||||
Demonstrates how to use Cursor CLI as an agent tool in PraisonAI.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from praisonai.integrations import CursorCLIIntegration
|
||||
|
||||
async def main():
|
||||
# Create Cursor CLI integration
|
||||
cursor = CursorCLIIntegration(
|
||||
workspace=".",
|
||||
output_format="json",
|
||||
force=True,
|
||||
model="gpt-5",
|
||||
stream_partial=True
|
||||
)
|
||||
|
||||
# Check availability
|
||||
print(f"Cursor CLI available: {cursor.is_available}")
|
||||
print(f"Force mode: {cursor.force}")
|
||||
print(f"Model: {cursor.model}")
|
||||
|
||||
# Execute a coding task
|
||||
result = await cursor.execute("List files in the current directory")
|
||||
print(f"Result: {result}")
|
||||
|
||||
# Stream output
|
||||
print("\nStreaming output:")
|
||||
async for event in cursor.stream("Explain the project"):
|
||||
content = event.get("content", "")
|
||||
if content:
|
||||
print(content, end="", flush=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
External Agents Integration Example
|
||||
|
||||
Demonstrates how to use all external CLI integrations as agent tools.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from praisonai.integrations import (
|
||||
ClaudeCodeIntegration,
|
||||
GeminiCLIIntegration,
|
||||
CodexCLIIntegration,
|
||||
CursorCLIIntegration,
|
||||
get_available_integrations
|
||||
)
|
||||
|
||||
async def main():
|
||||
# Check availability of all integrations
|
||||
print("=== External CLI Integrations ===\n")
|
||||
|
||||
availability = get_available_integrations()
|
||||
print("Availability:")
|
||||
for name, avail in availability.items():
|
||||
status = "✅" if avail else "❌"
|
||||
print(f" {status} {name}")
|
||||
|
||||
# Create integrations
|
||||
print("\n--- Claude Code ---")
|
||||
claude = ClaudeCodeIntegration(workspace=".", skip_permissions=True)
|
||||
print(f"CLI: {claude.cli_command}")
|
||||
print(f"Available: {claude.is_available}")
|
||||
tool = claude.as_tool()
|
||||
print(f"Tool: {tool.__name__}")
|
||||
|
||||
print("\n--- Gemini CLI ---")
|
||||
gemini = GeminiCLIIntegration(workspace=".", model="gemini-2.5-flash")
|
||||
print(f"CLI: {gemini.cli_command}")
|
||||
print(f"Available: {gemini.is_available}")
|
||||
tool = gemini.as_tool()
|
||||
print(f"Tool: {tool.__name__}")
|
||||
|
||||
print("\n--- Codex CLI ---")
|
||||
codex = CodexCLIIntegration(workspace=".", full_auto=True)
|
||||
print(f"CLI: {codex.cli_command}")
|
||||
print(f"Available: {codex.is_available}")
|
||||
tool = codex.as_tool()
|
||||
print(f"Tool: {tool.__name__}")
|
||||
|
||||
print("\n--- Cursor CLI ---")
|
||||
cursor = CursorCLIIntegration(workspace=".", force=True)
|
||||
print(f"CLI: {cursor.cli_command}")
|
||||
print(f"Available: {cursor.is_available}")
|
||||
tool = cursor.as_tool()
|
||||
print(f"Tool: {tool.__name__}")
|
||||
|
||||
print("\n=== All integrations ready ===")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Gemini CLI Integration Example
|
||||
|
||||
Demonstrates how to use Gemini CLI as an agent tool in PraisonAI.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from praisonai.integrations import GeminiCLIIntegration
|
||||
|
||||
async def main():
|
||||
# Create Gemini CLI integration
|
||||
gemini = GeminiCLIIntegration(
|
||||
workspace=".",
|
||||
model="gemini-2.5-flash",
|
||||
output_format="json",
|
||||
include_directories=["../lib", "../docs"]
|
||||
)
|
||||
|
||||
# Check availability
|
||||
print(f"Gemini CLI available: {gemini.is_available}")
|
||||
print(f"Model: {gemini.model}")
|
||||
|
||||
# Execute a coding task
|
||||
result = await gemini.execute("Analyze the code structure")
|
||||
print(f"Result: {result}")
|
||||
|
||||
# Execute with stats
|
||||
result, stats = await gemini.execute_with_stats("Explain main.py")
|
||||
print(f"Result: {result}")
|
||||
print(f"Stats: {stats}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,11 +1,48 @@
|
||||
"""
|
||||
AutoAgents Example
|
||||
|
||||
AutoAgents automatically creates and manages AI agents based on high-level instructions.
|
||||
It features dynamic agent count based on task complexity.
|
||||
|
||||
Documentation: https://docs.praison.ai/features/autoagents
|
||||
CLI Reference: https://docs.praison.ai/nocode/auto
|
||||
|
||||
New Features:
|
||||
- Dynamic agent count (1-4 based on task complexity)
|
||||
- Workflow patterns: sequential, parallel, routing, orchestrator-workers, evaluator-optimizer
|
||||
- Pattern recommendation based on task keywords
|
||||
- Tool preservation from LLM suggestions
|
||||
"""
|
||||
|
||||
from praisonaiagents import AutoAgents
|
||||
from praisonaiagents.tools import duckduckgo
|
||||
|
||||
# Basic usage - AutoAgents analyzes complexity and creates optimal agents
|
||||
agents = AutoAgents(
|
||||
instructions="Search for information about AI Agents",
|
||||
tools=[duckduckgo],
|
||||
process="sequential",
|
||||
verbose=True
|
||||
process="sequential", # or "hierarchical"
|
||||
verbose=True,
|
||||
max_agents=3 # Maximum number of agents to create
|
||||
)
|
||||
|
||||
agents.start()
|
||||
result = agents.start()
|
||||
print(result)
|
||||
|
||||
# =============================================================================
|
||||
# CLI Usage Examples (for reference)
|
||||
# =============================================================================
|
||||
#
|
||||
# # Auto-generate agents (dynamic count based on complexity)
|
||||
# praisonai --auto "Write a haiku about spring"
|
||||
# praisonai --auto "Research AI trends, analyze data, write report"
|
||||
#
|
||||
# # Auto-generate workflow with specific pattern
|
||||
# praisonai workflow auto "Research and write" --pattern sequential
|
||||
# praisonai workflow auto "Research from multiple sources" --pattern parallel
|
||||
# praisonai workflow auto "Comprehensive analysis" --pattern orchestrator-workers
|
||||
# praisonai workflow auto "Refine content quality" --pattern evaluator-optimizer
|
||||
#
|
||||
# # With framework selection
|
||||
# praisonai --framework crewai --auto "Create a movie script"
|
||||
# praisonai --framework autogen --auto "Create a movie script"
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Unified Web Search Example
|
||||
|
||||
This example demonstrates the search_web tool that automatically tries
|
||||
multiple search providers with fallback support.
|
||||
|
||||
Search Provider Priority:
|
||||
1. Tavily (requires TAVILY_API_KEY + tavily-python)
|
||||
2. Exa (requires EXA_API_KEY + exa_py)
|
||||
3. You.com (requires YDC_API_KEY + youdotcom)
|
||||
4. DuckDuckGo (requires duckduckgo_search package, no API key)
|
||||
5. SearxNG (requires requests + running SearxNG instance)
|
||||
|
||||
Installation:
|
||||
pip install praisonaiagents duckduckgo_search
|
||||
|
||||
Usage:
|
||||
python web_search_example.py
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import search_web, get_available_providers
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Unified Web Search - Provider Status")
|
||||
print("=" * 60)
|
||||
|
||||
# Check which providers are available
|
||||
providers = get_available_providers()
|
||||
for p in providers:
|
||||
status = "✓ Available" if p["available"] else f"✗ {p['reason']}"
|
||||
print(f" {p['name']:12} {status}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example 1: Basic Search")
|
||||
print("=" * 60)
|
||||
|
||||
# Simple search - automatically uses best available provider
|
||||
results = search_web("Python programming best practices", max_results=3)
|
||||
|
||||
if results and "error" not in results[0]:
|
||||
print(f"\nFound {len(results)} results using {results[0].get('provider', 'unknown')}:\n")
|
||||
for r in results:
|
||||
print(f" Title: {r.get('title', 'N/A')[:60]}...")
|
||||
print(f" URL: {r.get('url', 'N/A')}")
|
||||
print()
|
||||
else:
|
||||
print(f"\nSearch failed: {results}")
|
||||
|
||||
print("=" * 60)
|
||||
print("Example 2: Specify Providers")
|
||||
print("=" * 60)
|
||||
|
||||
# Only try specific providers
|
||||
results = search_web(
|
||||
"machine learning tutorials",
|
||||
max_results=3,
|
||||
providers=["duckduckgo", "tavily"] # Try these in order
|
||||
)
|
||||
|
||||
if results and "error" not in results[0]:
|
||||
print(f"\nFound {len(results)} results using {results[0].get('provider', 'unknown')}:\n")
|
||||
for r in results:
|
||||
print(f" Title: {r.get('title', 'N/A')[:60]}...")
|
||||
print(f" URL: {r.get('url', 'N/A')}")
|
||||
print()
|
||||
else:
|
||||
print(f"\nSearch failed: {results}")
|
||||
|
||||
print("=" * 60)
|
||||
print("Example 3: With PraisonAI Agent")
|
||||
print("=" * 60)
|
||||
|
||||
# Use search_web as an agent tool
|
||||
agent = Agent(
|
||||
name="SearchAgent",
|
||||
role="Web Researcher",
|
||||
goal="Find and summarize information from the web",
|
||||
instructions="Search the web and provide concise summaries of findings.",
|
||||
tools=[search_web]
|
||||
)
|
||||
|
||||
result = agent.start("What are the top 3 AI trends in 2025?")
|
||||
print(f"\nAgent Response:\n{result}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -218,6 +218,10 @@ TOOL_MAPPINGS = {
|
||||
'crawl4ai_extract_sync': ('.crawl4ai_tools', None),
|
||||
'Crawl4AITools': ('.crawl4ai_tools', 'Crawl4AITools'),
|
||||
'crawl4ai_tools': ('.crawl4ai_tools', None),
|
||||
|
||||
# Unified Web Search (auto-fallback across providers)
|
||||
'search_web': ('.web_search', None),
|
||||
'get_available_providers': ('.web_search', None),
|
||||
}
|
||||
|
||||
_instances = {} # Cache for class instances
|
||||
@@ -254,7 +258,8 @@ def __getattr__(name: str) -> Any:
|
||||
'exa', 'exa_search', 'exa_search_contents', 'exa_find_similar', 'exa_answer',
|
||||
'exa_search_async', 'exa_search_contents_async', 'exa_answer_async',
|
||||
'crawl4ai', 'crawl4ai_many', 'crawl4ai_extract', 'crawl4ai_llm_extract',
|
||||
'crawl4ai_sync', 'crawl4ai_extract_sync'
|
||||
'crawl4ai_sync', 'crawl4ai_extract_sync',
|
||||
'search_web', 'get_available_providers'
|
||||
]:
|
||||
return getattr(module, name)
|
||||
if name in ['file_tools', 'pandas_tools', 'wikipedia_tools',
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Unified web search tool with automatic fallback across multiple providers.
|
||||
|
||||
This module provides a single `search_web` function that automatically tries
|
||||
multiple search providers in order, falling back to the next if one fails.
|
||||
|
||||
Search Provider Priority:
|
||||
1. Tavily (requires TAVILY_API_KEY + tavily-python)
|
||||
2. Exa (requires EXA_API_KEY + exa_py)
|
||||
3. You.com (requires YDC_API_KEY + youdotcom)
|
||||
4. DuckDuckGo (requires duckduckgo_search package, no API key)
|
||||
5. SearxNG (requires requests + running SearxNG instance)
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.tools import search_web
|
||||
|
||||
# Simple search - automatically uses best available provider
|
||||
results = search_web("AI news 2024")
|
||||
|
||||
# With max results
|
||||
results = search_web("Python tutorials", max_results=10)
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
import logging
|
||||
import os
|
||||
from importlib import util
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_tavily() -> tuple[bool, Optional[str]]:
|
||||
"""Check if Tavily is available."""
|
||||
if util.find_spec("tavily") is None:
|
||||
return False, "tavily-python package not installed"
|
||||
if not os.environ.get("TAVILY_API_KEY"):
|
||||
return False, "TAVILY_API_KEY not set"
|
||||
return True, None
|
||||
|
||||
|
||||
def _check_exa() -> tuple[bool, Optional[str]]:
|
||||
"""Check if Exa is available."""
|
||||
if util.find_spec("exa_py") is None:
|
||||
return False, "exa_py package not installed"
|
||||
if not os.environ.get("EXA_API_KEY"):
|
||||
return False, "EXA_API_KEY not set"
|
||||
return True, None
|
||||
|
||||
|
||||
def _check_youdotcom() -> tuple[bool, Optional[str]]:
|
||||
"""Check if You.com is available."""
|
||||
if util.find_spec("youdotcom") is None:
|
||||
return False, "youdotcom package not installed"
|
||||
if not os.environ.get("YDC_API_KEY"):
|
||||
return False, "YDC_API_KEY not set"
|
||||
return True, None
|
||||
|
||||
|
||||
def _check_duckduckgo() -> tuple[bool, Optional[str]]:
|
||||
"""Check if DuckDuckGo is available."""
|
||||
if util.find_spec("duckduckgo_search") is None:
|
||||
return False, "duckduckgo_search package not installed"
|
||||
return True, None
|
||||
|
||||
|
||||
def _check_searxng() -> tuple[bool, Optional[str]]:
|
||||
"""Check if SearxNG is available."""
|
||||
if util.find_spec("requests") is None:
|
||||
return False, "requests package not installed"
|
||||
return True, None
|
||||
|
||||
|
||||
def _search_tavily(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Search using Tavily."""
|
||||
from tavily import TavilyClient
|
||||
|
||||
client = TavilyClient(api_key=os.environ.get("TAVILY_API_KEY"))
|
||||
response = client.search(query=query, max_results=max_results)
|
||||
|
||||
results = []
|
||||
for r in response.get("results", []):
|
||||
results.append({
|
||||
"title": r.get("title", ""),
|
||||
"url": r.get("url", ""),
|
||||
"snippet": r.get("content", ""),
|
||||
"provider": "tavily"
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _search_exa(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Search using Exa."""
|
||||
from exa_py import Exa
|
||||
|
||||
client = Exa(os.environ.get("EXA_API_KEY"))
|
||||
response = client.search(query=query, num_results=max_results)
|
||||
|
||||
results = []
|
||||
for r in response.results:
|
||||
results.append({
|
||||
"title": getattr(r, "title", "") or "",
|
||||
"url": r.url,
|
||||
"snippet": getattr(r, "text", "") or "",
|
||||
"provider": "exa"
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _search_youdotcom(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Search using You.com."""
|
||||
from youdotcom import You
|
||||
|
||||
client = You(api_key_auth=os.environ.get("YDC_API_KEY"))
|
||||
response = client.search.unified(query=query, count=max_results)
|
||||
|
||||
results = []
|
||||
# Handle response object
|
||||
if hasattr(response, 'results'):
|
||||
web_results = getattr(response.results, 'web', []) or []
|
||||
for r in web_results[:max_results]:
|
||||
results.append({
|
||||
"title": getattr(r, "title", "") or "",
|
||||
"url": getattr(r, "url", "") or "",
|
||||
"snippet": getattr(r, "description", "") or "",
|
||||
"provider": "youdotcom"
|
||||
})
|
||||
elif isinstance(response, dict):
|
||||
web_results = response.get("results", {}).get("web", [])
|
||||
for r in web_results[:max_results]:
|
||||
results.append({
|
||||
"title": r.get("title", ""),
|
||||
"url": r.get("url", ""),
|
||||
"snippet": r.get("description", ""),
|
||||
"provider": "youdotcom"
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _search_duckduckgo(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Search using DuckDuckGo."""
|
||||
from duckduckgo_search import DDGS
|
||||
|
||||
results = []
|
||||
ddgs = DDGS()
|
||||
for result in ddgs.text(keywords=query, max_results=max_results):
|
||||
results.append({
|
||||
"title": result.get("title", ""),
|
||||
"url": result.get("href", ""),
|
||||
"snippet": result.get("body", ""),
|
||||
"provider": "duckduckgo"
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _search_searxng(query: str, max_results: int = 5, searxng_url: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Search using SearxNG."""
|
||||
import requests
|
||||
|
||||
url = searxng_url or os.environ.get("SEARXNG_URL", "http://localhost:32768/search")
|
||||
|
||||
params = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'engines': 'google,bing,duckduckgo',
|
||||
'safesearch': '1'
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
raw_results = response.json().get('results', [])
|
||||
|
||||
results = []
|
||||
for result in raw_results[:max_results]:
|
||||
results.append({
|
||||
"title": result.get("title", ""),
|
||||
"url": result.get("url", ""),
|
||||
"snippet": result.get("content", ""),
|
||||
"provider": "searxng"
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
# Provider configuration: (name, check_func, search_func)
|
||||
SEARCH_PROVIDERS = [
|
||||
("tavily", _check_tavily, _search_tavily),
|
||||
("exa", _check_exa, _search_exa),
|
||||
("youdotcom", _check_youdotcom, _search_youdotcom),
|
||||
("duckduckgo", _check_duckduckgo, _search_duckduckgo),
|
||||
("searxng", _check_searxng, _search_searxng),
|
||||
]
|
||||
|
||||
|
||||
def search_web(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
providers: Optional[List[str]] = None,
|
||||
searxng_url: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search the web using multiple providers with automatic fallback.
|
||||
|
||||
Tries each search provider in order until one succeeds. For each provider,
|
||||
it first checks if the API key is set (if required) and if the package is
|
||||
installed, then attempts the search. If the search fails, it moves to the
|
||||
next provider.
|
||||
|
||||
Args:
|
||||
query: The search query string
|
||||
max_results: Maximum number of results to return (default: 5)
|
||||
providers: Optional list of provider names to try, in order.
|
||||
If not specified, uses default order:
|
||||
["tavily", "exa", "youdotcom", "duckduckgo", "searxng"]
|
||||
searxng_url: Optional custom SearxNG instance URL
|
||||
|
||||
Returns:
|
||||
List of search results, each containing:
|
||||
- title: Result title
|
||||
- url: Result URL
|
||||
- snippet: Result description/snippet
|
||||
- provider: Name of the provider that returned the result
|
||||
|
||||
If all providers fail, returns a list with a single error dict.
|
||||
|
||||
Example:
|
||||
# Use default provider order
|
||||
results = search_web("AI news 2024")
|
||||
|
||||
# Specify providers to try
|
||||
results = search_web("Python tutorials", providers=["duckduckgo", "tavily"])
|
||||
|
||||
# With custom SearxNG URL
|
||||
results = search_web("tech news", searxng_url="http://my-searxng:8080/search")
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Determine which providers to try
|
||||
if providers:
|
||||
# Filter to only valid provider names
|
||||
provider_order = []
|
||||
for name in providers:
|
||||
for pname, check_fn, search_fn in SEARCH_PROVIDERS:
|
||||
if pname == name.lower():
|
||||
provider_order.append((pname, check_fn, search_fn))
|
||||
break
|
||||
else:
|
||||
provider_order = SEARCH_PROVIDERS
|
||||
|
||||
for provider_name, check_func, search_func in provider_order:
|
||||
# Step 1: Check if provider is available (API key + package)
|
||||
is_available, check_error = check_func()
|
||||
if not is_available:
|
||||
logger.debug(f"Skipping {provider_name}: {check_error}")
|
||||
errors.append(f"{provider_name}: {check_error}")
|
||||
continue
|
||||
|
||||
# Step 2: Try to search
|
||||
try:
|
||||
logger.debug(f"Trying {provider_name} for query: {query}")
|
||||
|
||||
if provider_name == "searxng":
|
||||
results = search_func(query, max_results, searxng_url)
|
||||
else:
|
||||
results = search_func(query, max_results)
|
||||
|
||||
if results:
|
||||
logger.info(f"search_web: Successfully used {provider_name}")
|
||||
return results
|
||||
else:
|
||||
errors.append(f"{provider_name}: No results returned")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"{provider_name}: {str(e)}"
|
||||
logger.debug(f"Search failed with {provider_name}: {e}")
|
||||
errors.append(error_msg)
|
||||
continue
|
||||
|
||||
# All providers failed
|
||||
error_summary = "; ".join(errors) if errors else "No search providers available"
|
||||
logger.error(f"search_web: All providers failed - {error_summary}")
|
||||
return [{"error": f"All search providers failed: {error_summary}"}]
|
||||
|
||||
|
||||
def get_available_providers() -> List[Dict[str, Any]]:
|
||||
"""Get list of available search providers and their status.
|
||||
|
||||
Returns:
|
||||
List of dicts with provider info:
|
||||
- name: Provider name
|
||||
- available: Whether the provider is ready to use
|
||||
- reason: Reason if not available
|
||||
|
||||
Example:
|
||||
providers = get_available_providers()
|
||||
for p in providers:
|
||||
print(f"{p['name']}: {'✓' if p['available'] else '✗'} {p.get('reason', '')}")
|
||||
"""
|
||||
result = []
|
||||
for name, check_func, _ in SEARCH_PROVIDERS:
|
||||
is_available, error = check_func()
|
||||
result.append({
|
||||
"name": name,
|
||||
"available": is_available,
|
||||
"reason": error if not is_available else None
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage and provider status check
|
||||
print("=" * 60)
|
||||
print("Web Search Tool - Provider Status")
|
||||
print("=" * 60)
|
||||
|
||||
providers = get_available_providers()
|
||||
for p in providers:
|
||||
status = "✓ Available" if p["available"] else f"✗ {p['reason']}"
|
||||
print(f" {p['name']:12} {status}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Testing search_web...")
|
||||
print("=" * 60)
|
||||
|
||||
results = search_web("Python programming", max_results=3)
|
||||
if results and "error" not in results[0]:
|
||||
print(f"\nFound {len(results)} results using {results[0].get('provider', 'unknown')}:")
|
||||
for r in results:
|
||||
print(f"\n Title: {r.get('title', 'N/A')[:50]}...")
|
||||
print(f" URL: {r.get('url', 'N/A')}")
|
||||
else:
|
||||
print(f"\nSearch failed: {results}")
|
||||
+86
-5
@@ -6,10 +6,13 @@
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<!-- mcp-name: io.github.MervinPraison/praisonai -->
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/MervinPraison/PraisonAI"><img src="https://static.pepy.tech/badge/PraisonAI" alt="Total Downloads" /></a>
|
||||
<a href="https://github.com/MervinPraison/PraisonAI"><img src="https://img.shields.io/github/v/release/MervinPraison/PraisonAI" alt="Latest Stable Version" /></a>
|
||||
<a href="https://github.com/MervinPraison/PraisonAI"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License" /></a>
|
||||
<a href="https://registry.modelcontextprotocol.io/servers/io.github.MervinPraison/praisonai"><img src="https://img.shields.io/badge/MCP-Registry-blue" alt="MCP Registry" /></a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
@@ -188,8 +191,14 @@ npm install praisonai
|
||||
| 📚 Add Custom Knowledge | [Example](examples/python/concepts/knowledge-agents.py) | [📖](https://docs.praison.ai/features/knowledge) |
|
||||
| 🧠 Memory (Short & Long Term) | [Example](examples/python/general/memory_example.py) | [📖](https://docs.praison.ai/concepts/memory) |
|
||||
| 📄 Chat with PDF Agents | [Example](examples/python/concepts/chat-with-pdf.py) | [📖](https://docs.praison.ai/features/chat-with-pdf) |
|
||||
| 💻 Code Interpreter Agents | [Example](examples/python/agents/code-agent.py) | [📖](https://docs.praison.ai/features/codeagent) |
|
||||
| ✏️ AI Code Editing Tools | [Example](#ai-code-editing) | [📖](https://docs.praison.ai/features/code) |
|
||||
| **💻 Code** | | |
|
||||
| ↳ Code Interpreter Agents | [Example](examples/python/agents/code-agent.py) | [📖](https://docs.praison.ai/features/codeagent) |
|
||||
| ↳ AI Code Editing Tools | [Example](examples/python/code/code_editing_example.py) | [📖](https://docs.praison.ai/code/editing) |
|
||||
| ↳ External Agents (All) | [Example](examples/python/code/external_agents_example.py) | [📖](https://docs.praison.ai/code/external-agents) |
|
||||
| ↳ Claude Code CLI | [Example](examples/python/code/claude_code_example.py) | [📖](https://docs.praison.ai/code/claude-code) |
|
||||
| ↳ Gemini CLI | [Example](examples/python/code/gemini_cli_example.py) | [📖](https://docs.praison.ai/code/gemini-cli) |
|
||||
| ↳ Codex CLI | [Example](examples/python/code/codex_cli_example.py) | [📖](https://docs.praison.ai/code/codex-cli) |
|
||||
| ↳ Cursor CLI | [Example](examples/python/code/cursor_cli_example.py) | [📖](https://docs.praison.ai/code/cursor-cli) |
|
||||
| 📚 RAG Agents | [Example](examples/python/concepts/rag-agents.py) | [📖](https://docs.praison.ai/features/rag) |
|
||||
| 🤔 Async & Parallel Processing | [Example](examples/python/general/async_example.py) | [📖](https://docs.praison.ai/features/async) |
|
||||
| 🔢 Math Agents | [Example](examples/python/agents/math-agent.py) | [📖](https://docs.praison.ai/features/mathagent) |
|
||||
@@ -207,6 +216,7 @@ npm install praisonai
|
||||
| 🧠 Claude Memory Tool | [Example](#claude-memory-tool-cli) | [📖](https://docs.praison.ai/features/claude-memory-tool) |
|
||||
| 💾 File-Based Memory | [Example](examples/python/general/memory_example.py) | [📖](https://docs.praison.ai/concepts/memory) |
|
||||
| 🔍 Built-in Search Tools | [Example](examples/python/agents/websearch-agent.py) | [📖](https://docs.praison.ai/tools/tavily) |
|
||||
| 🔎 Unified Web Search | [Example](src/praisonai-agents/examples/web_search_example.py) | [📖](https://docs.praison.ai/tools/web-search) |
|
||||
| 📋 Planning Mode | [Example](examples/python/agents/planning-agent.py) | [📖](https://docs.praison.ai/features/planning-mode) |
|
||||
| 🔧 Planning Tools | [Example](#3-agent-with-planning-mode) | [📖](https://docs.praison.ai/features/planning-mode) |
|
||||
| 🧠 Planning Reasoning | [Example](#3-agent-with-planning-mode) | [📖](https://docs.praison.ai/features/planning-mode) |
|
||||
@@ -226,7 +236,7 @@ npm install praisonai
|
||||
| 💻 Programming Agent | [Example](examples/python/agents/programming-agent.py) | [📖](https://docs.praison.ai/agents/programming) |
|
||||
| 📝 Markdown Agent | [Example](examples/python/agents/markdown-agent.py) | [📖](https://docs.praison.ai/agents/markdown) |
|
||||
| 📝 Prompt Expander Agent | [Example](#prompt-expansion) | [📖](https://docs.praison.ai/agents/prompt-expander) |
|
||||
| 🔀 Router Agent | [Example](examples/python/agents/router-agent-cost-optimization.py) | [📖](https://docs.praison.ai/features/routing) |
|
||||
| 🔀 Model Router | [Example](examples/python/agents/router-agent-cost-optimization.py) | [📖](https://docs.praison.ai/features/model-router) |
|
||||
| ⛓️ Prompt Chaining | [Example](examples/python/general/prompt_chaining.py) | [📖](https://docs.praison.ai/features/promptchaining) |
|
||||
| 🔍 Evaluator Optimiser | [Example](examples/python/general/evaluator-optimiser.py) | [📖](https://docs.praison.ai/features/evaluator-optimiser) |
|
||||
| 👷 Orchestrator Workers | [Example](examples/python/general/orchestrator-workers.py) | [📖](https://docs.praison.ai/features/orchestrator-worker) |
|
||||
@@ -248,6 +258,14 @@ npm install praisonai
|
||||
| @ @Mentions in Prompts | [Example](#mentions-in-prompts) | [📖](https://docs.praison.ai/cli/mentions) |
|
||||
| 💾 Auto-Save Sessions | [Example](#session-management-python) | [📖](https://docs.praison.ai/cli/session) |
|
||||
| 📜 History in Context | [Example](#session-management-python) | [📖](https://docs.praison.ai/cli/session) |
|
||||
| **🖥️ CLI Features** | | |
|
||||
| ↳ Slash Commands | [Example](examples/python/cli/slash_commands_example.py) | [📖](https://docs.praison.ai/cli/slash-commands) |
|
||||
| ↳ Autonomy Modes | [Example](examples/python/cli/autonomy_modes_example.py) | [📖](https://docs.praison.ai/cli/autonomy-modes) |
|
||||
| ↳ Cost Tracking | [Example](examples/python/cli/cost_tracking_example.py) | [📖](https://docs.praison.ai/cli/cost-tracking) |
|
||||
| ↳ Repository Map | [Example](examples/python/cli/repo_map_example.py) | [📖](https://docs.praison.ai/cli/repo-map) |
|
||||
| ↳ Interactive TUI | [Example](examples/python/cli/interactive_tui_example.py) | [📖](https://docs.praison.ai/cli/interactive-tui) |
|
||||
| ↳ Git Integration | [Example](examples/python/cli/git_integration_example.py) | [📖](https://docs.praison.ai/cli/git-integration) |
|
||||
| ↳ Sandbox Execution | [Example](examples/python/cli/sandbox_execution_example.py) | [📖](https://docs.praison.ai/cli/sandbox-execution) |
|
||||
|
||||
---
|
||||
|
||||
@@ -1120,6 +1138,15 @@ praisonai workflow auto "Research AI trends" --pattern parallel
|
||||
# Generate routing workflow (classifier routes to specialists)
|
||||
praisonai workflow auto "Build a chatbot" --pattern routing
|
||||
|
||||
# Generate orchestrator-workers workflow (central orchestrator delegates)
|
||||
praisonai workflow auto "Comprehensive market analysis" --pattern orchestrator-workers
|
||||
|
||||
# Generate evaluator-optimizer workflow (iterative refinement)
|
||||
praisonai workflow auto "Write and refine article" --pattern evaluator-optimizer
|
||||
|
||||
# Specify output file
|
||||
praisonai workflow auto "Build a chatbot" --pattern routing
|
||||
|
||||
# Specify output file
|
||||
praisonai workflow auto "Research AI" --pattern sequential --output my_workflow.yaml
|
||||
```
|
||||
@@ -1129,7 +1156,7 @@ praisonai workflow auto "Research AI" --pattern sequential --output my_workflow.
|
||||
|------|-------------|
|
||||
| `--workflow-var key=value` | Set workflow variable (can be repeated) |
|
||||
| `--var key=value` | Set variable for YAML workflows |
|
||||
| `--pattern <pattern>` | Pattern for auto-generation (sequential, parallel, routing, loop) |
|
||||
| `--pattern <pattern>` | Pattern for auto-generation (sequential, parallel, routing, loop, orchestrator-workers, evaluator-optimizer) |
|
||||
| `--output <file>` | Output file for auto-generation |
|
||||
| `--llm <model>` | LLM model (e.g., openai/gpt-4o-mini) |
|
||||
| `--tools <tools>` | Tools (comma-separated, e.g., tavily) |
|
||||
@@ -1361,8 +1388,13 @@ praisonai "Complex analysis" --router --router-provider anthropic
|
||||
# Router automatically selects:
|
||||
# - Simple tasks → gpt-4o-mini, claude-3-haiku
|
||||
# - Complex tasks → gpt-4-turbo, claude-3-opus
|
||||
|
||||
# Create workflow with model routing template
|
||||
praisonai workflow create --template model-routing --output my_workflow.yaml
|
||||
```
|
||||
|
||||
Custom models can be configured in `agents.yaml`. See [Model Router Docs](https://docs.praison.ai/features/model-router) for details.
|
||||
|
||||
### Flow Display CLI:
|
||||
```bash
|
||||
# Enable visual workflow tracking
|
||||
@@ -1451,6 +1483,55 @@ export N8N_API_KEY="your-api-key"
|
||||
praisonai agents.yaml --n8n
|
||||
```
|
||||
|
||||
### External Agents CLI:
|
||||
|
||||
Use external AI coding CLI tools (Claude Code, Gemini CLI, Codex CLI, Cursor CLI) as agent tools:
|
||||
|
||||
```bash
|
||||
# Use Claude Code for coding tasks
|
||||
praisonai "Refactor the auth module" --external-agent claude
|
||||
|
||||
# Use Gemini CLI for code analysis
|
||||
praisonai "Analyze codebase architecture" --external-agent gemini
|
||||
|
||||
# Use OpenAI Codex CLI
|
||||
praisonai "Fix all bugs in src/" --external-agent codex
|
||||
|
||||
# Use Cursor CLI
|
||||
praisonai "Add comprehensive tests" --external-agent cursor
|
||||
```
|
||||
|
||||
**Python API:**
|
||||
```python
|
||||
from praisonai.integrations import (
|
||||
ClaudeCodeIntegration,
|
||||
GeminiCLIIntegration,
|
||||
CodexCLIIntegration,
|
||||
CursorCLIIntegration
|
||||
)
|
||||
|
||||
# Create integration
|
||||
claude = ClaudeCodeIntegration(workspace="/project")
|
||||
|
||||
# Execute a coding task
|
||||
result = await claude.execute("Refactor the auth module")
|
||||
|
||||
# Use as agent tool
|
||||
from praisonai import Agent
|
||||
tool = claude.as_tool()
|
||||
agent = Agent(tools=[tool])
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=your-key # Claude Code
|
||||
export GEMINI_API_KEY=your-key # Gemini CLI
|
||||
export OPENAI_API_KEY=your-key # Codex CLI
|
||||
export CURSOR_API_KEY=your-key # Cursor CLI
|
||||
```
|
||||
|
||||
See [External Agents Documentation](https://docs.praison.ai/code/external-agents) for more details.
|
||||
|
||||
### @Mentions in Prompts:
|
||||
```bash
|
||||
# Include file content in prompt
|
||||
@@ -2253,7 +2334,7 @@ PraisonAI provides zero-dependency persistent memory for agents. For detailed ex
|
||||
- 📖 **Wikipedia Agent** - Search and extract Wikipedia information
|
||||
- 💻 **Programming Agent** - Code development and analysis
|
||||
- 📝 **Markdown Agent** - Generate and format Markdown content
|
||||
- 🔀 **Router Agent** - Dynamic task routing with cost optimisation
|
||||
- 🔀 **Model Router** - Smart model selection based on task complexity
|
||||
|
||||
### MCP Protocol
|
||||
|
||||
|
||||
@@ -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.3.40.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.40.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.45.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.45.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# Suppress crewai.cli.config logger BEFORE any imports to prevent INFO log
|
||||
import logging
|
||||
logging.getLogger('crewai.cli.config').setLevel(logging.ERROR)
|
||||
|
||||
# Disable OpenTelemetry SDK
|
||||
import os
|
||||
os.environ["OTEL_SDK_DISABLED"] = "true"
|
||||
|
||||
+505
-105
@@ -51,14 +51,140 @@ try:
|
||||
except ImportError:
|
||||
PRAISONAI_TOOLS_AVAILABLE = False
|
||||
|
||||
# LiteLLM availability check for multi-provider support
|
||||
LITELLM_AVAILABLE = False
|
||||
try:
|
||||
import litellm # noqa: F401 - imported for availability check
|
||||
LITELLM_AVAILABLE = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
logging.basicConfig(level=os.environ.get('LOGLEVEL', 'INFO').upper(), format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
# Define Pydantic models outside of the generate method
|
||||
# =============================================================================
|
||||
# Available Tools List (shared between generators)
|
||||
# =============================================================================
|
||||
AVAILABLE_TOOLS = [
|
||||
"CodeDocsSearchTool", "CSVSearchTool", "DirectorySearchTool", "DOCXSearchTool",
|
||||
"DirectoryReadTool", "FileReadTool", "TXTSearchTool", "JSONSearchTool",
|
||||
"MDXSearchTool", "PDFSearchTool", "RagTool", "ScrapeElementFromWebsiteTool",
|
||||
"ScrapeWebsiteTool", "WebsiteSearchTool", "XMLSearchTool",
|
||||
"YoutubeChannelSearchTool", "YoutubeVideoSearchTool"
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Base Generator Class (DRY - shared functionality)
|
||||
# =============================================================================
|
||||
class BaseAutoGenerator:
|
||||
"""
|
||||
Base class for auto-generators with shared functionality.
|
||||
|
||||
Provides:
|
||||
- Lazy-loaded instructor client (LiteLLM or OpenAI fallback)
|
||||
- Environment variable handling for model/API configuration
|
||||
- Config list management
|
||||
"""
|
||||
|
||||
def __init__(self, config_list: Optional[List[Dict]] = None):
|
||||
"""
|
||||
Initialize base generator with LLM configuration.
|
||||
|
||||
Args:
|
||||
config_list: Optional LLM configuration list
|
||||
"""
|
||||
# Support multiple environment variable patterns for better compatibility
|
||||
model_name = os.environ.get("MODEL_NAME") or os.environ.get("OPENAI_MODEL_NAME", "gpt-4o-mini")
|
||||
base_url = (
|
||||
os.environ.get("OPENAI_BASE_URL") or
|
||||
os.environ.get("OPENAI_API_BASE") or
|
||||
os.environ.get("OLLAMA_API_BASE", "https://api.openai.com/v1")
|
||||
)
|
||||
|
||||
self.config_list = config_list or [
|
||||
{
|
||||
'model': model_name,
|
||||
'base_url': base_url,
|
||||
'api_key': os.environ.get("OPENAI_API_KEY")
|
||||
}
|
||||
]
|
||||
self._client = None # Lazy loading for performance
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
"""Lazy load the instructor client to avoid performance impact.
|
||||
|
||||
Uses LiteLLM via instructor.from_provider if available for multi-provider support,
|
||||
otherwise falls back to direct OpenAI SDK.
|
||||
"""
|
||||
if self._client is None:
|
||||
if LITELLM_AVAILABLE:
|
||||
# Use LiteLLM for multi-provider support (100+ LLMs)
|
||||
model_name = self.config_list[0]['model']
|
||||
self._client = instructor.from_provider(
|
||||
f"litellm/{model_name}",
|
||||
mode=instructor.Mode.JSON
|
||||
)
|
||||
else:
|
||||
# Fallback to direct OpenAI SDK
|
||||
self._client = instructor.patch(
|
||||
OpenAI(
|
||||
base_url=self.config_list[0]['base_url'],
|
||||
api_key=self.config_list[0]['api_key'],
|
||||
),
|
||||
mode=instructor.Mode.JSON,
|
||||
)
|
||||
return self._client
|
||||
|
||||
@staticmethod
|
||||
def get_available_tools() -> List[str]:
|
||||
"""Return list of available tools for agent assignment."""
|
||||
return AVAILABLE_TOOLS.copy()
|
||||
|
||||
@staticmethod
|
||||
def analyze_complexity(topic: str) -> str:
|
||||
"""
|
||||
Analyze task complexity based on keywords.
|
||||
|
||||
Args:
|
||||
topic: The task description
|
||||
|
||||
Returns:
|
||||
str: Complexity level - 'simple', 'moderate', or 'complex'
|
||||
"""
|
||||
topic_lower = topic.lower()
|
||||
|
||||
# Complex task indicators
|
||||
complex_keywords = [
|
||||
'comprehensive', 'multi-step', 'analyze and', 'research and write',
|
||||
'multiple', 'coordinate', 'complex', 'detailed analysis',
|
||||
'full report', 'in-depth', 'thorough'
|
||||
]
|
||||
|
||||
# Simple task indicators
|
||||
simple_keywords = [
|
||||
'write a', 'create a', 'simple', 'quick', 'brief',
|
||||
'haiku', 'poem', 'summary', 'list', 'single'
|
||||
]
|
||||
|
||||
if any(kw in topic_lower for kw in complex_keywords):
|
||||
return 'complex'
|
||||
elif any(kw in topic_lower for kw in simple_keywords):
|
||||
return 'simple'
|
||||
else:
|
||||
return 'moderate'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Models for Structured Output
|
||||
# =============================================================================
|
||||
|
||||
class TaskDetails(BaseModel):
|
||||
"""Details for a single task."""
|
||||
description: str
|
||||
expected_output: str
|
||||
|
||||
class RoleDetails(BaseModel):
|
||||
"""Details for a single role/agent."""
|
||||
role: str
|
||||
goal: str
|
||||
backstory: str
|
||||
@@ -66,14 +192,62 @@ class RoleDetails(BaseModel):
|
||||
tools: List[str]
|
||||
|
||||
class TeamStructure(BaseModel):
|
||||
"""Structure for multi-agent team."""
|
||||
roles: Dict[str, RoleDetails]
|
||||
|
||||
class AutoGenerator:
|
||||
def __init__(self, topic="Movie Story writing about AI", agent_file="test.yaml", framework="crewai", config_list: Optional[List[Dict]] = None):
|
||||
class SingleAgentStructure(BaseModel):
|
||||
"""Structure for single-agent generation (Anthropic's 'start simple' principle)."""
|
||||
name: str
|
||||
role: str
|
||||
goal: str
|
||||
backstory: str
|
||||
instructions: str
|
||||
tools: List[str] = []
|
||||
task_description: str
|
||||
expected_output: str
|
||||
|
||||
class PatternRecommendation(BaseModel):
|
||||
"""LLM-based pattern recommendation with reasoning."""
|
||||
pattern: str # sequential, parallel, routing, orchestrator-workers, evaluator-optimizer
|
||||
reasoning: str # Why this pattern was chosen
|
||||
confidence: float # 0.0 to 1.0 confidence score
|
||||
|
||||
class ValidationGate(BaseModel):
|
||||
"""Validation gate for prompt chaining workflows."""
|
||||
criteria: str # What to validate
|
||||
pass_action: str # Action if validation passes (e.g., "continue", "next_step")
|
||||
fail_action: str # Action if validation fails (e.g., "retry", "escalate", "abort")
|
||||
|
||||
class AutoGenerator(BaseAutoGenerator):
|
||||
"""
|
||||
Auto-generates agents.yaml files from a topic description.
|
||||
|
||||
Inherits from BaseAutoGenerator for shared LLM client functionality.
|
||||
|
||||
Usage:
|
||||
generator = AutoGenerator(framework="crewai", topic="Create a movie script")
|
||||
path = generator.generate()
|
||||
"""
|
||||
|
||||
def __init__(self, topic="Movie Story writing about AI", agent_file="test.yaml",
|
||||
framework="crewai", config_list: Optional[List[Dict]] = None,
|
||||
pattern: str = "sequential", single_agent: bool = False):
|
||||
"""
|
||||
Initialize the AutoGenerator class with the specified topic, agent file, and framework.
|
||||
|
||||
Args:
|
||||
topic: The task/topic for agent generation
|
||||
agent_file: Output YAML file name
|
||||
framework: Framework to use (crewai, autogen, praisonai)
|
||||
config_list: Optional LLM configuration
|
||||
pattern: Workflow pattern (sequential, parallel, routing, orchestrator-workers, evaluator-optimizer)
|
||||
single_agent: If True, generate a single agent instead of a team
|
||||
|
||||
Note: autogen framework is different from this AutoGenerator class.
|
||||
"""
|
||||
# Initialize base class first (handles config_list and client)
|
||||
super().__init__(config_list=config_list)
|
||||
|
||||
# Validate framework availability and show framework-specific messages
|
||||
if framework == "crewai" and not CREWAI_AVAILABLE:
|
||||
raise ImportError("""
|
||||
@@ -106,35 +280,42 @@ Tools are not available for {framework}. To use tools, install:
|
||||
pip install "praisonai[{framework}]"
|
||||
""")
|
||||
|
||||
# Support multiple environment variable patterns for better compatibility
|
||||
# Priority order: MODEL_NAME > OPENAI_MODEL_NAME for model selection
|
||||
model_name = os.environ.get("MODEL_NAME") or os.environ.get("OPENAI_MODEL_NAME", "gpt-5-nano")
|
||||
|
||||
# Priority order for base_url: OPENAI_BASE_URL > OPENAI_API_BASE > OLLAMA_API_BASE
|
||||
# OPENAI_BASE_URL is the standard OpenAI SDK environment variable
|
||||
base_url = (
|
||||
os.environ.get("OPENAI_BASE_URL") or
|
||||
os.environ.get("OPENAI_API_BASE") or
|
||||
os.environ.get("OLLAMA_API_BASE", "https://api.openai.com/v1")
|
||||
)
|
||||
|
||||
self.config_list = config_list or [
|
||||
{
|
||||
'model': model_name,
|
||||
'base_url': base_url,
|
||||
'api_key': os.environ.get("OPENAI_API_KEY")
|
||||
}
|
||||
]
|
||||
self.topic = topic
|
||||
self.agent_file = agent_file
|
||||
self.framework = framework or "praisonai"
|
||||
self.client = instructor.patch(
|
||||
OpenAI(
|
||||
base_url=self.config_list[0]['base_url'],
|
||||
api_key=self.config_list[0]['api_key'],
|
||||
),
|
||||
mode=instructor.Mode.JSON,
|
||||
)
|
||||
self.pattern = pattern
|
||||
self.single_agent = single_agent
|
||||
|
||||
def recommend_pattern(self, topic: str = None) -> str:
|
||||
"""
|
||||
Recommend the best workflow pattern based on task characteristics.
|
||||
|
||||
Args:
|
||||
topic: The task description (uses self.topic if not provided)
|
||||
|
||||
Returns:
|
||||
str: Recommended pattern name
|
||||
"""
|
||||
task = topic or self.topic
|
||||
task_lower = task.lower()
|
||||
|
||||
# Keywords that suggest specific patterns
|
||||
parallel_keywords = ['multiple', 'concurrent', 'parallel', 'simultaneously', 'different sources', 'compare', 'various']
|
||||
routing_keywords = ['classify', 'categorize', 'route', 'different types', 'depending on', 'if...then']
|
||||
orchestrator_keywords = ['complex', 'comprehensive', 'multi-step', 'coordinate', 'delegate', 'break down', 'analyze and']
|
||||
evaluator_keywords = ['refine', 'improve', 'iterate', 'quality', 'review', 'feedback', 'polish', 'optimize']
|
||||
|
||||
# Check for pattern indicators
|
||||
if any(kw in task_lower for kw in evaluator_keywords):
|
||||
return "evaluator-optimizer"
|
||||
elif any(kw in task_lower for kw in orchestrator_keywords):
|
||||
return "orchestrator-workers"
|
||||
elif any(kw in task_lower for kw in routing_keywords):
|
||||
return "routing"
|
||||
elif any(kw in task_lower for kw in parallel_keywords):
|
||||
return "parallel"
|
||||
else:
|
||||
return "sequential"
|
||||
|
||||
def generate(self, merge=False):
|
||||
"""
|
||||
@@ -157,7 +338,8 @@ Tools are not available for {framework}. To use tools, install:
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.config_list[0]['model'],
|
||||
response_model=TeamStructure,
|
||||
max_retries=10,
|
||||
max_retries=5,
|
||||
timeout=120.0, # 2 minute timeout for complex generations
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant designed to output complex team structures."},
|
||||
{"role": "user", "content": self.get_user_content()}
|
||||
@@ -194,8 +376,7 @@ Tools are not available for {framework}. To use tools, install:
|
||||
"goal": role_details['goal'],
|
||||
"role": role_details['role'],
|
||||
"tasks": {},
|
||||
# "tools": role_details.get('tools', []),
|
||||
"tools": ['']
|
||||
"tools": role_details.get('tools', [])
|
||||
}
|
||||
|
||||
for task_id, task_details in role_details['tasks'].items():
|
||||
@@ -265,7 +446,7 @@ Tools are not available for {framework}. To use tools, install:
|
||||
"goal": role_details['goal'],
|
||||
"role": role_details['role'],
|
||||
"tasks": {},
|
||||
"tools": ['']
|
||||
"tools": role_details.get('tools', [])
|
||||
}
|
||||
|
||||
# Add tasks for this role
|
||||
@@ -292,45 +473,77 @@ Tools are not available for {framework}. To use tools, install:
|
||||
prompt = generator.get_user_content()
|
||||
print(prompt)
|
||||
"""
|
||||
user_content = """Generate a team structure for \"""" + self.topic + """\" task.
|
||||
No Input data will be provided to the team.
|
||||
The team will work in sequence. First role will pass the output to the next role, and so on.
|
||||
The last role will generate the final output.
|
||||
Think step by step.
|
||||
With maximum 3 roles, each with 1 task. Include role goals, backstories, task descriptions, and expected outputs.
|
||||
List of Available Tools: CodeDocsSearchTool, CSVSearchTool, DirectorySearchTool, DOCXSearchTool, DirectoryReadTool, FileReadTool, TXTSearchTool, JSONSearchTool, MDXSearchTool, PDFSearchTool, RagTool, ScrapeElementFromWebsiteTool, ScrapeWebsiteTool, WebsiteSearchTool, XMLSearchTool, YoutubeChannelSearchTool, YoutubeVideoSearchTool.
|
||||
Only use Available Tools. Do Not use any other tools.
|
||||
Example Below:
|
||||
Use below example to understand the structure of the output.
|
||||
The final role you create should satisfy the provided task: """ + self.topic + """.
|
||||
{
|
||||
"roles": {
|
||||
"narrative_designer": {
|
||||
"role": "Narrative Designer",
|
||||
"goal": "Create AI storylines",
|
||||
"backstory": "Skilled in narrative development for AI, with a focus on story resonance.",
|
||||
"tools": ["ScrapeWebsiteTool"],
|
||||
"tasks": {
|
||||
"story_concept_development": {
|
||||
"description": "Craft a unique AI story concept with depth and engagement using concept from this page the content https://www.asthebirdfliesblog.com/posts/how-to-write-book-story-development .",
|
||||
"expected_output": "Document with narrative arcs, character bios, and settings."
|
||||
}
|
||||
}
|
||||
},
|
||||
"scriptwriter": {
|
||||
"role": "Scriptwriter",
|
||||
"goal": "Write scripts from AI concepts",
|
||||
"backstory": "Expert in dialogue and script structure, translating concepts into scripts.",
|
||||
"tasks": {
|
||||
"scriptwriting_task": {
|
||||
"description": "Turn narrative concepts into scripts, including dialogue and scenes.",
|
||||
"expected_output": "Production-ready script with dialogue and scene details."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
# Pattern-specific guidance
|
||||
pattern_guidance = {
|
||||
"sequential": "The team will work in sequence. Each role passes output to the next.",
|
||||
"parallel": "The team will work in parallel on independent subtasks, then combine results.",
|
||||
"routing": "A classifier agent will route requests to specialized agents based on input type.",
|
||||
"orchestrator-workers": "A central orchestrator will dynamically delegate tasks to specialized workers.",
|
||||
"evaluator-optimizer": "One agent generates content, another evaluates it in a loop until quality criteria are met."
|
||||
}
|
||||
|
||||
workflow_guidance = pattern_guidance.get(self.pattern, pattern_guidance["sequential"])
|
||||
|
||||
user_content = f"""Analyze and generate a team structure for: "{self.topic}"
|
||||
|
||||
STEP 1: TASK COMPLEXITY ANALYSIS
|
||||
First, analyze the task complexity:
|
||||
- Is this a simple task that 1-2 agents could handle?
|
||||
- Does it require multiple specialized perspectives?
|
||||
- Are there clear sequential dependencies between steps?
|
||||
|
||||
STEP 2: DETERMINE OPTIMAL TEAM SIZE
|
||||
Based on your analysis, create the minimum number of agents needed:
|
||||
- Simple tasks: 1-2 agents
|
||||
- Moderate tasks: 2-3 agents
|
||||
- Complex tasks: 3-4 agents (maximum)
|
||||
|
||||
IMPORTANT: Only create agents that provide meaningful specialization. Avoid unnecessary complexity.
|
||||
|
||||
STEP 3: DESIGN THE TEAM (Pattern: {self.pattern})
|
||||
{workflow_guidance}
|
||||
Each agent should have:
|
||||
- A clear, distinct role
|
||||
- A specific goal
|
||||
- Relevant backstory
|
||||
- 1 focused task with clear description and expected output
|
||||
- Appropriate tools (only if needed)
|
||||
|
||||
Available Tools: CodeDocsSearchTool, CSVSearchTool, DirectorySearchTool, DOCXSearchTool, DirectoryReadTool, FileReadTool, TXTSearchTool, JSONSearchTool, MDXSearchTool, PDFSearchTool, RagTool, ScrapeElementFromWebsiteTool, ScrapeWebsiteTool, WebsiteSearchTool, XMLSearchTool, YoutubeChannelSearchTool, YoutubeVideoSearchTool.
|
||||
Only use these tools if the task requires them. Use empty list [] if no tools needed.
|
||||
|
||||
Example structure (2 agents for a writing task):
|
||||
{{
|
||||
"roles": {{
|
||||
"researcher": {{
|
||||
"role": "Research Analyst",
|
||||
"goal": "Gather comprehensive information on the topic",
|
||||
"backstory": "Expert researcher skilled at finding and synthesizing information.",
|
||||
"tools": ["WebsiteSearchTool"],
|
||||
"tasks": {{
|
||||
"research_task": {{
|
||||
"description": "Research key information about the topic and compile findings.",
|
||||
"expected_output": "Comprehensive research notes with key facts and insights."
|
||||
}}
|
||||
}}
|
||||
}},
|
||||
"writer": {{
|
||||
"role": "Content Writer",
|
||||
"goal": "Create polished final content",
|
||||
"backstory": "Skilled writer who transforms research into engaging content.",
|
||||
"tools": [],
|
||||
"tasks": {{
|
||||
"writing_task": {{
|
||||
"description": "Write the final content based on research findings.",
|
||||
"expected_output": "Polished, well-structured final document."
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
Now generate the optimal team structure for: {self.topic}
|
||||
"""
|
||||
return user_content
|
||||
|
||||
|
||||
@@ -372,13 +585,14 @@ class WorkflowStructure(BaseModel):
|
||||
description: str
|
||||
agents: Dict[str, WorkflowAgentDetails]
|
||||
steps: List[Dict] # Can be agent steps, route, parallel, etc.
|
||||
gates: Optional[List[ValidationGate]] = None # Optional validation gates
|
||||
|
||||
|
||||
class WorkflowAutoGenerator:
|
||||
class WorkflowAutoGenerator(BaseAutoGenerator):
|
||||
"""
|
||||
Auto-generates workflow.yaml files from a topic description.
|
||||
|
||||
Uses lazy loading to avoid performance impact when not used.
|
||||
Inherits from BaseAutoGenerator for shared LLM client functionality.
|
||||
|
||||
Usage:
|
||||
generator = WorkflowAutoGenerator(topic="Research AI trends and write a report")
|
||||
@@ -387,7 +601,9 @@ class WorkflowAutoGenerator:
|
||||
|
||||
def __init__(self, topic: str = "Research and write about AI",
|
||||
workflow_file: str = "workflow.yaml",
|
||||
config_list: Optional[List[Dict]] = None):
|
||||
config_list: Optional[List[Dict]] = None,
|
||||
framework: str = "praisonai",
|
||||
single_agent: bool = False):
|
||||
"""
|
||||
Initialize the WorkflowAutoGenerator.
|
||||
|
||||
@@ -395,45 +611,105 @@ class WorkflowAutoGenerator:
|
||||
topic: The task/topic for the workflow
|
||||
workflow_file: Output file name
|
||||
config_list: Optional LLM configuration
|
||||
framework: Framework to use (praisonai, crewai, autogen)
|
||||
single_agent: If True, generate a single agent workflow
|
||||
"""
|
||||
# Support multiple environment variable patterns
|
||||
model_name = os.environ.get("MODEL_NAME") or os.environ.get("OPENAI_MODEL_NAME", "gpt-4o-mini")
|
||||
base_url = (
|
||||
os.environ.get("OPENAI_BASE_URL") or
|
||||
os.environ.get("OPENAI_API_BASE") or
|
||||
os.environ.get("OLLAMA_API_BASE", "https://api.openai.com/v1")
|
||||
)
|
||||
# Initialize base class (handles config_list and client)
|
||||
super().__init__(config_list=config_list)
|
||||
|
||||
self.config_list = config_list or [
|
||||
{
|
||||
'model': model_name,
|
||||
'base_url': base_url,
|
||||
'api_key': os.environ.get("OPENAI_API_KEY")
|
||||
}
|
||||
]
|
||||
self.topic = topic
|
||||
self.workflow_file = workflow_file
|
||||
self._client = None # Lazy loading
|
||||
self.framework = framework
|
||||
self.single_agent = single_agent
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
"""Lazy load the OpenAI client to avoid performance impact."""
|
||||
if self._client is None:
|
||||
self._client = instructor.patch(
|
||||
OpenAI(
|
||||
base_url=self.config_list[0]['base_url'],
|
||||
api_key=self.config_list[0]['api_key'],
|
||||
),
|
||||
mode=instructor.Mode.JSON,
|
||||
)
|
||||
return self._client
|
||||
def recommend_pattern(self, topic: str = None) -> str:
|
||||
"""
|
||||
Recommend the best workflow pattern based on task characteristics.
|
||||
|
||||
Args:
|
||||
topic: The task description (uses self.topic if not provided)
|
||||
|
||||
Returns:
|
||||
str: Recommended pattern name
|
||||
|
||||
Pattern recommendations based on Anthropic's best practices:
|
||||
- sequential: Clear step-by-step dependencies
|
||||
- parallel: Independent subtasks that can run concurrently
|
||||
- routing: Different input types need different handling
|
||||
- orchestrator-workers: Complex tasks needing dynamic decomposition
|
||||
- evaluator-optimizer: Tasks requiring iterative refinement
|
||||
"""
|
||||
task = topic or self.topic
|
||||
task_lower = task.lower()
|
||||
|
||||
# Keywords that suggest specific patterns
|
||||
parallel_keywords = ['multiple', 'concurrent', 'parallel', 'simultaneously', 'different sources', 'compare', 'various']
|
||||
routing_keywords = ['classify', 'categorize', 'route', 'different types', 'depending on', 'if...then']
|
||||
orchestrator_keywords = ['complex', 'comprehensive', 'multi-step', 'coordinate', 'delegate', 'break down', 'analyze and']
|
||||
evaluator_keywords = ['refine', 'improve', 'iterate', 'quality', 'review', 'feedback', 'polish', 'optimize']
|
||||
|
||||
# Check for pattern indicators
|
||||
if any(kw in task_lower for kw in evaluator_keywords):
|
||||
return "evaluator-optimizer"
|
||||
elif any(kw in task_lower for kw in orchestrator_keywords):
|
||||
return "orchestrator-workers"
|
||||
elif any(kw in task_lower for kw in routing_keywords):
|
||||
return "routing"
|
||||
elif any(kw in task_lower for kw in parallel_keywords):
|
||||
return "parallel"
|
||||
else:
|
||||
return "sequential"
|
||||
|
||||
def generate(self, pattern: str = "sequential") -> str:
|
||||
def recommend_pattern_llm(self, topic: str = None) -> PatternRecommendation:
|
||||
"""
|
||||
Use LLM to recommend the best workflow pattern with reasoning.
|
||||
|
||||
Args:
|
||||
topic: The task description (uses self.topic if not provided)
|
||||
|
||||
Returns:
|
||||
PatternRecommendation: Pattern with reasoning and confidence score
|
||||
"""
|
||||
task = topic or self.topic
|
||||
|
||||
prompt = f"""Analyze this task and recommend the best workflow pattern:
|
||||
|
||||
Task: "{task}"
|
||||
|
||||
Available patterns:
|
||||
1. sequential - Agents work one after another, passing output to the next
|
||||
2. parallel - Multiple agents work concurrently on independent subtasks
|
||||
3. routing - A classifier routes requests to specialized agents based on input type
|
||||
4. orchestrator-workers - Central orchestrator dynamically delegates to specialized workers
|
||||
5. evaluator-optimizer - Generator creates content, evaluator reviews in a loop until quality met
|
||||
|
||||
Respond with:
|
||||
- pattern: The recommended pattern name
|
||||
- reasoning: Why this pattern is best for this task
|
||||
- confidence: Your confidence score (0.0 to 1.0)
|
||||
"""
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.config_list[0]['model'],
|
||||
response_model=PatternRecommendation,
|
||||
max_retries=3,
|
||||
timeout=60.0,
|
||||
messages=[
|
||||
{"role": "system", "content": "You are an expert at designing AI agent workflows."},
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def generate(self, pattern: str = "sequential", merge: bool = False) -> str:
|
||||
"""
|
||||
Generate a workflow YAML file.
|
||||
|
||||
Args:
|
||||
pattern: Workflow pattern - "sequential", "routing", "parallel", "loop"
|
||||
pattern: Workflow pattern - "sequential", "routing", "parallel", "loop",
|
||||
"orchestrator-workers", "evaluator-optimizer"
|
||||
merge: If True, merge with existing workflow file instead of overwriting
|
||||
|
||||
Returns:
|
||||
Path to the generated workflow file
|
||||
@@ -442,6 +718,7 @@ class WorkflowAutoGenerator:
|
||||
model=self.config_list[0]['model'],
|
||||
response_model=WorkflowStructure,
|
||||
max_retries=5,
|
||||
timeout=120.0, # 2 minute timeout for complex generations
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant that designs workflow structures."},
|
||||
{"role": "user", "content": self._get_prompt(pattern)}
|
||||
@@ -449,16 +726,86 @@ class WorkflowAutoGenerator:
|
||||
)
|
||||
|
||||
json_data = json.loads(response.model_dump_json())
|
||||
|
||||
if merge and os.path.exists(self.workflow_file):
|
||||
return self._save_workflow(self.merge_with_existing_workflow(json_data), pattern)
|
||||
return self._save_workflow(json_data, pattern)
|
||||
|
||||
def merge_with_existing_workflow(self, new_data: Dict) -> Dict:
|
||||
"""
|
||||
Merge new workflow data with existing workflow file.
|
||||
|
||||
Args:
|
||||
new_data: The new workflow data to merge
|
||||
|
||||
Returns:
|
||||
Dict: Merged workflow data
|
||||
"""
|
||||
try:
|
||||
with open(self.workflow_file, 'r') as f:
|
||||
existing_data = yaml.safe_load(f)
|
||||
|
||||
if not existing_data:
|
||||
return new_data
|
||||
except (yaml.YAMLError, FileNotFoundError) as e:
|
||||
logging.warning(f"Could not load existing workflow file {self.workflow_file}: {e}")
|
||||
return new_data
|
||||
|
||||
# Merge agents (avoid duplicates)
|
||||
merged_agents = existing_data.get('agents', {}).copy()
|
||||
for agent_id, agent_data in new_data.get('agents', {}).items():
|
||||
# Rename if conflict
|
||||
final_id = agent_id
|
||||
counter = 1
|
||||
while final_id in merged_agents:
|
||||
final_id = f"{agent_id}_auto_{counter}"
|
||||
counter += 1
|
||||
merged_agents[final_id] = agent_data
|
||||
|
||||
# Merge steps (append new steps)
|
||||
merged_steps = existing_data.get('steps', []) + new_data.get('steps', [])
|
||||
|
||||
# Create merged structure
|
||||
merged = {
|
||||
'name': existing_data.get('name', new_data.get('name', 'Merged Workflow')),
|
||||
'description': f"{existing_data.get('description', '')} + {new_data.get('description', '')}",
|
||||
'agents': merged_agents,
|
||||
'steps': merged_steps
|
||||
}
|
||||
|
||||
return merged
|
||||
|
||||
def _get_prompt(self, pattern: str) -> str:
|
||||
"""Generate the prompt based on the workflow pattern."""
|
||||
# Analyze complexity to determine agent count
|
||||
complexity = self.analyze_complexity(self.topic)
|
||||
if complexity == 'simple':
|
||||
agent_guidance = "Create 1-2 agents (simple task detected)."
|
||||
elif complexity == 'complex':
|
||||
agent_guidance = "Create 3-4 agents (complex task detected)."
|
||||
else:
|
||||
agent_guidance = "Create 2-3 agents (moderate task detected)."
|
||||
|
||||
# Get available tools
|
||||
tools_list = ", ".join(self.get_available_tools())
|
||||
|
||||
base_prompt = f"""Generate a workflow structure for: "{self.topic}"
|
||||
|
||||
STEP 1: ANALYZE TASK COMPLEXITY
|
||||
- Is this a simple task (1-2 agents)?
|
||||
- Does it require multiple specialists (2-3 agents)?
|
||||
- Is it complex with many dependencies (3-4 agents)?
|
||||
|
||||
STEP 2: DESIGN WORKFLOW
|
||||
The workflow should use the "{pattern}" pattern.
|
||||
Create 2-4 agents with clear roles and instructions.
|
||||
{agent_guidance}
|
||||
Each agent should have clear roles and instructions.
|
||||
Each step should have a clear action.
|
||||
|
||||
STEP 3: ASSIGN TOOLS (if needed)
|
||||
Available Tools: {tools_list}
|
||||
Only assign tools if the task requires them. Use empty list or null if no tools needed.
|
||||
|
||||
"""
|
||||
|
||||
if pattern == "routing":
|
||||
@@ -501,6 +848,59 @@ Example structure:
|
||||
{"agent": "aggregator", "action": "Combine all findings"}
|
||||
]
|
||||
}
|
||||
"""
|
||||
elif pattern == "orchestrator-workers":
|
||||
base_prompt += """
|
||||
Create an orchestrator-workers workflow where a central orchestrator dynamically delegates tasks to specialized workers.
|
||||
The orchestrator analyzes the input, decides which workers are needed, and synthesizes results.
|
||||
|
||||
Example structure:
|
||||
{
|
||||
"name": "Orchestrator-Workers Workflow",
|
||||
"description": "Central orchestrator delegates to specialized workers",
|
||||
"agents": {
|
||||
"orchestrator": {"name": "Orchestrator", "role": "Task Coordinator", "goal": "Analyze tasks and delegate to appropriate workers", "instructions": "Break down the task, identify required specialists, and coordinate their work. Output a JSON with 'subtasks' array listing which workers to invoke."},
|
||||
"researcher": {"name": "Researcher", "role": "Research Specialist", "goal": "Gather information", "instructions": "Research and provide factual information"},
|
||||
"analyst": {"name": "Analyst", "role": "Data Analyst", "goal": "Analyze data and patterns", "instructions": "Analyze information and identify insights"},
|
||||
"writer": {"name": "Writer", "role": "Content Writer", "goal": "Create written content", "instructions": "Write clear, engaging content"},
|
||||
"synthesizer": {"name": "Synthesizer", "role": "Results Synthesizer", "goal": "Combine all worker outputs", "instructions": "Synthesize all worker outputs into a coherent final result"}
|
||||
},
|
||||
"steps": [
|
||||
{"agent": "orchestrator", "action": "Analyze task and determine required workers: {{input}}"},
|
||||
{"name": "worker_dispatch", "parallel": [
|
||||
{"agent": "researcher", "action": "Research: {{input}}"},
|
||||
{"agent": "analyst", "action": "Analyze: {{input}}"},
|
||||
{"agent": "writer", "action": "Draft content for: {{input}}"}
|
||||
]},
|
||||
{"agent": "synthesizer", "action": "Combine all worker outputs into final result"}
|
||||
]
|
||||
}
|
||||
"""
|
||||
elif pattern == "evaluator-optimizer":
|
||||
base_prompt += """
|
||||
Create an evaluator-optimizer workflow where one agent generates content and another evaluates it in a loop.
|
||||
The generator improves based on evaluator feedback until quality criteria are met.
|
||||
|
||||
Example structure:
|
||||
{
|
||||
"name": "Evaluator-Optimizer Workflow",
|
||||
"description": "Iterative refinement through generation and evaluation",
|
||||
"agents": {
|
||||
"generator": {"name": "Generator", "role": "Content Generator", "goal": "Generate high-quality content", "instructions": "Create content based on the input. If feedback is provided, improve the content accordingly."},
|
||||
"evaluator": {"name": "Evaluator", "role": "Quality Evaluator", "goal": "Evaluate content quality", "instructions": "Evaluate the content on: clarity, accuracy, completeness, and relevance. Score 1-10 for each. If average score < 7, provide specific improvement feedback. If score >= 7, respond with 'APPROVED'."}
|
||||
},
|
||||
"steps": [
|
||||
{"agent": "generator", "action": "Generate initial content for: {{input}}"},
|
||||
{"name": "evaluation_loop", "loop": {
|
||||
"agent": "evaluator",
|
||||
"action": "Evaluate the generated content",
|
||||
"condition": "output does not contain 'APPROVED'",
|
||||
"max_iterations": 3,
|
||||
"feedback_to": "generator"
|
||||
}},
|
||||
{"agent": "generator", "action": "Finalize content based on all feedback"}
|
||||
]
|
||||
}
|
||||
"""
|
||||
else: # sequential
|
||||
base_prompt += """
|
||||
|
||||
@@ -21,6 +21,10 @@ Features:
|
||||
- flow_display: Visual workflow tracking
|
||||
- workflow: YAML workflow management
|
||||
- n8n: Export workflows to n8n visual editor
|
||||
- external_agents: External AI CLI tool integrations (Claude, Gemini, Codex, Cursor)
|
||||
- slash_commands: Interactive slash commands (/help, /cost, /plan, etc.)
|
||||
- autonomy_mode: Autonomy levels (suggest, auto-edit, full-auto)
|
||||
- cost_tracker: Real-time cost and token tracking
|
||||
"""
|
||||
|
||||
# Type hints available for IDE support
|
||||
@@ -43,6 +47,15 @@ __all__ = [
|
||||
'FlowDisplayHandler',
|
||||
'WorkflowHandler',
|
||||
'N8nHandler',
|
||||
'ExternalAgentsHandler',
|
||||
# New CLI enhancement features
|
||||
'SlashCommandHandler',
|
||||
'AutonomyModeHandler',
|
||||
'CostTrackerHandler',
|
||||
'RepoMapHandler',
|
||||
'InteractiveTUIHandler',
|
||||
'GitIntegrationHandler',
|
||||
'SandboxExecutorHandler',
|
||||
]
|
||||
|
||||
def __getattr__(name):
|
||||
@@ -95,4 +108,29 @@ def __getattr__(name):
|
||||
elif name == 'N8nHandler':
|
||||
from .n8n import N8nHandler
|
||||
return N8nHandler
|
||||
elif name == 'ExternalAgentsHandler':
|
||||
from .external_agents import ExternalAgentsHandler
|
||||
return ExternalAgentsHandler
|
||||
# New CLI enhancement features
|
||||
elif name == 'SlashCommandHandler':
|
||||
from .slash_commands import SlashCommandHandler
|
||||
return SlashCommandHandler
|
||||
elif name == 'AutonomyModeHandler':
|
||||
from .autonomy_mode import AutonomyModeHandler
|
||||
return AutonomyModeHandler
|
||||
elif name == 'CostTrackerHandler':
|
||||
from .cost_tracker import CostTrackerHandler
|
||||
return CostTrackerHandler
|
||||
elif name == 'RepoMapHandler':
|
||||
from .repo_map import RepoMapHandler
|
||||
return RepoMapHandler
|
||||
elif name == 'InteractiveTUIHandler':
|
||||
from .interactive_tui import InteractiveTUIHandler
|
||||
return InteractiveTUIHandler
|
||||
elif name == 'GitIntegrationHandler':
|
||||
from .git_integration import GitIntegrationHandler
|
||||
return GitIntegrationHandler
|
||||
elif name == 'SandboxExecutorHandler':
|
||||
from .sandbox_executor import SandboxExecutorHandler
|
||||
return SandboxExecutorHandler
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,490 @@
|
||||
"""
|
||||
Autonomy Modes for PraisonAI CLI.
|
||||
|
||||
Inspired by Codex CLI's approval modes and Gemini CLI's approval settings.
|
||||
Provides configurable autonomy levels: suggest, auto-edit, full-auto.
|
||||
|
||||
Architecture:
|
||||
- AutonomyMode: Enum for different autonomy levels
|
||||
- AutonomyPolicy: Policy configuration for each mode
|
||||
- AutonomyManager: Manages mode transitions and approvals
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, Optional, Set
|
||||
from enum import Enum
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AutonomyMode(Enum):
|
||||
"""
|
||||
Autonomy levels for agent execution.
|
||||
|
||||
Inspired by Codex CLI's approval modes:
|
||||
- SUGGEST: Read-only, requires approval for all changes
|
||||
- AUTO_EDIT: Auto-approve file edits, require approval for commands
|
||||
- FULL_AUTO: Auto-approve everything (YOLO mode)
|
||||
"""
|
||||
SUGGEST = "suggest" # Default: ask for approval on everything
|
||||
AUTO_EDIT = "auto_edit" # Auto-approve file edits only
|
||||
FULL_AUTO = "full_auto" # Auto-approve everything (dangerous)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, value: str) -> "AutonomyMode":
|
||||
"""Parse mode from string."""
|
||||
value = value.lower().strip().replace("-", "_")
|
||||
for mode in cls:
|
||||
if mode.value == value:
|
||||
return mode
|
||||
raise ValueError(f"Unknown autonomy mode: {value}")
|
||||
|
||||
|
||||
class ActionType(Enum):
|
||||
"""Types of actions that require approval."""
|
||||
FILE_READ = "file_read"
|
||||
FILE_WRITE = "file_write"
|
||||
FILE_DELETE = "file_delete"
|
||||
SHELL_COMMAND = "shell_command"
|
||||
NETWORK_REQUEST = "network_request"
|
||||
CODE_EXECUTION = "code_execution"
|
||||
GIT_OPERATION = "git_operation"
|
||||
TOOL_CALL = "tool_call"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionRequest:
|
||||
"""
|
||||
Request for an action that may require approval.
|
||||
|
||||
Attributes:
|
||||
action_type: Type of action
|
||||
description: Human-readable description
|
||||
details: Additional details (file path, command, etc.)
|
||||
risk_level: Estimated risk (low, medium, high)
|
||||
reversible: Whether the action can be undone
|
||||
"""
|
||||
action_type: ActionType
|
||||
description: str
|
||||
details: Dict[str, Any] = field(default_factory=dict)
|
||||
risk_level: str = "medium"
|
||||
reversible: bool = True
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.action_type.value}: {self.description}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApprovalResult:
|
||||
"""Result of an approval request."""
|
||||
approved: bool
|
||||
reason: Optional[str] = None
|
||||
modified_action: Optional[ActionRequest] = None
|
||||
remember_choice: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutonomyPolicy:
|
||||
"""
|
||||
Policy configuration for autonomy mode.
|
||||
|
||||
Defines which actions are auto-approved, require approval,
|
||||
or are blocked entirely.
|
||||
"""
|
||||
mode: AutonomyMode
|
||||
|
||||
# Actions that are auto-approved
|
||||
auto_approve: Set[ActionType] = field(default_factory=set)
|
||||
|
||||
# Actions that always require approval
|
||||
require_approval: Set[ActionType] = field(default_factory=set)
|
||||
|
||||
# Actions that are blocked entirely
|
||||
blocked: Set[ActionType] = field(default_factory=set)
|
||||
|
||||
# Trusted paths for file operations
|
||||
trusted_paths: Set[str] = field(default_factory=set)
|
||||
|
||||
# Trusted commands (patterns)
|
||||
trusted_commands: Set[str] = field(default_factory=set)
|
||||
|
||||
# Maximum auto-approved actions before requiring confirmation
|
||||
max_auto_actions: int = 10
|
||||
|
||||
# Whether to show what would be done in suggest mode
|
||||
show_preview: bool = True
|
||||
|
||||
@classmethod
|
||||
def for_mode(cls, mode: AutonomyMode) -> "AutonomyPolicy":
|
||||
"""Create default policy for a given mode."""
|
||||
if mode == AutonomyMode.SUGGEST:
|
||||
return cls(
|
||||
mode=mode,
|
||||
auto_approve={ActionType.FILE_READ},
|
||||
require_approval={
|
||||
ActionType.FILE_WRITE,
|
||||
ActionType.FILE_DELETE,
|
||||
ActionType.SHELL_COMMAND,
|
||||
ActionType.CODE_EXECUTION,
|
||||
ActionType.GIT_OPERATION,
|
||||
ActionType.NETWORK_REQUEST,
|
||||
ActionType.TOOL_CALL,
|
||||
},
|
||||
blocked=set(),
|
||||
show_preview=True
|
||||
)
|
||||
elif mode == AutonomyMode.AUTO_EDIT:
|
||||
return cls(
|
||||
mode=mode,
|
||||
auto_approve={
|
||||
ActionType.FILE_READ,
|
||||
ActionType.FILE_WRITE,
|
||||
ActionType.TOOL_CALL,
|
||||
},
|
||||
require_approval={
|
||||
ActionType.FILE_DELETE,
|
||||
ActionType.SHELL_COMMAND,
|
||||
ActionType.CODE_EXECUTION,
|
||||
ActionType.GIT_OPERATION,
|
||||
ActionType.NETWORK_REQUEST,
|
||||
},
|
||||
blocked=set(),
|
||||
show_preview=True
|
||||
)
|
||||
elif mode == AutonomyMode.FULL_AUTO:
|
||||
return cls(
|
||||
mode=mode,
|
||||
auto_approve={
|
||||
ActionType.FILE_READ,
|
||||
ActionType.FILE_WRITE,
|
||||
ActionType.FILE_DELETE,
|
||||
ActionType.SHELL_COMMAND,
|
||||
ActionType.CODE_EXECUTION,
|
||||
ActionType.GIT_OPERATION,
|
||||
ActionType.NETWORK_REQUEST,
|
||||
ActionType.TOOL_CALL,
|
||||
},
|
||||
require_approval=set(),
|
||||
blocked=set(),
|
||||
show_preview=False,
|
||||
max_auto_actions=100
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown mode: {mode}")
|
||||
|
||||
|
||||
class AutonomyManager:
|
||||
"""
|
||||
Manages autonomy mode and approval flow.
|
||||
|
||||
Handles:
|
||||
- Mode transitions
|
||||
- Approval requests
|
||||
- Action tracking
|
||||
- Policy enforcement
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode: AutonomyMode = AutonomyMode.SUGGEST,
|
||||
policy: Optional[AutonomyPolicy] = None,
|
||||
approval_callback: Optional[Callable[[ActionRequest], ApprovalResult]] = None,
|
||||
verbose: bool = False
|
||||
):
|
||||
self.mode = mode
|
||||
self.policy = policy or AutonomyPolicy.for_mode(mode)
|
||||
self.approval_callback = approval_callback or self._default_approval
|
||||
self.verbose = verbose
|
||||
|
||||
# Tracking
|
||||
self._action_count = 0
|
||||
self._auto_approved_count = 0
|
||||
self._denied_count = 0
|
||||
self._remembered_approvals: Dict[str, bool] = {}
|
||||
|
||||
def set_mode(self, mode: AutonomyMode) -> None:
|
||||
"""Change the autonomy mode."""
|
||||
old_mode = self.mode
|
||||
self.mode = mode
|
||||
self.policy = AutonomyPolicy.for_mode(mode)
|
||||
|
||||
if self.verbose:
|
||||
logger.info(f"Autonomy mode changed: {old_mode.value} -> {mode.value}")
|
||||
|
||||
def request_approval(self, action: ActionRequest) -> ApprovalResult:
|
||||
"""
|
||||
Request approval for an action.
|
||||
|
||||
Returns:
|
||||
ApprovalResult with approval decision
|
||||
"""
|
||||
self._action_count += 1
|
||||
|
||||
# Check if blocked
|
||||
if action.action_type in self.policy.blocked:
|
||||
self._denied_count += 1
|
||||
return ApprovalResult(
|
||||
approved=False,
|
||||
reason=f"Action type '{action.action_type.value}' is blocked by policy"
|
||||
)
|
||||
|
||||
# Check remembered approvals
|
||||
action_key = self._get_action_key(action)
|
||||
if action_key in self._remembered_approvals:
|
||||
approved = self._remembered_approvals[action_key]
|
||||
if approved:
|
||||
self._auto_approved_count += 1
|
||||
else:
|
||||
self._denied_count += 1
|
||||
return ApprovalResult(approved=approved, reason="Remembered choice")
|
||||
|
||||
# Check if auto-approved
|
||||
if action.action_type in self.policy.auto_approve:
|
||||
# Check max auto actions
|
||||
if self._auto_approved_count >= self.policy.max_auto_actions:
|
||||
# Force approval after max auto actions
|
||||
return self._request_user_approval(action)
|
||||
|
||||
self._auto_approved_count += 1
|
||||
return ApprovalResult(approved=True, reason="Auto-approved by policy")
|
||||
|
||||
# Check if requires approval
|
||||
if action.action_type in self.policy.require_approval:
|
||||
return self._request_user_approval(action)
|
||||
|
||||
# Default: require approval for unknown action types
|
||||
return self._request_user_approval(action)
|
||||
|
||||
def _request_user_approval(self, action: ActionRequest) -> ApprovalResult:
|
||||
"""Request approval from user via callback."""
|
||||
result = self.approval_callback(action)
|
||||
|
||||
if result.remember_choice:
|
||||
action_key = self._get_action_key(action)
|
||||
self._remembered_approvals[action_key] = result.approved
|
||||
|
||||
if result.approved:
|
||||
self._auto_approved_count += 1
|
||||
else:
|
||||
self._denied_count += 1
|
||||
|
||||
return result
|
||||
|
||||
def _get_action_key(self, action: ActionRequest) -> str:
|
||||
"""Generate a key for remembering approval decisions."""
|
||||
# Use action type and relevant details
|
||||
key_parts = [action.action_type.value]
|
||||
|
||||
if "path" in action.details:
|
||||
key_parts.append(action.details["path"])
|
||||
if "command" in action.details:
|
||||
key_parts.append(action.details["command"])
|
||||
|
||||
return ":".join(key_parts)
|
||||
|
||||
def _default_approval(self, action: ActionRequest) -> ApprovalResult:
|
||||
"""Default approval callback using console input."""
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import Confirm
|
||||
|
||||
console = Console()
|
||||
|
||||
# Build action description
|
||||
risk_color = {
|
||||
"low": "green",
|
||||
"medium": "yellow",
|
||||
"high": "red"
|
||||
}.get(action.risk_level, "white")
|
||||
|
||||
details_str = "\n".join(
|
||||
f" {k}: {v}" for k, v in action.details.items()
|
||||
) if action.details else " (no details)"
|
||||
|
||||
panel_content = f"""
|
||||
[bold]{action.description}[/bold]
|
||||
|
||||
Type: {action.action_type.value}
|
||||
Risk: [{risk_color}]{action.risk_level}[/{risk_color}]
|
||||
Reversible: {"Yes" if action.reversible else "No"}
|
||||
|
||||
Details:
|
||||
{details_str}
|
||||
"""
|
||||
|
||||
console.print(Panel(
|
||||
panel_content,
|
||||
title="🔒 Approval Required",
|
||||
border_style="yellow"
|
||||
))
|
||||
|
||||
approved = Confirm.ask("Approve this action?", default=False)
|
||||
remember = False
|
||||
|
||||
if approved:
|
||||
remember = Confirm.ask("Remember this choice for similar actions?", default=False)
|
||||
|
||||
return ApprovalResult(
|
||||
approved=approved,
|
||||
remember_choice=remember
|
||||
)
|
||||
|
||||
def get_stats(self) -> Dict[str, int]:
|
||||
"""Get approval statistics."""
|
||||
return {
|
||||
"total_actions": self._action_count,
|
||||
"auto_approved": self._auto_approved_count,
|
||||
"denied": self._denied_count,
|
||||
"remembered": len(self._remembered_approvals)
|
||||
}
|
||||
|
||||
def reset_stats(self) -> None:
|
||||
"""Reset approval statistics."""
|
||||
self._action_count = 0
|
||||
self._auto_approved_count = 0
|
||||
self._denied_count = 0
|
||||
|
||||
def clear_remembered(self) -> None:
|
||||
"""Clear remembered approval decisions."""
|
||||
self._remembered_approvals.clear()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CLI Integration Handler
|
||||
# ============================================================================
|
||||
|
||||
class AutonomyModeHandler:
|
||||
"""
|
||||
Handler for integrating autonomy modes with PraisonAI CLI.
|
||||
"""
|
||||
|
||||
def __init__(self, verbose: bool = False):
|
||||
self.verbose = verbose
|
||||
self._manager: Optional[AutonomyManager] = None
|
||||
|
||||
@property
|
||||
def feature_name(self) -> str:
|
||||
return "autonomy_mode"
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
mode: str = "suggest",
|
||||
approval_callback: Optional[Callable[[ActionRequest], ApprovalResult]] = None
|
||||
) -> AutonomyManager:
|
||||
"""
|
||||
Initialize the autonomy manager.
|
||||
|
||||
Args:
|
||||
mode: Autonomy mode string (suggest, auto_edit, full_auto)
|
||||
approval_callback: Optional custom approval callback
|
||||
|
||||
Returns:
|
||||
Configured AutonomyManager
|
||||
"""
|
||||
try:
|
||||
autonomy_mode = AutonomyMode.from_string(mode)
|
||||
except ValueError:
|
||||
logger.warning(f"Unknown mode '{mode}', defaulting to 'suggest'")
|
||||
autonomy_mode = AutonomyMode.SUGGEST
|
||||
|
||||
self._manager = AutonomyManager(
|
||||
mode=autonomy_mode,
|
||||
approval_callback=approval_callback,
|
||||
verbose=self.verbose
|
||||
)
|
||||
|
||||
if self.verbose:
|
||||
from rich import print as rprint
|
||||
rprint(f"[cyan]Autonomy mode: {autonomy_mode.value}[/cyan]")
|
||||
|
||||
return self._manager
|
||||
|
||||
def get_manager(self) -> Optional[AutonomyManager]:
|
||||
"""Get the current autonomy manager."""
|
||||
return self._manager
|
||||
|
||||
def request_approval(self, action: ActionRequest) -> ApprovalResult:
|
||||
"""Request approval for an action."""
|
||||
if not self._manager:
|
||||
self._manager = self.initialize()
|
||||
|
||||
return self._manager.request_approval(action)
|
||||
|
||||
def set_mode(self, mode: str) -> None:
|
||||
"""Change the autonomy mode."""
|
||||
if not self._manager:
|
||||
self.initialize(mode)
|
||||
else:
|
||||
try:
|
||||
autonomy_mode = AutonomyMode.from_string(mode)
|
||||
self._manager.set_mode(autonomy_mode)
|
||||
except ValueError as e:
|
||||
logger.error(f"Failed to set mode: {e}")
|
||||
|
||||
def get_mode(self) -> str:
|
||||
"""Get the current autonomy mode."""
|
||||
if self._manager:
|
||||
return self._manager.mode.value
|
||||
return AutonomyMode.SUGGEST.value
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Convenience Functions
|
||||
# ============================================================================
|
||||
|
||||
def create_file_write_action(path: str, content_preview: str = "") -> ActionRequest:
|
||||
"""Create an action request for file write."""
|
||||
return ActionRequest(
|
||||
action_type=ActionType.FILE_WRITE,
|
||||
description=f"Write to file: {path}",
|
||||
details={
|
||||
"path": path,
|
||||
"preview": content_preview[:200] if content_preview else "(no preview)"
|
||||
},
|
||||
risk_level="medium",
|
||||
reversible=True
|
||||
)
|
||||
|
||||
|
||||
def create_shell_command_action(command: str, cwd: str = ".") -> ActionRequest:
|
||||
"""Create an action request for shell command."""
|
||||
# Assess risk based on command
|
||||
dangerous_patterns = ["rm ", "sudo ", "chmod ", "chown ", "> /", "| sh", "curl |"]
|
||||
risk = "high" if any(p in command for p in dangerous_patterns) else "medium"
|
||||
|
||||
return ActionRequest(
|
||||
action_type=ActionType.SHELL_COMMAND,
|
||||
description=f"Execute command: {command}",
|
||||
details={
|
||||
"command": command,
|
||||
"cwd": cwd
|
||||
},
|
||||
risk_level=risk,
|
||||
reversible=False
|
||||
)
|
||||
|
||||
|
||||
def create_git_action(operation: str, details: Dict[str, Any] = None) -> ActionRequest:
|
||||
"""Create an action request for git operation."""
|
||||
return ActionRequest(
|
||||
action_type=ActionType.GIT_OPERATION,
|
||||
description=f"Git {operation}",
|
||||
details=details or {},
|
||||
risk_level="low" if operation in ["status", "log", "diff"] else "medium",
|
||||
reversible=operation not in ["push", "force-push"]
|
||||
)
|
||||
|
||||
|
||||
def create_tool_call_action(tool_name: str, args: Dict[str, Any] = None) -> ActionRequest:
|
||||
"""Create an action request for tool call."""
|
||||
return ActionRequest(
|
||||
action_type=ActionType.TOOL_CALL,
|
||||
description=f"Call tool: {tool_name}",
|
||||
details={
|
||||
"tool": tool_name,
|
||||
"args": args or {}
|
||||
},
|
||||
risk_level="low",
|
||||
reversible=True
|
||||
)
|
||||
@@ -0,0 +1,514 @@
|
||||
"""
|
||||
Cost Tracking System for PraisonAI CLI.
|
||||
|
||||
Inspired by Aider's cost tracking and Gemini CLI's stats command.
|
||||
Provides real-time cost and token usage tracking.
|
||||
|
||||
Architecture:
|
||||
- CostTracker: Tracks tokens and costs per session
|
||||
- ModelPricing: Pricing data for different models
|
||||
- UsageStats: Aggregated usage statistics
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Model Pricing Data
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class ModelPricing:
|
||||
"""
|
||||
Pricing information for a model.
|
||||
|
||||
Prices are per 1M tokens (as commonly quoted).
|
||||
"""
|
||||
model_name: str
|
||||
input_price_per_1m: float # Price per 1M input tokens
|
||||
output_price_per_1m: float # Price per 1M output tokens
|
||||
context_window: int = 128000
|
||||
|
||||
def calculate_cost(self, input_tokens: int, output_tokens: int) -> float:
|
||||
"""Calculate cost for given token counts."""
|
||||
input_cost = (input_tokens / 1_000_000) * self.input_price_per_1m
|
||||
output_cost = (output_tokens / 1_000_000) * self.output_price_per_1m
|
||||
return input_cost + output_cost
|
||||
|
||||
|
||||
# Default pricing for common models (as of late 2024)
|
||||
DEFAULT_PRICING: Dict[str, ModelPricing] = {
|
||||
# OpenAI models
|
||||
"gpt-4o": ModelPricing("gpt-4o", 2.50, 10.00, 128000),
|
||||
"gpt-4o-mini": ModelPricing("gpt-4o-mini", 0.15, 0.60, 128000),
|
||||
"gpt-4-turbo": ModelPricing("gpt-4-turbo", 10.00, 30.00, 128000),
|
||||
"gpt-4": ModelPricing("gpt-4", 30.00, 60.00, 8192),
|
||||
"gpt-3.5-turbo": ModelPricing("gpt-3.5-turbo", 0.50, 1.50, 16385),
|
||||
"o1": ModelPricing("o1", 15.00, 60.00, 200000),
|
||||
"o1-mini": ModelPricing("o1-mini", 3.00, 12.00, 128000),
|
||||
"o1-preview": ModelPricing("o1-preview", 15.00, 60.00, 128000),
|
||||
"o3-mini": ModelPricing("o3-mini", 1.10, 4.40, 200000),
|
||||
|
||||
# Anthropic models
|
||||
"claude-3-5-sonnet-20241022": ModelPricing("claude-3-5-sonnet", 3.00, 15.00, 200000),
|
||||
"claude-3-5-sonnet": ModelPricing("claude-3-5-sonnet", 3.00, 15.00, 200000),
|
||||
"claude-3-opus": ModelPricing("claude-3-opus", 15.00, 75.00, 200000),
|
||||
"claude-3-sonnet": ModelPricing("claude-3-sonnet", 3.00, 15.00, 200000),
|
||||
"claude-3-haiku": ModelPricing("claude-3-haiku", 0.25, 1.25, 200000),
|
||||
|
||||
# Google models
|
||||
"gemini-2.0-flash": ModelPricing("gemini-2.0-flash", 0.10, 0.40, 1000000),
|
||||
"gemini-1.5-pro": ModelPricing("gemini-1.5-pro", 1.25, 5.00, 2000000),
|
||||
"gemini-1.5-flash": ModelPricing("gemini-1.5-flash", 0.075, 0.30, 1000000),
|
||||
|
||||
# Default fallback
|
||||
"default": ModelPricing("default", 1.00, 3.00, 128000),
|
||||
}
|
||||
|
||||
|
||||
def get_pricing(model_name: str) -> ModelPricing:
|
||||
"""Get pricing for a model, with fallback to default."""
|
||||
# Try exact match
|
||||
if model_name in DEFAULT_PRICING:
|
||||
return DEFAULT_PRICING[model_name]
|
||||
|
||||
# Try partial match
|
||||
model_lower = model_name.lower()
|
||||
for key, pricing in DEFAULT_PRICING.items():
|
||||
if key in model_lower or model_lower in key:
|
||||
return pricing
|
||||
|
||||
# Return default
|
||||
logger.debug(f"No pricing found for model '{model_name}', using default")
|
||||
return DEFAULT_PRICING["default"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Usage Statistics
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class TokenUsage:
|
||||
"""Token usage for a single request."""
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
cached_tokens: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
if self.total_tokens == 0:
|
||||
self.total_tokens = self.input_tokens + self.output_tokens
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestStats:
|
||||
"""Statistics for a single request."""
|
||||
timestamp: datetime
|
||||
model: str
|
||||
usage: TokenUsage
|
||||
cost: float
|
||||
duration_ms: float = 0.0
|
||||
prompt_preview: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"model": self.model,
|
||||
"input_tokens": self.usage.input_tokens,
|
||||
"output_tokens": self.usage.output_tokens,
|
||||
"total_tokens": self.usage.total_tokens,
|
||||
"cached_tokens": self.usage.cached_tokens,
|
||||
"cost": self.cost,
|
||||
"duration_ms": self.duration_ms,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionStats:
|
||||
"""Aggregated statistics for a session."""
|
||||
session_id: str
|
||||
start_time: datetime
|
||||
end_time: Optional[datetime] = None
|
||||
|
||||
total_requests: int = 0
|
||||
total_input_tokens: int = 0
|
||||
total_output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
total_cached_tokens: int = 0
|
||||
total_cost: float = 0.0
|
||||
total_duration_ms: float = 0.0
|
||||
|
||||
models_used: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def add_request(self, stats: RequestStats) -> None:
|
||||
"""Add request statistics to session totals."""
|
||||
self.total_requests += 1
|
||||
self.total_input_tokens += stats.usage.input_tokens
|
||||
self.total_output_tokens += stats.usage.output_tokens
|
||||
self.total_tokens += stats.usage.total_tokens
|
||||
self.total_cached_tokens += stats.usage.cached_tokens
|
||||
self.total_cost += stats.cost
|
||||
self.total_duration_ms += stats.duration_ms
|
||||
|
||||
# Track model usage
|
||||
if stats.model not in self.models_used:
|
||||
self.models_used[stats.model] = 0
|
||||
self.models_used[stats.model] += 1
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> float:
|
||||
"""Get session duration in seconds."""
|
||||
end = self.end_time or datetime.now()
|
||||
return (end - self.start_time).total_seconds()
|
||||
|
||||
@property
|
||||
def avg_tokens_per_request(self) -> float:
|
||||
"""Get average tokens per request."""
|
||||
if self.total_requests == 0:
|
||||
return 0.0
|
||||
return self.total_tokens / self.total_requests
|
||||
|
||||
@property
|
||||
def avg_cost_per_request(self) -> float:
|
||||
"""Get average cost per request."""
|
||||
if self.total_requests == 0:
|
||||
return 0.0
|
||||
return self.total_cost / self.total_requests
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"start_time": self.start_time.isoformat(),
|
||||
"end_time": self.end_time.isoformat() if self.end_time else None,
|
||||
"duration_seconds": self.duration_seconds,
|
||||
"total_requests": self.total_requests,
|
||||
"total_input_tokens": self.total_input_tokens,
|
||||
"total_output_tokens": self.total_output_tokens,
|
||||
"total_tokens": self.total_tokens,
|
||||
"total_cached_tokens": self.total_cached_tokens,
|
||||
"total_cost": self.total_cost,
|
||||
"avg_tokens_per_request": self.avg_tokens_per_request,
|
||||
"avg_cost_per_request": self.avg_cost_per_request,
|
||||
"models_used": self.models_used,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Cost Tracker
|
||||
# ============================================================================
|
||||
|
||||
class CostTracker:
|
||||
"""
|
||||
Tracks costs and token usage for a session.
|
||||
|
||||
Features:
|
||||
- Real-time cost tracking
|
||||
- Per-model statistics
|
||||
- Session history
|
||||
- Export to JSON
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: Optional[str] = None,
|
||||
pricing: Optional[Dict[str, ModelPricing]] = None,
|
||||
verbose: bool = False
|
||||
):
|
||||
self.session_id = session_id or self._generate_session_id()
|
||||
self.pricing = pricing or DEFAULT_PRICING
|
||||
self.verbose = verbose
|
||||
|
||||
self.session_stats = SessionStats(
|
||||
session_id=self.session_id,
|
||||
start_time=datetime.now()
|
||||
)
|
||||
self.request_history: List[RequestStats] = []
|
||||
|
||||
def _generate_session_id(self) -> str:
|
||||
"""Generate a unique session ID."""
|
||||
import uuid
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
def track_request(
|
||||
self,
|
||||
model: str,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
cached_tokens: int = 0,
|
||||
duration_ms: float = 0.0,
|
||||
prompt_preview: str = ""
|
||||
) -> RequestStats:
|
||||
"""
|
||||
Track a single request.
|
||||
|
||||
Returns:
|
||||
RequestStats for the tracked request
|
||||
"""
|
||||
usage = TokenUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cached_tokens=cached_tokens
|
||||
)
|
||||
|
||||
# Calculate cost
|
||||
pricing = get_pricing(model)
|
||||
cost = pricing.calculate_cost(input_tokens, output_tokens)
|
||||
|
||||
# Create request stats
|
||||
stats = RequestStats(
|
||||
timestamp=datetime.now(),
|
||||
model=model,
|
||||
usage=usage,
|
||||
cost=cost,
|
||||
duration_ms=duration_ms,
|
||||
prompt_preview=prompt_preview[:100] if prompt_preview else ""
|
||||
)
|
||||
|
||||
# Update session stats
|
||||
self.session_stats.add_request(stats)
|
||||
self.request_history.append(stats)
|
||||
|
||||
if self.verbose:
|
||||
logger.info(
|
||||
f"Request tracked: {model} - "
|
||||
f"{usage.total_tokens} tokens, ${cost:.4f}"
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
def track_from_response(
|
||||
self,
|
||||
model: str,
|
||||
response: Any,
|
||||
duration_ms: float = 0.0
|
||||
) -> Optional[RequestStats]:
|
||||
"""
|
||||
Track request from LLM response object.
|
||||
|
||||
Handles various response formats from different providers.
|
||||
"""
|
||||
try:
|
||||
# Try to extract usage from response
|
||||
usage = None
|
||||
|
||||
# OpenAI format
|
||||
if hasattr(response, 'usage'):
|
||||
usage = response.usage
|
||||
input_tokens = getattr(usage, 'prompt_tokens', 0)
|
||||
output_tokens = getattr(usage, 'completion_tokens', 0)
|
||||
cached_tokens = getattr(usage, 'cached_tokens', 0)
|
||||
# Dict format
|
||||
elif isinstance(response, dict) and 'usage' in response:
|
||||
usage = response['usage']
|
||||
input_tokens = usage.get('prompt_tokens', 0)
|
||||
output_tokens = usage.get('completion_tokens', 0)
|
||||
cached_tokens = usage.get('cached_tokens', 0)
|
||||
else:
|
||||
logger.debug("Could not extract usage from response")
|
||||
return None
|
||||
|
||||
return self.track_request(
|
||||
model=model,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cached_tokens=cached_tokens,
|
||||
duration_ms=duration_ms
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error tracking response: {e}")
|
||||
return None
|
||||
|
||||
def get_current_stats(self) -> SessionStats:
|
||||
"""Get current session statistics."""
|
||||
return self.session_stats
|
||||
|
||||
def get_total_cost(self) -> float:
|
||||
"""Get total cost for the session."""
|
||||
return self.session_stats.total_cost
|
||||
|
||||
def get_total_tokens(self) -> int:
|
||||
"""Get total tokens for the session."""
|
||||
return self.session_stats.total_tokens
|
||||
|
||||
def get_request_count(self) -> int:
|
||||
"""Get total request count."""
|
||||
return self.session_stats.total_requests
|
||||
|
||||
def format_summary(self) -> str:
|
||||
"""Format a summary of the session statistics."""
|
||||
stats = self.session_stats
|
||||
|
||||
lines = [
|
||||
f"Session: {stats.session_id}",
|
||||
f"Duration: {stats.duration_seconds:.1f}s",
|
||||
f"Requests: {stats.total_requests}",
|
||||
"",
|
||||
"Tokens:",
|
||||
f" Input: {stats.total_input_tokens:,}",
|
||||
f" Output: {stats.total_output_tokens:,}",
|
||||
f" Total: {stats.total_tokens:,}",
|
||||
f" Cached: {stats.total_cached_tokens:,}",
|
||||
"",
|
||||
f"Cost: ${stats.total_cost:.4f}",
|
||||
f"Avg per request: ${stats.avg_cost_per_request:.4f}",
|
||||
]
|
||||
|
||||
if stats.models_used:
|
||||
lines.append("")
|
||||
lines.append("Models used:")
|
||||
for model, count in stats.models_used.items():
|
||||
lines.append(f" {model}: {count} requests")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def print_summary(self) -> None:
|
||||
"""Print a formatted summary to console."""
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
stats = self.session_stats
|
||||
|
||||
# Create summary table
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Metric")
|
||||
table.add_column("Value", justify="right")
|
||||
|
||||
table.add_row("Session ID", stats.session_id)
|
||||
table.add_row("Duration", f"{stats.duration_seconds:.1f}s")
|
||||
table.add_row("Requests", str(stats.total_requests))
|
||||
table.add_row("", "")
|
||||
table.add_row("Input Tokens", f"{stats.total_input_tokens:,}")
|
||||
table.add_row("Output Tokens", f"{stats.total_output_tokens:,}")
|
||||
table.add_row("Total Tokens", f"{stats.total_tokens:,}")
|
||||
table.add_row("Cached Tokens", f"{stats.total_cached_tokens:,}")
|
||||
table.add_row("", "")
|
||||
table.add_row("Total Cost", f"${stats.total_cost:.4f}")
|
||||
table.add_row("Avg Cost/Request", f"${stats.avg_cost_per_request:.4f}")
|
||||
|
||||
console.print(Panel(table, title="💰 Session Statistics", border_style="green"))
|
||||
|
||||
# Print model breakdown if multiple models used
|
||||
if len(stats.models_used) > 1:
|
||||
model_table = Table(show_header=True, header_style="bold cyan")
|
||||
model_table.add_column("Model")
|
||||
model_table.add_column("Requests", justify="right")
|
||||
|
||||
for model, count in stats.models_used.items():
|
||||
model_table.add_row(model, str(count))
|
||||
|
||||
console.print(Panel(model_table, title="Models Used", border_style="blue"))
|
||||
|
||||
def export_json(self, filepath: Optional[str] = None) -> str:
|
||||
"""
|
||||
Export session data to JSON.
|
||||
|
||||
Args:
|
||||
filepath: Optional file path to write to
|
||||
|
||||
Returns:
|
||||
JSON string
|
||||
"""
|
||||
data = {
|
||||
"session": self.session_stats.to_dict(),
|
||||
"requests": [r.to_dict() for r in self.request_history]
|
||||
}
|
||||
|
||||
json_str = json.dumps(data, indent=2)
|
||||
|
||||
if filepath:
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(json_str)
|
||||
logger.info(f"Exported session data to {filepath}")
|
||||
|
||||
return json_str
|
||||
|
||||
def end_session(self) -> SessionStats:
|
||||
"""Mark the session as ended."""
|
||||
self.session_stats.end_time = datetime.now()
|
||||
return self.session_stats
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CLI Integration Handler
|
||||
# ============================================================================
|
||||
|
||||
class CostTrackerHandler:
|
||||
"""
|
||||
Handler for integrating cost tracking with PraisonAI CLI.
|
||||
"""
|
||||
|
||||
def __init__(self, verbose: bool = False):
|
||||
self.verbose = verbose
|
||||
self._tracker: Optional[CostTracker] = None
|
||||
|
||||
@property
|
||||
def feature_name(self) -> str:
|
||||
return "cost_tracker"
|
||||
|
||||
def initialize(self, session_id: Optional[str] = None) -> CostTracker:
|
||||
"""Initialize the cost tracker."""
|
||||
self._tracker = CostTracker(
|
||||
session_id=session_id,
|
||||
verbose=self.verbose
|
||||
)
|
||||
|
||||
if self.verbose:
|
||||
from rich import print as rprint
|
||||
rprint(f"[cyan]Cost tracking enabled for session: {self._tracker.session_id}[/cyan]")
|
||||
|
||||
return self._tracker
|
||||
|
||||
def get_tracker(self) -> Optional[CostTracker]:
|
||||
"""Get the current cost tracker."""
|
||||
return self._tracker
|
||||
|
||||
def track_request(
|
||||
self,
|
||||
model: str,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
**kwargs
|
||||
) -> Optional[RequestStats]:
|
||||
"""Track a request."""
|
||||
if not self._tracker:
|
||||
self._tracker = self.initialize()
|
||||
|
||||
return self._tracker.track_request(
|
||||
model=model,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""Get session summary as dict."""
|
||||
if not self._tracker:
|
||||
return {}
|
||||
return self._tracker.session_stats.to_dict()
|
||||
|
||||
def print_summary(self) -> None:
|
||||
"""Print session summary."""
|
||||
if self._tracker:
|
||||
self._tracker.print_summary()
|
||||
|
||||
def get_cost(self) -> float:
|
||||
"""Get total cost."""
|
||||
if self._tracker:
|
||||
return self._tracker.get_total_cost()
|
||||
return 0.0
|
||||
|
||||
def get_tokens(self) -> int:
|
||||
"""Get total tokens."""
|
||||
if self._tracker:
|
||||
return self._tracker.get_total_tokens()
|
||||
return 0
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
External Agents Handler for CLI.
|
||||
|
||||
Provides integration with external AI coding CLI tools:
|
||||
- Claude Code CLI
|
||||
- Gemini CLI
|
||||
- OpenAI Codex CLI
|
||||
- Cursor CLI
|
||||
|
||||
Usage:
|
||||
praisonai "Refactor the code" --external-agent claude
|
||||
praisonai "Analyze codebase" --external-agent gemini
|
||||
praisonai "Fix bugs" --external-agent codex
|
||||
praisonai "Add tests" --external-agent cursor
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
|
||||
from .base import FlagHandler
|
||||
|
||||
|
||||
class ExternalAgentsHandler(FlagHandler):
|
||||
"""
|
||||
Handler for --external-agent flag.
|
||||
|
||||
Integrates external AI coding CLI tools as agent tools.
|
||||
|
||||
Example:
|
||||
praisonai "Refactor the auth module" --external-agent claude
|
||||
praisonai "Analyze this codebase" --external-agent gemini
|
||||
"""
|
||||
|
||||
# Mapping of integration names to their module paths
|
||||
INTEGRATIONS = {
|
||||
"claude": "claude_code.ClaudeCodeIntegration",
|
||||
"gemini": "gemini_cli.GeminiCLIIntegration",
|
||||
"codex": "codex_cli.CodexCLIIntegration",
|
||||
"cursor": "cursor_cli.CursorCLIIntegration",
|
||||
}
|
||||
|
||||
def __init__(self, verbose: bool = False):
|
||||
"""Initialize the handler."""
|
||||
super().__init__(verbose)
|
||||
self._integration_cache: Dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def feature_name(self) -> str:
|
||||
"""Return the feature name."""
|
||||
return "external_agents"
|
||||
|
||||
@property
|
||||
def flag_name(self) -> str:
|
||||
"""Return the flag name."""
|
||||
return "external-agent"
|
||||
|
||||
@property
|
||||
def flag_help(self) -> str:
|
||||
"""Return the flag help text."""
|
||||
return "External AI CLI tool to use (claude, gemini, codex, cursor)"
|
||||
|
||||
def check_dependencies(self) -> Tuple[bool, str]:
|
||||
"""Check if integrations module is available."""
|
||||
try:
|
||||
import importlib.util
|
||||
if importlib.util.find_spec("praisonai.integrations") is not None:
|
||||
return True, ""
|
||||
return False, "Integrations module not available"
|
||||
except ImportError:
|
||||
return False, "Integrations module not available"
|
||||
|
||||
def get_integration(self, name: str, **options):
|
||||
"""
|
||||
Get an integration instance by name.
|
||||
|
||||
Args:
|
||||
name: Integration name (claude, gemini, codex, cursor)
|
||||
**options: Options to pass to the integration
|
||||
|
||||
Returns:
|
||||
Integration instance
|
||||
|
||||
Raises:
|
||||
ValueError: If integration name is invalid
|
||||
"""
|
||||
if name not in self.INTEGRATIONS:
|
||||
raise ValueError(f"Unknown integration: {name}. Available: {list(self.INTEGRATIONS.keys())}")
|
||||
|
||||
# Check cache first
|
||||
cache_key = f"{name}_{hash(frozenset(options.items()))}"
|
||||
if cache_key in self._integration_cache:
|
||||
return self._integration_cache[cache_key]
|
||||
|
||||
# Lazy import the integration
|
||||
module_path = self.INTEGRATIONS[name]
|
||||
module_name, class_name = module_path.rsplit(".", 1)
|
||||
|
||||
import importlib
|
||||
module = importlib.import_module(f"praisonai.integrations.{module_name}")
|
||||
integration_class = getattr(module, class_name)
|
||||
|
||||
# Create instance with options
|
||||
integration = integration_class(**options)
|
||||
|
||||
# Cache it
|
||||
self._integration_cache[cache_key] = integration
|
||||
|
||||
return integration
|
||||
|
||||
def list_integrations(self) -> List[str]:
|
||||
"""
|
||||
List all available integration names.
|
||||
|
||||
Returns:
|
||||
List of integration names
|
||||
"""
|
||||
return list(self.INTEGRATIONS.keys())
|
||||
|
||||
def check_availability(self) -> Dict[str, bool]:
|
||||
"""
|
||||
Check availability of all integrations.
|
||||
|
||||
Returns:
|
||||
Dict mapping integration name to availability
|
||||
"""
|
||||
availability = {}
|
||||
for name in self.INTEGRATIONS:
|
||||
try:
|
||||
integration = self.get_integration(name)
|
||||
availability[name] = integration.is_available
|
||||
except Exception:
|
||||
availability[name] = False
|
||||
return availability
|
||||
|
||||
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Apply external agent configuration to agent config.
|
||||
|
||||
Args:
|
||||
config: Agent configuration dictionary
|
||||
flag_value: Integration name or dict with name and options
|
||||
|
||||
Returns:
|
||||
Modified configuration with external agent tool
|
||||
"""
|
||||
if not flag_value:
|
||||
return config
|
||||
|
||||
# Handle both string and dict formats
|
||||
if isinstance(flag_value, str):
|
||||
name = flag_value
|
||||
options = {}
|
||||
elif isinstance(flag_value, dict):
|
||||
name = flag_value.get("name", "")
|
||||
options = {k: v for k, v in flag_value.items() if k != "name"}
|
||||
else:
|
||||
return config
|
||||
|
||||
try:
|
||||
integration = self.get_integration(name, **options)
|
||||
|
||||
if integration.is_available:
|
||||
# Add tool to existing tools
|
||||
existing_tools = config.get("tools", [])
|
||||
if isinstance(existing_tools, list):
|
||||
existing_tools.append(integration.as_tool())
|
||||
else:
|
||||
existing_tools = [integration.as_tool()]
|
||||
config["tools"] = existing_tools
|
||||
|
||||
self.print_status(f"🔌 External agent connected: {name}", "success")
|
||||
else:
|
||||
self.print_status(f"⚠️ External agent not available: {name}", "warning")
|
||||
|
||||
except ValueError as e:
|
||||
self.print_status(str(e), "error")
|
||||
except Exception as e:
|
||||
self.print_status(f"Failed to connect external agent: {e}", "error")
|
||||
|
||||
return config
|
||||
|
||||
def execute(
|
||||
self,
|
||||
integration_name: str = None,
|
||||
**options
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
Execute external agent setup.
|
||||
|
||||
Args:
|
||||
integration_name: Name of the integration
|
||||
**options: Options for the integration
|
||||
|
||||
Returns:
|
||||
Integration instance or None
|
||||
"""
|
||||
if not integration_name:
|
||||
# List available integrations
|
||||
self.print_status("\n🔧 Available External Agents:", "info")
|
||||
self.print_status("-" * 40, "info")
|
||||
|
||||
availability = self.check_availability()
|
||||
for name, available in availability.items():
|
||||
status = "✅ Available" if available else "❌ Not installed"
|
||||
self.print_status(f" {name}: {status}", "info")
|
||||
|
||||
return None
|
||||
|
||||
try:
|
||||
integration = self.get_integration(integration_name, **options)
|
||||
|
||||
if integration.is_available:
|
||||
self.print_status(f"🔌 External agent ready: {integration_name}", "success")
|
||||
return integration
|
||||
else:
|
||||
self.print_status(f"⚠️ External agent not installed: {integration_name}", "warning")
|
||||
self.print_status(f" Install with: {self._get_install_instructions(integration_name)}", "info")
|
||||
return None
|
||||
|
||||
except ValueError as e:
|
||||
self.print_status(str(e), "error")
|
||||
return None
|
||||
|
||||
def _get_install_instructions(self, name: str) -> str:
|
||||
"""Get installation instructions for an integration."""
|
||||
instructions = {
|
||||
"claude": "npm install -g @anthropic-ai/claude-code",
|
||||
"gemini": "npm install -g @anthropic-ai/gemini-cli",
|
||||
"codex": "npm install -g @openai/codex",
|
||||
"cursor": "Download from cursor.com",
|
||||
}
|
||||
return instructions.get(name, "See documentation")
|
||||
@@ -0,0 +1,651 @@
|
||||
"""
|
||||
Git Integration System for PraisonAI CLI.
|
||||
|
||||
Inspired by Aider's Git integration for auto-commits and change tracking.
|
||||
Provides seamless Git operations with AI-generated commit messages.
|
||||
|
||||
Architecture:
|
||||
- GitManager: Core Git operations using subprocess
|
||||
- CommitGenerator: AI-powered commit message generation
|
||||
- DiffViewer: Rich diff display
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Data Classes
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class GitStatus:
|
||||
"""Git repository status."""
|
||||
branch: str = ""
|
||||
is_clean: bool = True
|
||||
staged_files: List[str] = field(default_factory=list)
|
||||
modified_files: List[str] = field(default_factory=list)
|
||||
untracked_files: List[str] = field(default_factory=list)
|
||||
ahead: int = 0
|
||||
behind: int = 0
|
||||
|
||||
@property
|
||||
def has_changes(self) -> bool:
|
||||
"""Check if there are any changes."""
|
||||
return bool(self.staged_files or self.modified_files or self.untracked_files)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitCommit:
|
||||
"""Git commit information."""
|
||||
hash: str
|
||||
short_hash: str
|
||||
message: str
|
||||
author: str
|
||||
date: str
|
||||
files_changed: int = 0
|
||||
insertions: int = 0
|
||||
deletions: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitDiff:
|
||||
"""Git diff information."""
|
||||
file_path: str
|
||||
additions: int = 0
|
||||
deletions: int = 0
|
||||
content: str = ""
|
||||
is_new: bool = False
|
||||
is_deleted: bool = False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Git Manager
|
||||
# ============================================================================
|
||||
|
||||
class GitManager:
|
||||
"""
|
||||
Manages Git operations using subprocess.
|
||||
|
||||
Uses subprocess instead of GitPython for lighter dependencies.
|
||||
"""
|
||||
|
||||
def __init__(self, repo_path: Optional[str] = None, verbose: bool = False):
|
||||
self.repo_path = Path(repo_path) if repo_path else Path.cwd()
|
||||
self.verbose = verbose
|
||||
self._git_available = self._check_git()
|
||||
|
||||
def _check_git(self) -> bool:
|
||||
"""Check if git is available."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=self.repo_path
|
||||
)
|
||||
return result.returncode == 0
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
def _run_git(self, *args: str, check: bool = True) -> subprocess.CompletedProcess:
|
||||
"""Run a git command."""
|
||||
cmd = ["git"] + list(args)
|
||||
if self.verbose:
|
||||
logger.debug(f"Running: {' '.join(cmd)}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=self.repo_path
|
||||
)
|
||||
|
||||
if check and result.returncode != 0:
|
||||
logger.debug(f"Git error: {result.stderr}")
|
||||
|
||||
return result
|
||||
|
||||
@property
|
||||
def is_repo(self) -> bool:
|
||||
"""Check if current directory is a git repository."""
|
||||
if not self._git_available:
|
||||
return False
|
||||
result = self._run_git("rev-parse", "--git-dir", check=False)
|
||||
return result.returncode == 0
|
||||
|
||||
def get_status(self) -> GitStatus:
|
||||
"""Get repository status."""
|
||||
status = GitStatus()
|
||||
|
||||
if not self.is_repo:
|
||||
return status
|
||||
|
||||
# Get branch
|
||||
result = self._run_git("branch", "--show-current", check=False)
|
||||
if result.returncode == 0:
|
||||
status.branch = result.stdout.strip()
|
||||
|
||||
# Get status
|
||||
result = self._run_git("status", "--porcelain", check=False)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
|
||||
status_code = line[:2]
|
||||
file_path = line[3:]
|
||||
|
||||
if status_code[0] in "MADRCU":
|
||||
status.staged_files.append(file_path)
|
||||
if status_code[1] in "MD":
|
||||
status.modified_files.append(file_path)
|
||||
if status_code == "??":
|
||||
status.untracked_files.append(file_path)
|
||||
|
||||
status.is_clean = not status.has_changes
|
||||
|
||||
# Get ahead/behind
|
||||
result = self._run_git("rev-list", "--left-right", "--count", "HEAD...@{u}", check=False)
|
||||
if result.returncode == 0:
|
||||
parts = result.stdout.strip().split()
|
||||
if len(parts) == 2:
|
||||
status.ahead = int(parts[0])
|
||||
status.behind = int(parts[1])
|
||||
|
||||
return status
|
||||
|
||||
def get_diff(self, staged: bool = False, file_path: Optional[str] = None) -> List[GitDiff]:
|
||||
"""Get diff information."""
|
||||
diffs = []
|
||||
|
||||
if not self.is_repo:
|
||||
return diffs
|
||||
|
||||
args = ["diff"]
|
||||
if staged:
|
||||
args.append("--staged")
|
||||
args.append("--numstat")
|
||||
|
||||
if file_path:
|
||||
args.append("--")
|
||||
args.append(file_path)
|
||||
|
||||
result = self._run_git(*args, check=False)
|
||||
if result.returncode != 0:
|
||||
return diffs
|
||||
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 3:
|
||||
additions = int(parts[0]) if parts[0] != "-" else 0
|
||||
deletions = int(parts[1]) if parts[1] != "-" else 0
|
||||
path = parts[2]
|
||||
|
||||
diffs.append(GitDiff(
|
||||
file_path=path,
|
||||
additions=additions,
|
||||
deletions=deletions
|
||||
))
|
||||
|
||||
return diffs
|
||||
|
||||
def get_diff_content(self, staged: bool = False, file_path: Optional[str] = None) -> str:
|
||||
"""Get full diff content."""
|
||||
if not self.is_repo:
|
||||
return ""
|
||||
|
||||
args = ["diff"]
|
||||
if staged:
|
||||
args.append("--staged")
|
||||
|
||||
if file_path:
|
||||
args.append("--")
|
||||
args.append(file_path)
|
||||
|
||||
result = self._run_git(*args, check=False)
|
||||
return result.stdout if result.returncode == 0 else ""
|
||||
|
||||
def stage_files(self, files: Optional[List[str]] = None) -> bool:
|
||||
"""Stage files for commit."""
|
||||
if not self.is_repo:
|
||||
return False
|
||||
|
||||
if files:
|
||||
result = self._run_git("add", *files, check=False)
|
||||
else:
|
||||
result = self._run_git("add", "-A", check=False)
|
||||
|
||||
return result.returncode == 0
|
||||
|
||||
def commit(self, message: str, allow_empty: bool = False) -> Optional[GitCommit]:
|
||||
"""Create a commit."""
|
||||
if not self.is_repo:
|
||||
return None
|
||||
|
||||
args = ["commit", "-m", message]
|
||||
if allow_empty:
|
||||
args.append("--allow-empty")
|
||||
|
||||
result = self._run_git(*args, check=False)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.debug(f"Commit failed: {result.stderr}")
|
||||
return None
|
||||
|
||||
# Get commit info
|
||||
return self.get_last_commit()
|
||||
|
||||
def get_last_commit(self) -> Optional[GitCommit]:
|
||||
"""Get the last commit."""
|
||||
if not self.is_repo:
|
||||
return None
|
||||
|
||||
result = self._run_git(
|
||||
"log", "-1",
|
||||
"--format=%H|%h|%s|%an|%ai",
|
||||
check=False
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
parts = result.stdout.strip().split("|")
|
||||
if len(parts) >= 5:
|
||||
return GitCommit(
|
||||
hash=parts[0],
|
||||
short_hash=parts[1],
|
||||
message=parts[2],
|
||||
author=parts[3],
|
||||
date=parts[4]
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def get_log(self, count: int = 10) -> List[GitCommit]:
|
||||
"""Get commit log."""
|
||||
commits = []
|
||||
|
||||
if not self.is_repo:
|
||||
return commits
|
||||
|
||||
result = self._run_git(
|
||||
"log", f"-{count}",
|
||||
"--format=%H|%h|%s|%an|%ai",
|
||||
check=False
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return commits
|
||||
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parts = line.split("|")
|
||||
if len(parts) >= 5:
|
||||
commits.append(GitCommit(
|
||||
hash=parts[0],
|
||||
short_hash=parts[1],
|
||||
message=parts[2],
|
||||
author=parts[3],
|
||||
date=parts[4]
|
||||
))
|
||||
|
||||
return commits
|
||||
|
||||
def undo_last_commit(self, soft: bool = True) -> bool:
|
||||
"""Undo the last commit."""
|
||||
if not self.is_repo:
|
||||
return False
|
||||
|
||||
reset_type = "--soft" if soft else "--hard"
|
||||
result = self._run_git("reset", reset_type, "HEAD~1", check=False)
|
||||
|
||||
return result.returncode == 0
|
||||
|
||||
def create_branch(self, name: str, checkout: bool = True) -> bool:
|
||||
"""Create a new branch."""
|
||||
if not self.is_repo:
|
||||
return False
|
||||
|
||||
if checkout:
|
||||
result = self._run_git("checkout", "-b", name, check=False)
|
||||
else:
|
||||
result = self._run_git("branch", name, check=False)
|
||||
|
||||
return result.returncode == 0
|
||||
|
||||
def checkout_branch(self, name: str) -> bool:
|
||||
"""Checkout a branch."""
|
||||
if not self.is_repo:
|
||||
return False
|
||||
|
||||
result = self._run_git("checkout", name, check=False)
|
||||
return result.returncode == 0
|
||||
|
||||
def get_branches(self) -> List[str]:
|
||||
"""Get list of branches."""
|
||||
if not self.is_repo:
|
||||
return []
|
||||
|
||||
result = self._run_git("branch", "--list", check=False)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
|
||||
branches = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line:
|
||||
# Remove * and whitespace
|
||||
branch = line.strip().lstrip("* ")
|
||||
branches.append(branch)
|
||||
|
||||
return branches
|
||||
|
||||
def stash(self, message: Optional[str] = None) -> bool:
|
||||
"""Stash changes."""
|
||||
if not self.is_repo:
|
||||
return False
|
||||
|
||||
args = ["stash", "push"]
|
||||
if message:
|
||||
args.extend(["-m", message])
|
||||
|
||||
result = self._run_git(*args, check=False)
|
||||
return result.returncode == 0
|
||||
|
||||
def stash_pop(self) -> bool:
|
||||
"""Pop stashed changes."""
|
||||
if not self.is_repo:
|
||||
return False
|
||||
|
||||
result = self._run_git("stash", "pop", check=False)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Commit Message Generator
|
||||
# ============================================================================
|
||||
|
||||
class CommitMessageGenerator:
|
||||
"""
|
||||
Generates commit messages using AI or templates.
|
||||
"""
|
||||
|
||||
def __init__(self, use_ai: bool = True):
|
||||
self.use_ai = use_ai
|
||||
|
||||
def generate(
|
||||
self,
|
||||
diff_content: str,
|
||||
context: Optional[str] = None,
|
||||
style: str = "conventional"
|
||||
) -> str:
|
||||
"""
|
||||
Generate a commit message.
|
||||
|
||||
Args:
|
||||
diff_content: The diff to describe
|
||||
context: Optional context about the changes
|
||||
style: Message style (conventional, simple, detailed)
|
||||
|
||||
Returns:
|
||||
Generated commit message
|
||||
"""
|
||||
if not diff_content:
|
||||
return "Empty commit"
|
||||
|
||||
if self.use_ai:
|
||||
return self._generate_with_ai(diff_content, context, style)
|
||||
else:
|
||||
return self._generate_from_diff(diff_content, style)
|
||||
|
||||
def _generate_with_ai(
|
||||
self,
|
||||
diff_content: str,
|
||||
context: Optional[str],
|
||||
style: str
|
||||
) -> str:
|
||||
"""Generate commit message using AI."""
|
||||
# This would integrate with the agent system
|
||||
# For now, return a template-based message
|
||||
return self._generate_from_diff(diff_content, style)
|
||||
|
||||
def _generate_from_diff(self, diff_content: str, style: str) -> str:
|
||||
"""Generate commit message from diff analysis."""
|
||||
# Analyze diff
|
||||
files_changed = set()
|
||||
additions = 0
|
||||
deletions = 0
|
||||
|
||||
for line in diff_content.split("\n"):
|
||||
if line.startswith("diff --git"):
|
||||
match = re.search(r"b/(.+)$", line)
|
||||
if match:
|
||||
files_changed.add(match.group(1))
|
||||
elif line.startswith("+") and not line.startswith("+++"):
|
||||
additions += 1
|
||||
elif line.startswith("-") and not line.startswith("---"):
|
||||
deletions += 1
|
||||
|
||||
# Determine change type
|
||||
if additions > deletions * 2:
|
||||
change_type = "feat"
|
||||
action = "Add"
|
||||
elif deletions > additions * 2:
|
||||
change_type = "refactor"
|
||||
action = "Remove"
|
||||
else:
|
||||
change_type = "fix"
|
||||
action = "Update"
|
||||
|
||||
# Build message
|
||||
if len(files_changed) == 1:
|
||||
file_name = list(files_changed)[0]
|
||||
scope = Path(file_name).stem
|
||||
message = f"{change_type}({scope}): {action} {file_name}"
|
||||
elif len(files_changed) <= 3:
|
||||
files_str = ", ".join(sorted(files_changed))
|
||||
message = f"{change_type}: {action} {files_str}"
|
||||
else:
|
||||
message = f"{change_type}: {action} {len(files_changed)} files"
|
||||
|
||||
if style == "detailed":
|
||||
message += f"\n\n+{additions} -{deletions} lines"
|
||||
|
||||
return message
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Diff Viewer
|
||||
# ============================================================================
|
||||
|
||||
class DiffViewer:
|
||||
"""
|
||||
Rich-based diff viewer.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._console = None
|
||||
|
||||
@property
|
||||
def console(self):
|
||||
"""Lazy load Rich console."""
|
||||
if self._console is None:
|
||||
try:
|
||||
from rich.console import Console
|
||||
self._console = Console()
|
||||
except ImportError:
|
||||
self._console = None
|
||||
return self._console
|
||||
|
||||
def display_diff(self, diff_content: str, title: str = "Changes") -> None:
|
||||
"""Display diff with syntax highlighting."""
|
||||
if not self.console:
|
||||
print(diff_content)
|
||||
return
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.syntax import Syntax
|
||||
|
||||
syntax = Syntax(diff_content, "diff", theme="monokai", line_numbers=True)
|
||||
self.console.print(Panel(syntax, title=title, border_style="blue"))
|
||||
|
||||
def display_status(self, status: GitStatus) -> None:
|
||||
"""Display git status."""
|
||||
if not self.console:
|
||||
print(f"Branch: {status.branch}")
|
||||
print(f"Staged: {len(status.staged_files)}")
|
||||
print(f"Modified: {len(status.modified_files)}")
|
||||
print(f"Untracked: {len(status.untracked_files)}")
|
||||
return
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Category")
|
||||
table.add_column("Files", justify="right")
|
||||
|
||||
table.add_row("Branch", status.branch or "detached")
|
||||
table.add_row("Staged", str(len(status.staged_files)))
|
||||
table.add_row("Modified", str(len(status.modified_files)))
|
||||
table.add_row("Untracked", str(len(status.untracked_files)))
|
||||
|
||||
if status.ahead or status.behind:
|
||||
table.add_row("Ahead/Behind", f"+{status.ahead} / -{status.behind}")
|
||||
|
||||
self.console.print(Panel(table, title="📊 Git Status", border_style="green"))
|
||||
|
||||
def display_log(self, commits: List[GitCommit]) -> None:
|
||||
"""Display commit log."""
|
||||
if not self.console:
|
||||
for commit in commits:
|
||||
print(f"{commit.short_hash} {commit.message}")
|
||||
return
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Hash")
|
||||
table.add_column("Message")
|
||||
table.add_column("Author")
|
||||
table.add_column("Date")
|
||||
|
||||
for commit in commits:
|
||||
table.add_row(
|
||||
commit.short_hash,
|
||||
commit.message[:50] + "..." if len(commit.message) > 50 else commit.message,
|
||||
commit.author,
|
||||
commit.date[:10]
|
||||
)
|
||||
|
||||
self.console.print(table)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CLI Integration Handler
|
||||
# ============================================================================
|
||||
|
||||
class GitIntegrationHandler:
|
||||
"""
|
||||
Handler for integrating Git with PraisonAI CLI.
|
||||
"""
|
||||
|
||||
def __init__(self, verbose: bool = False):
|
||||
self.verbose = verbose
|
||||
self._git: Optional[GitManager] = None
|
||||
self._viewer: Optional[DiffViewer] = None
|
||||
self._generator: Optional[CommitMessageGenerator] = None
|
||||
|
||||
@property
|
||||
def feature_name(self) -> str:
|
||||
return "git_integration"
|
||||
|
||||
def initialize(self, repo_path: Optional[str] = None) -> GitManager:
|
||||
"""Initialize Git manager."""
|
||||
self._git = GitManager(repo_path=repo_path, verbose=self.verbose)
|
||||
self._viewer = DiffViewer()
|
||||
self._generator = CommitMessageGenerator()
|
||||
|
||||
if self.verbose and self._git.is_repo:
|
||||
from rich import print as rprint
|
||||
status = self._git.get_status()
|
||||
rprint(f"[cyan]Git initialized on branch: {status.branch}[/cyan]")
|
||||
|
||||
return self._git
|
||||
|
||||
def get_git(self) -> Optional[GitManager]:
|
||||
"""Get the Git manager."""
|
||||
return self._git
|
||||
|
||||
def show_status(self) -> GitStatus:
|
||||
"""Show git status."""
|
||||
if not self._git:
|
||||
self._git = self.initialize()
|
||||
|
||||
status = self._git.get_status()
|
||||
if self._viewer:
|
||||
self._viewer.display_status(status)
|
||||
|
||||
return status
|
||||
|
||||
def show_diff(self, staged: bool = False) -> str:
|
||||
"""Show diff."""
|
||||
if not self._git:
|
||||
self._git = self.initialize()
|
||||
|
||||
diff_content = self._git.get_diff_content(staged=staged)
|
||||
if self._viewer and diff_content:
|
||||
self._viewer.display_diff(diff_content)
|
||||
|
||||
return diff_content
|
||||
|
||||
def commit(
|
||||
self,
|
||||
message: Optional[str] = None,
|
||||
auto_stage: bool = True
|
||||
) -> Optional[GitCommit]:
|
||||
"""Create a commit."""
|
||||
if not self._git:
|
||||
self._git = self.initialize()
|
||||
|
||||
if auto_stage:
|
||||
self._git.stage_files()
|
||||
|
||||
if not message:
|
||||
# Generate message from diff
|
||||
diff_content = self._git.get_diff_content(staged=True)
|
||||
if self._generator:
|
||||
message = self._generator.generate(diff_content)
|
||||
else:
|
||||
message = "Update files"
|
||||
|
||||
return self._git.commit(message)
|
||||
|
||||
def undo(self, soft: bool = True) -> bool:
|
||||
"""Undo last commit."""
|
||||
if not self._git:
|
||||
self._git = self.initialize()
|
||||
|
||||
return self._git.undo_last_commit(soft=soft)
|
||||
|
||||
def show_log(self, count: int = 10) -> List[GitCommit]:
|
||||
"""Show commit log."""
|
||||
if not self._git:
|
||||
self._git = self.initialize()
|
||||
|
||||
commits = self._git.get_log(count=count)
|
||||
if self._viewer:
|
||||
self._viewer.display_log(commits)
|
||||
|
||||
return commits
|
||||
@@ -0,0 +1,603 @@
|
||||
"""
|
||||
Interactive TUI System for PraisonAI CLI.
|
||||
|
||||
Inspired by Aider's interactive mode and prompt_toolkit usage.
|
||||
Provides rich interactive terminal experience with completions and history.
|
||||
|
||||
Architecture:
|
||||
- InteractiveSession: Main interactive session manager
|
||||
- CommandCompleter: Auto-completion for commands and files
|
||||
- HistoryManager: Persistent command history
|
||||
- StatusDisplay: Rich-based status bar and output
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class InteractiveConfig:
|
||||
"""Configuration for interactive session."""
|
||||
prompt: str = ">>> "
|
||||
multiline: bool = True
|
||||
history_file: Optional[str] = None
|
||||
max_history: int = 1000
|
||||
enable_completions: bool = True
|
||||
enable_syntax_highlighting: bool = True
|
||||
vi_mode: bool = False
|
||||
auto_suggest: bool = True
|
||||
show_status_bar: bool = True
|
||||
color_scheme: str = "monokai"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Command Completer
|
||||
# ============================================================================
|
||||
|
||||
class CommandCompleter:
|
||||
"""
|
||||
Provides auto-completion for commands, files, and symbols.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
commands: Optional[List[str]] = None,
|
||||
file_patterns: Optional[List[str]] = None
|
||||
):
|
||||
self.commands = commands or []
|
||||
self.file_patterns = file_patterns or ["*.py", "*.js", "*.ts"]
|
||||
self._file_cache: List[str] = []
|
||||
self._symbol_cache: List[str] = []
|
||||
|
||||
def add_commands(self, commands: List[str]) -> None:
|
||||
"""Add commands to completion list."""
|
||||
self.commands.extend(commands)
|
||||
|
||||
def add_symbols(self, symbols: List[str]) -> None:
|
||||
"""Add symbols to completion list."""
|
||||
self._symbol_cache.extend(symbols)
|
||||
|
||||
def refresh_files(self, root: Optional[Path] = None) -> None:
|
||||
"""Refresh file cache."""
|
||||
root = root or Path.cwd()
|
||||
self._file_cache = []
|
||||
|
||||
for pattern in self.file_patterns:
|
||||
for file_path in root.rglob(pattern):
|
||||
try:
|
||||
rel_path = str(file_path.relative_to(root))
|
||||
if not any(excl in rel_path for excl in ["__pycache__", "node_modules", ".git"]):
|
||||
self._file_cache.append(rel_path)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def get_completions(self, text: str, cursor_pos: int) -> List[str]:
|
||||
"""
|
||||
Get completions for the current input.
|
||||
|
||||
Args:
|
||||
text: Current input text
|
||||
cursor_pos: Cursor position
|
||||
|
||||
Returns:
|
||||
List of completion suggestions
|
||||
"""
|
||||
# Get word before cursor
|
||||
before_cursor = text[:cursor_pos]
|
||||
words = before_cursor.split()
|
||||
current_word = words[-1] if words else ""
|
||||
|
||||
completions = []
|
||||
|
||||
# Slash commands
|
||||
if current_word.startswith("/"):
|
||||
cmd_part = current_word[1:].lower()
|
||||
for cmd in self.commands:
|
||||
if cmd.lower().startswith(cmd_part):
|
||||
completions.append(f"/{cmd}")
|
||||
|
||||
# @ mentions for files
|
||||
elif current_word.startswith("@"):
|
||||
file_part = current_word[1:].lower()
|
||||
for file_path in self._file_cache:
|
||||
if file_part in file_path.lower():
|
||||
completions.append(f"@{file_path}")
|
||||
|
||||
# General completions
|
||||
else:
|
||||
word_lower = current_word.lower()
|
||||
|
||||
# Check symbols
|
||||
for symbol in self._symbol_cache:
|
||||
if symbol.lower().startswith(word_lower):
|
||||
completions.append(symbol)
|
||||
|
||||
# Check files
|
||||
for file_path in self._file_cache:
|
||||
if word_lower in file_path.lower():
|
||||
completions.append(file_path)
|
||||
|
||||
return completions[:20] # Limit results
|
||||
|
||||
def create_prompt_toolkit_completer(self) -> Any:
|
||||
"""Create a prompt_toolkit completer."""
|
||||
try:
|
||||
from prompt_toolkit.completion import Completer, Completion
|
||||
|
||||
outer_self = self
|
||||
|
||||
class PTCompleter(Completer):
|
||||
def get_completions(self, document, complete_event):
|
||||
text = document.text
|
||||
cursor_pos = document.cursor_position
|
||||
|
||||
for completion in outer_self.get_completions(text, cursor_pos):
|
||||
# Calculate start position
|
||||
words = text[:cursor_pos].split()
|
||||
current_word = words[-1] if words else ""
|
||||
start_pos = -len(current_word)
|
||||
|
||||
yield Completion(completion, start_position=start_pos)
|
||||
|
||||
return PTCompleter()
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# History Manager
|
||||
# ============================================================================
|
||||
|
||||
class HistoryManager:
|
||||
"""
|
||||
Manages command history with persistence.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
history_file: Optional[str] = None,
|
||||
max_entries: int = 1000
|
||||
):
|
||||
self.history_file = Path(history_file) if history_file else None
|
||||
self.max_entries = max_entries
|
||||
self._history: List[str] = []
|
||||
self._position: int = 0
|
||||
|
||||
if self.history_file:
|
||||
self._load_history()
|
||||
|
||||
def _load_history(self) -> None:
|
||||
"""Load history from file."""
|
||||
if self.history_file and self.history_file.exists():
|
||||
try:
|
||||
with open(self.history_file, 'r', encoding='utf-8') as f:
|
||||
self._history = [
|
||||
line.strip() for line in f.readlines()
|
||||
if line.strip()
|
||||
][-self.max_entries:]
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not load history: {e}")
|
||||
|
||||
def _save_history(self) -> None:
|
||||
"""Save history to file."""
|
||||
if self.history_file:
|
||||
try:
|
||||
self.history_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.history_file, 'w', encoding='utf-8') as f:
|
||||
for entry in self._history[-self.max_entries:]:
|
||||
f.write(entry + "\n")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not save history: {e}")
|
||||
|
||||
def add(self, entry: str) -> None:
|
||||
"""Add an entry to history."""
|
||||
entry = entry.strip()
|
||||
if entry and (not self._history or self._history[-1] != entry):
|
||||
self._history.append(entry)
|
||||
self._position = len(self._history)
|
||||
self._save_history()
|
||||
|
||||
def get_previous(self) -> Optional[str]:
|
||||
"""Get previous history entry."""
|
||||
if self._position > 0:
|
||||
self._position -= 1
|
||||
return self._history[self._position]
|
||||
return None
|
||||
|
||||
def get_next(self) -> Optional[str]:
|
||||
"""Get next history entry."""
|
||||
if self._position < len(self._history) - 1:
|
||||
self._position += 1
|
||||
return self._history[self._position]
|
||||
elif self._position == len(self._history) - 1:
|
||||
self._position = len(self._history)
|
||||
return ""
|
||||
return None
|
||||
|
||||
def search(self, prefix: str) -> List[str]:
|
||||
"""Search history for entries starting with prefix."""
|
||||
return [
|
||||
entry for entry in self._history
|
||||
if entry.lower().startswith(prefix.lower())
|
||||
]
|
||||
|
||||
def get_all(self) -> List[str]:
|
||||
"""Get all history entries."""
|
||||
return self._history.copy()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear history."""
|
||||
self._history.clear()
|
||||
self._position = 0
|
||||
if self.history_file and self.history_file.exists():
|
||||
self.history_file.unlink()
|
||||
|
||||
def create_prompt_toolkit_history(self) -> Any:
|
||||
"""Create a prompt_toolkit history object."""
|
||||
try:
|
||||
from prompt_toolkit.history import FileHistory, InMemoryHistory
|
||||
|
||||
if self.history_file:
|
||||
return FileHistory(str(self.history_file))
|
||||
return InMemoryHistory()
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Status Display
|
||||
# ============================================================================
|
||||
|
||||
class StatusDisplay:
|
||||
"""
|
||||
Rich-based status display for interactive session.
|
||||
"""
|
||||
|
||||
def __init__(self, show_status_bar: bool = True):
|
||||
self.show_status_bar = show_status_bar
|
||||
self._console = None
|
||||
self._status_items: Dict[str, str] = {}
|
||||
|
||||
@property
|
||||
def console(self):
|
||||
"""Lazy load Rich console."""
|
||||
if self._console is None:
|
||||
try:
|
||||
from rich.console import Console
|
||||
self._console = Console()
|
||||
except ImportError:
|
||||
self._console = None
|
||||
return self._console
|
||||
|
||||
def set_status(self, key: str, value: str) -> None:
|
||||
"""Set a status item."""
|
||||
self._status_items[key] = value
|
||||
|
||||
def clear_status(self, key: str) -> None:
|
||||
"""Clear a status item."""
|
||||
self._status_items.pop(key, None)
|
||||
|
||||
def print_status_bar(self) -> None:
|
||||
"""Print the status bar."""
|
||||
if not self.show_status_bar or not self.console:
|
||||
return
|
||||
|
||||
from rich.columns import Columns
|
||||
from rich.text import Text
|
||||
|
||||
items = []
|
||||
for key, value in self._status_items.items():
|
||||
items.append(Text(f"{key}: {value}", style="dim"))
|
||||
|
||||
if items:
|
||||
self.console.print(Columns(items, equal=True, expand=True))
|
||||
|
||||
def print_welcome(self, version: str = "1.0.0") -> None:
|
||||
"""Print welcome message."""
|
||||
if not self.console:
|
||||
print(f"PraisonAI CLI v{version}")
|
||||
return
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
welcome = Text()
|
||||
welcome.append("PraisonAI CLI", style="bold cyan")
|
||||
welcome.append(f" v{version}\n", style="dim")
|
||||
welcome.append("Type ", style="dim")
|
||||
welcome.append("/help", style="bold green")
|
||||
welcome.append(" for commands, ", style="dim")
|
||||
welcome.append("/exit", style="bold yellow")
|
||||
welcome.append(" to quit", style="dim")
|
||||
|
||||
self.console.print(Panel(welcome, border_style="blue"))
|
||||
|
||||
def print_response(self, response: str, title: str = "Response") -> None:
|
||||
"""Print a response with formatting."""
|
||||
if not self.console:
|
||||
print(response)
|
||||
return
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.markdown import Markdown
|
||||
|
||||
try:
|
||||
md = Markdown(response)
|
||||
self.console.print(Panel(md, title=title, border_style="green"))
|
||||
except Exception:
|
||||
self.console.print(Panel(response, title=title, border_style="green"))
|
||||
|
||||
def print_error(self, error: str) -> None:
|
||||
"""Print an error message."""
|
||||
if not self.console:
|
||||
print(f"Error: {error}")
|
||||
return
|
||||
|
||||
from rich.panel import Panel
|
||||
self.console.print(Panel(error, title="Error", border_style="red"))
|
||||
|
||||
def print_info(self, message: str) -> None:
|
||||
"""Print an info message."""
|
||||
if not self.console:
|
||||
print(message)
|
||||
return
|
||||
|
||||
self.console.print(f"[cyan]ℹ[/cyan] {message}")
|
||||
|
||||
def print_success(self, message: str) -> None:
|
||||
"""Print a success message."""
|
||||
if not self.console:
|
||||
print(message)
|
||||
return
|
||||
|
||||
self.console.print(f"[green]✓[/green] {message}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Interactive Session
|
||||
# ============================================================================
|
||||
|
||||
class InteractiveSession:
|
||||
"""
|
||||
Main interactive session manager.
|
||||
|
||||
Provides a rich interactive terminal experience with:
|
||||
- Command completion
|
||||
- History
|
||||
- Syntax highlighting
|
||||
- Status bar
|
||||
- Keyboard shortcuts
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[InteractiveConfig] = None,
|
||||
on_input: Optional[Callable[[str], Optional[str]]] = None,
|
||||
on_command: Optional[Callable[[str], Optional[Dict[str, Any]]]] = None
|
||||
):
|
||||
self.config = config or InteractiveConfig()
|
||||
self.on_input = on_input
|
||||
self.on_command = on_command
|
||||
|
||||
self.completer = CommandCompleter()
|
||||
self.history = HistoryManager(
|
||||
history_file=self.config.history_file,
|
||||
max_entries=self.config.max_history
|
||||
)
|
||||
self.display = StatusDisplay(show_status_bar=self.config.show_status_bar)
|
||||
|
||||
self._running = False
|
||||
self._prompt_session = None
|
||||
|
||||
def _create_prompt_session(self) -> Any:
|
||||
"""Create a prompt_toolkit session."""
|
||||
try:
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.lexers import PygmentsLexer
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from pygments.lexers import MarkdownLexer
|
||||
|
||||
session_kwargs = {
|
||||
"message": self.config.prompt,
|
||||
"multiline": self.config.multiline,
|
||||
}
|
||||
|
||||
# Add completer
|
||||
if self.config.enable_completions:
|
||||
completer = self.completer.create_prompt_toolkit_completer()
|
||||
if completer:
|
||||
session_kwargs["completer"] = completer
|
||||
|
||||
# Add history
|
||||
history = self.history.create_prompt_toolkit_history()
|
||||
if history:
|
||||
session_kwargs["history"] = history
|
||||
|
||||
# Add syntax highlighting
|
||||
if self.config.enable_syntax_highlighting:
|
||||
session_kwargs["lexer"] = PygmentsLexer(MarkdownLexer)
|
||||
|
||||
# Add auto-suggest
|
||||
if self.config.auto_suggest:
|
||||
session_kwargs["auto_suggest"] = AutoSuggestFromHistory()
|
||||
|
||||
# VI mode
|
||||
if self.config.vi_mode:
|
||||
from prompt_toolkit.enums import EditingMode
|
||||
session_kwargs["editing_mode"] = EditingMode.VI
|
||||
|
||||
return PromptSession(**session_kwargs)
|
||||
|
||||
except ImportError as e:
|
||||
logger.debug(f"prompt_toolkit not available: {e}")
|
||||
return None
|
||||
|
||||
def _get_input_fallback(self) -> str:
|
||||
"""Fallback input method without prompt_toolkit."""
|
||||
try:
|
||||
if self.config.multiline:
|
||||
print(f"{self.config.prompt}(Enter empty line to submit)")
|
||||
lines = []
|
||||
while True:
|
||||
line = input()
|
||||
if not line:
|
||||
break
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
else:
|
||||
return input(self.config.prompt)
|
||||
except EOFError:
|
||||
return "/exit"
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
return ""
|
||||
|
||||
def get_input(self) -> str:
|
||||
"""Get input from user."""
|
||||
if self._prompt_session:
|
||||
try:
|
||||
return self._prompt_session.prompt()
|
||||
except KeyboardInterrupt:
|
||||
return ""
|
||||
except EOFError:
|
||||
return "/exit"
|
||||
else:
|
||||
return self._get_input_fallback()
|
||||
|
||||
def process_input(self, user_input: str) -> Optional[str]:
|
||||
"""
|
||||
Process user input.
|
||||
|
||||
Args:
|
||||
user_input: Raw user input
|
||||
|
||||
Returns:
|
||||
Response string or None
|
||||
"""
|
||||
user_input = user_input.strip()
|
||||
|
||||
if not user_input:
|
||||
return None
|
||||
|
||||
# Add to history
|
||||
self.history.add(user_input)
|
||||
|
||||
# Check for slash commands
|
||||
if user_input.startswith("/"):
|
||||
if self.on_command:
|
||||
result = self.on_command(user_input)
|
||||
if result:
|
||||
if result.get("type") == "exit":
|
||||
self._running = False
|
||||
return None
|
||||
return result.get("message")
|
||||
return None
|
||||
|
||||
# Regular input
|
||||
if self.on_input:
|
||||
return self.on_input(user_input)
|
||||
|
||||
return None
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the interactive session."""
|
||||
self._running = True
|
||||
self._prompt_session = self._create_prompt_session()
|
||||
|
||||
# Print welcome
|
||||
self.display.print_welcome()
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
# Get input
|
||||
user_input = self.get_input()
|
||||
|
||||
# Process
|
||||
response = self.process_input(user_input)
|
||||
|
||||
# Display response
|
||||
if response:
|
||||
self.display.print_response(response)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self.display.print_info("Use /exit to quit")
|
||||
except Exception as e:
|
||||
self.display.print_error(str(e))
|
||||
logger.exception("Error in interactive session")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the interactive session."""
|
||||
self._running = False
|
||||
|
||||
def add_commands(self, commands: List[str]) -> None:
|
||||
"""Add commands for completion."""
|
||||
self.completer.add_commands(commands)
|
||||
|
||||
def add_symbols(self, symbols: List[str]) -> None:
|
||||
"""Add symbols for completion."""
|
||||
self.completer.add_symbols(symbols)
|
||||
|
||||
def refresh_files(self, root: Optional[Path] = None) -> None:
|
||||
"""Refresh file completions."""
|
||||
self.completer.refresh_files(root)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CLI Integration Handler
|
||||
# ============================================================================
|
||||
|
||||
class InteractiveTUIHandler:
|
||||
"""
|
||||
Handler for integrating Interactive TUI with PraisonAI CLI.
|
||||
"""
|
||||
|
||||
def __init__(self, verbose: bool = False):
|
||||
self.verbose = verbose
|
||||
self._session: Optional[InteractiveSession] = None
|
||||
|
||||
@property
|
||||
def feature_name(self) -> str:
|
||||
return "interactive_tui"
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
config: Optional[InteractiveConfig] = None,
|
||||
on_input: Optional[Callable[[str], Optional[str]]] = None,
|
||||
on_command: Optional[Callable[[str], Optional[Dict[str, Any]]]] = None
|
||||
) -> InteractiveSession:
|
||||
"""Initialize the interactive session."""
|
||||
self._session = InteractiveSession(
|
||||
config=config,
|
||||
on_input=on_input,
|
||||
on_command=on_command
|
||||
)
|
||||
|
||||
if self.verbose:
|
||||
from rich import print as rprint
|
||||
rprint("[cyan]Interactive TUI initialized[/cyan]")
|
||||
|
||||
return self._session
|
||||
|
||||
def get_session(self) -> Optional[InteractiveSession]:
|
||||
"""Get the current session."""
|
||||
return self._session
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the interactive session."""
|
||||
if not self._session:
|
||||
self._session = self.initialize()
|
||||
|
||||
self._session.run()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the interactive session."""
|
||||
if self._session:
|
||||
self._session.stop()
|
||||
@@ -0,0 +1,860 @@
|
||||
"""
|
||||
Repository Map System for PraisonAI CLI.
|
||||
|
||||
Inspired by Aider's RepoMap using tree-sitter for code parsing.
|
||||
Provides intelligent codebase context for LLM interactions.
|
||||
|
||||
Architecture:
|
||||
- RepoMap: Main class for building repository maps
|
||||
- SymbolExtractor: Extracts symbols using tree-sitter or regex fallback
|
||||
- SymbolRanker: Ranks symbols by importance using graph analysis
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Data Classes
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class Symbol:
|
||||
"""
|
||||
Represents a code symbol (class, function, method, variable).
|
||||
"""
|
||||
name: str
|
||||
kind: str # class, function, method, variable, import
|
||||
file_path: str
|
||||
line_number: int
|
||||
signature: str = "" # Full signature for functions/methods
|
||||
parent: Optional[str] = None # Parent class for methods
|
||||
references: int = 0 # Number of references in codebase
|
||||
|
||||
@property
|
||||
def qualified_name(self) -> str:
|
||||
"""Get fully qualified name."""
|
||||
if self.parent:
|
||||
return f"{self.parent}.{self.name}"
|
||||
return self.name
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.name, self.kind, self.file_path, self.line_number))
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileMap:
|
||||
"""
|
||||
Map of symbols in a single file.
|
||||
"""
|
||||
file_path: str
|
||||
symbols: List[Symbol] = field(default_factory=list)
|
||||
imports: List[str] = field(default_factory=list)
|
||||
|
||||
def add_symbol(self, symbol: Symbol) -> None:
|
||||
"""Add a symbol to the file map."""
|
||||
self.symbols.append(symbol)
|
||||
|
||||
def get_summary(self, max_symbols: int = 10) -> str:
|
||||
"""Get a summary of the file's symbols."""
|
||||
lines = [f"{self.file_path}:"]
|
||||
|
||||
# Sort by importance (references) and take top symbols
|
||||
sorted_symbols = sorted(
|
||||
self.symbols,
|
||||
key=lambda s: (s.references, s.kind == "class"),
|
||||
reverse=True
|
||||
)[:max_symbols]
|
||||
|
||||
for symbol in sorted_symbols:
|
||||
if symbol.signature:
|
||||
lines.append(f" {symbol.signature}")
|
||||
else:
|
||||
lines.append(f" {symbol.kind} {symbol.name}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepoMapConfig:
|
||||
"""Configuration for repository mapping."""
|
||||
max_tokens: int = 1024
|
||||
max_files: int = 50
|
||||
max_symbols_per_file: int = 20
|
||||
include_imports: bool = True
|
||||
include_docstrings: bool = False
|
||||
file_extensions: Set[str] = field(default_factory=lambda: {
|
||||
".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".go", ".rs",
|
||||
".cpp", ".c", ".h", ".hpp", ".rb", ".php", ".swift", ".kt"
|
||||
})
|
||||
exclude_patterns: Set[str] = field(default_factory=lambda: {
|
||||
"__pycache__", "node_modules", ".git", ".venv", "venv",
|
||||
"build", "dist", ".egg-info", "__init__.py"
|
||||
})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Symbol Extraction
|
||||
# ============================================================================
|
||||
|
||||
class SymbolExtractor:
|
||||
"""
|
||||
Extracts symbols from source code.
|
||||
|
||||
Uses tree-sitter when available, falls back to regex patterns.
|
||||
"""
|
||||
|
||||
def __init__(self, use_tree_sitter: bool = True):
|
||||
self.use_tree_sitter = use_tree_sitter
|
||||
self._tree_sitter_available = False
|
||||
self._parsers: Dict[str, Any] = {}
|
||||
|
||||
if use_tree_sitter:
|
||||
self._init_tree_sitter()
|
||||
|
||||
def _init_tree_sitter(self) -> None:
|
||||
"""Initialize tree-sitter if available."""
|
||||
try:
|
||||
# Try to import tree-sitter-languages (pip installable)
|
||||
import tree_sitter_languages
|
||||
self._tree_sitter_available = True
|
||||
self._ts_languages = tree_sitter_languages
|
||||
logger.debug("tree-sitter-languages available")
|
||||
except ImportError:
|
||||
try:
|
||||
# Fallback to tree-sitter with manual language setup
|
||||
import tree_sitter
|
||||
self._tree_sitter_available = True
|
||||
self._tree_sitter = tree_sitter
|
||||
logger.debug("tree-sitter available (manual setup required)")
|
||||
except ImportError:
|
||||
logger.debug("tree-sitter not available, using regex fallback")
|
||||
self._tree_sitter_available = False
|
||||
|
||||
def extract_symbols(self, file_path: str, content: str) -> List[Symbol]:
|
||||
"""
|
||||
Extract symbols from file content.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
content: File content
|
||||
|
||||
Returns:
|
||||
List of extracted symbols
|
||||
"""
|
||||
ext = Path(file_path).suffix.lower()
|
||||
|
||||
if self._tree_sitter_available:
|
||||
try:
|
||||
return self._extract_with_tree_sitter(file_path, content, ext)
|
||||
except Exception as e:
|
||||
logger.debug(f"tree-sitter extraction failed: {e}")
|
||||
|
||||
# Fallback to regex
|
||||
return self._extract_with_regex(file_path, content, ext)
|
||||
|
||||
def _extract_with_tree_sitter(
|
||||
self, file_path: str, content: str, ext: str
|
||||
) -> List[Symbol]:
|
||||
"""Extract symbols using tree-sitter."""
|
||||
symbols = []
|
||||
|
||||
# Map extension to language
|
||||
lang_map = {
|
||||
".py": "python",
|
||||
".js": "javascript",
|
||||
".ts": "typescript",
|
||||
".jsx": "javascript",
|
||||
".tsx": "typescript",
|
||||
".java": "java",
|
||||
".go": "go",
|
||||
".rs": "rust",
|
||||
".rb": "ruby",
|
||||
".cpp": "cpp",
|
||||
".c": "c",
|
||||
".h": "c",
|
||||
}
|
||||
|
||||
lang = lang_map.get(ext)
|
||||
if not lang:
|
||||
return self._extract_with_regex(file_path, content, ext)
|
||||
|
||||
try:
|
||||
parser = self._ts_languages.get_parser(lang)
|
||||
tree = parser.parse(content.encode())
|
||||
|
||||
# Extract based on language
|
||||
if lang == "python":
|
||||
symbols = self._extract_python_symbols(file_path, tree, content)
|
||||
else:
|
||||
# Generic extraction for other languages
|
||||
symbols = self._extract_generic_symbols(file_path, tree, content, lang)
|
||||
except Exception as e:
|
||||
logger.debug(f"tree-sitter parsing failed for {lang}: {e}")
|
||||
return self._extract_with_regex(file_path, content, ext)
|
||||
|
||||
return symbols
|
||||
|
||||
def _extract_python_symbols(
|
||||
self, file_path: str, tree: Any, content: str
|
||||
) -> List[Symbol]:
|
||||
"""Extract Python symbols from tree-sitter AST."""
|
||||
symbols = []
|
||||
lines = content.split("\n")
|
||||
|
||||
def visit_node(node, parent_class=None):
|
||||
if node.type == "class_definition":
|
||||
name_node = node.child_by_field_name("name")
|
||||
if name_node:
|
||||
class_name = content[name_node.start_byte:name_node.end_byte]
|
||||
line_num = node.start_point[0] + 1
|
||||
|
||||
# Get class signature
|
||||
sig_end = node.children[0].end_byte if node.children else node.start_byte + 50
|
||||
signature = content[node.start_byte:sig_end].split("\n")[0]
|
||||
|
||||
symbols.append(Symbol(
|
||||
name=class_name,
|
||||
kind="class",
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=signature.strip()
|
||||
))
|
||||
|
||||
# Visit children with class context
|
||||
for child in node.children:
|
||||
visit_node(child, class_name)
|
||||
return
|
||||
|
||||
elif node.type == "function_definition":
|
||||
name_node = node.child_by_field_name("name")
|
||||
if name_node:
|
||||
func_name = content[name_node.start_byte:name_node.end_byte]
|
||||
line_num = node.start_point[0] + 1
|
||||
|
||||
# Get function signature
|
||||
sig_line = lines[line_num - 1] if line_num <= len(lines) else ""
|
||||
|
||||
kind = "method" if parent_class else "function"
|
||||
symbols.append(Symbol(
|
||||
name=func_name,
|
||||
kind=kind,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=sig_line.strip(),
|
||||
parent=parent_class
|
||||
))
|
||||
|
||||
# Visit children
|
||||
for child in node.children:
|
||||
visit_node(child, parent_class)
|
||||
|
||||
visit_node(tree.root_node)
|
||||
return symbols
|
||||
|
||||
def _extract_generic_symbols(
|
||||
self, file_path: str, tree: Any, content: str, lang: str
|
||||
) -> List[Symbol]:
|
||||
"""Generic symbol extraction for other languages."""
|
||||
symbols = []
|
||||
|
||||
# Node types for different languages
|
||||
class_types = {"class_definition", "class_declaration", "struct_definition"}
|
||||
func_types = {"function_definition", "function_declaration", "method_definition"}
|
||||
|
||||
def visit_node(node, parent=None):
|
||||
if node.type in class_types:
|
||||
name = self._get_node_name(node, content)
|
||||
if name:
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
kind="class",
|
||||
file_path=file_path,
|
||||
line_number=node.start_point[0] + 1
|
||||
))
|
||||
for child in node.children:
|
||||
visit_node(child, name)
|
||||
return
|
||||
|
||||
elif node.type in func_types:
|
||||
name = self._get_node_name(node, content)
|
||||
if name:
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
kind="method" if parent else "function",
|
||||
file_path=file_path,
|
||||
line_number=node.start_point[0] + 1,
|
||||
parent=parent
|
||||
))
|
||||
|
||||
for child in node.children:
|
||||
visit_node(child, parent)
|
||||
|
||||
visit_node(tree.root_node)
|
||||
return symbols
|
||||
|
||||
def _get_node_name(self, node: Any, content: str) -> Optional[str]:
|
||||
"""Get name from a tree-sitter node."""
|
||||
name_node = node.child_by_field_name("name")
|
||||
if name_node:
|
||||
return content[name_node.start_byte:name_node.end_byte]
|
||||
return None
|
||||
|
||||
def _extract_with_regex(
|
||||
self, file_path: str, content: str, ext: str
|
||||
) -> List[Symbol]:
|
||||
"""Extract symbols using regex patterns (fallback)."""
|
||||
symbols = []
|
||||
lines = content.split("\n")
|
||||
|
||||
if ext == ".py":
|
||||
symbols = self._extract_python_regex(file_path, lines)
|
||||
elif ext in {".js", ".ts", ".jsx", ".tsx"}:
|
||||
symbols = self._extract_js_regex(file_path, lines)
|
||||
elif ext in {".java", ".kt"}:
|
||||
symbols = self._extract_java_regex(file_path, lines)
|
||||
elif ext == ".go":
|
||||
symbols = self._extract_go_regex(file_path, lines)
|
||||
elif ext == ".rs":
|
||||
symbols = self._extract_rust_regex(file_path, lines)
|
||||
else:
|
||||
# Generic extraction
|
||||
symbols = self._extract_generic_regex(file_path, lines)
|
||||
|
||||
return symbols
|
||||
|
||||
def _extract_python_regex(self, file_path: str, lines: List[str]) -> List[Symbol]:
|
||||
"""Extract Python symbols using regex."""
|
||||
symbols = []
|
||||
current_class = None
|
||||
|
||||
class_pattern = re.compile(r'^class\s+(\w+)')
|
||||
func_pattern = re.compile(r'^(\s*)def\s+(\w+)\s*\(([^)]*)\)')
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
line_num = i + 1
|
||||
|
||||
# Check for class
|
||||
class_match = class_pattern.match(line)
|
||||
if class_match:
|
||||
current_class = class_match.group(1)
|
||||
symbols.append(Symbol(
|
||||
name=current_class,
|
||||
kind="class",
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=line.strip()
|
||||
))
|
||||
continue
|
||||
|
||||
# Check for function/method
|
||||
func_match = func_pattern.match(line)
|
||||
if func_match:
|
||||
indent = len(func_match.group(1))
|
||||
func_name = func_match.group(2)
|
||||
|
||||
# If indented, it's a method
|
||||
if indent > 0 and current_class:
|
||||
symbols.append(Symbol(
|
||||
name=func_name,
|
||||
kind="method",
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=line.strip(),
|
||||
parent=current_class
|
||||
))
|
||||
else:
|
||||
current_class = None # Reset class context
|
||||
symbols.append(Symbol(
|
||||
name=func_name,
|
||||
kind="function",
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=line.strip()
|
||||
))
|
||||
|
||||
return symbols
|
||||
|
||||
def _extract_js_regex(self, file_path: str, lines: List[str]) -> List[Symbol]:
|
||||
"""Extract JavaScript/TypeScript symbols using regex."""
|
||||
symbols = []
|
||||
|
||||
class_pattern = re.compile(r'(?:export\s+)?class\s+(\w+)')
|
||||
func_pattern = re.compile(
|
||||
r'(?:export\s+)?(?:async\s+)?function\s+(\w+)|'
|
||||
r'(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>'
|
||||
)
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
line_num = i + 1
|
||||
|
||||
class_match = class_pattern.search(line)
|
||||
if class_match:
|
||||
symbols.append(Symbol(
|
||||
name=class_match.group(1),
|
||||
kind="class",
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=line.strip()[:80]
|
||||
))
|
||||
|
||||
func_match = func_pattern.search(line)
|
||||
if func_match:
|
||||
name = func_match.group(1) or func_match.group(2)
|
||||
if name:
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
kind="function",
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=line.strip()[:80]
|
||||
))
|
||||
|
||||
return symbols
|
||||
|
||||
def _extract_java_regex(self, file_path: str, lines: List[str]) -> List[Symbol]:
|
||||
"""Extract Java/Kotlin symbols using regex."""
|
||||
symbols = []
|
||||
|
||||
class_pattern = re.compile(
|
||||
r'(?:public|private|protected)?\s*(?:static\s+)?'
|
||||
r'(?:class|interface|enum)\s+(\w+)'
|
||||
)
|
||||
method_pattern = re.compile(
|
||||
r'(?:public|private|protected)?\s*(?:static\s+)?'
|
||||
r'(?:\w+(?:<[^>]+>)?)\s+(\w+)\s*\('
|
||||
)
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
line_num = i + 1
|
||||
|
||||
class_match = class_pattern.search(line)
|
||||
if class_match:
|
||||
symbols.append(Symbol(
|
||||
name=class_match.group(1),
|
||||
kind="class",
|
||||
file_path=file_path,
|
||||
line_number=line_num
|
||||
))
|
||||
|
||||
method_match = method_pattern.search(line)
|
||||
if method_match and not class_match:
|
||||
symbols.append(Symbol(
|
||||
name=method_match.group(1),
|
||||
kind="method",
|
||||
file_path=file_path,
|
||||
line_number=line_num
|
||||
))
|
||||
|
||||
return symbols
|
||||
|
||||
def _extract_go_regex(self, file_path: str, lines: List[str]) -> List[Symbol]:
|
||||
"""Extract Go symbols using regex."""
|
||||
symbols = []
|
||||
|
||||
func_pattern = re.compile(r'^func\s+(?:\([^)]+\)\s+)?(\w+)')
|
||||
type_pattern = re.compile(r'^type\s+(\w+)\s+(?:struct|interface)')
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
line_num = i + 1
|
||||
|
||||
type_match = type_pattern.match(line)
|
||||
if type_match:
|
||||
symbols.append(Symbol(
|
||||
name=type_match.group(1),
|
||||
kind="class",
|
||||
file_path=file_path,
|
||||
line_number=line_num
|
||||
))
|
||||
|
||||
func_match = func_pattern.match(line)
|
||||
if func_match:
|
||||
symbols.append(Symbol(
|
||||
name=func_match.group(1),
|
||||
kind="function",
|
||||
file_path=file_path,
|
||||
line_number=line_num
|
||||
))
|
||||
|
||||
return symbols
|
||||
|
||||
def _extract_rust_regex(self, file_path: str, lines: List[str]) -> List[Symbol]:
|
||||
"""Extract Rust symbols using regex."""
|
||||
symbols = []
|
||||
|
||||
fn_pattern = re.compile(r'(?:pub\s+)?(?:async\s+)?fn\s+(\w+)')
|
||||
struct_pattern = re.compile(r'(?:pub\s+)?struct\s+(\w+)')
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
line_num = i + 1
|
||||
|
||||
struct_match = struct_pattern.match(line)
|
||||
if struct_match:
|
||||
symbols.append(Symbol(
|
||||
name=struct_match.group(1),
|
||||
kind="class",
|
||||
file_path=file_path,
|
||||
line_number=line_num
|
||||
))
|
||||
|
||||
fn_match = fn_pattern.search(line)
|
||||
if fn_match:
|
||||
symbols.append(Symbol(
|
||||
name=fn_match.group(1),
|
||||
kind="function",
|
||||
file_path=file_path,
|
||||
line_number=line_num
|
||||
))
|
||||
|
||||
return symbols
|
||||
|
||||
def _extract_generic_regex(self, file_path: str, lines: List[str]) -> List[Symbol]:
|
||||
"""Generic symbol extraction for unknown languages."""
|
||||
symbols = []
|
||||
|
||||
# Very basic patterns
|
||||
class_pattern = re.compile(r'(?:class|struct|interface)\s+(\w+)')
|
||||
func_pattern = re.compile(r'(?:function|def|fn|func)\s+(\w+)')
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
line_num = i + 1
|
||||
|
||||
class_match = class_pattern.search(line)
|
||||
if class_match:
|
||||
symbols.append(Symbol(
|
||||
name=class_match.group(1),
|
||||
kind="class",
|
||||
file_path=file_path,
|
||||
line_number=line_num
|
||||
))
|
||||
|
||||
func_match = func_pattern.search(line)
|
||||
if func_match:
|
||||
symbols.append(Symbol(
|
||||
name=func_match.group(1),
|
||||
kind="function",
|
||||
file_path=file_path,
|
||||
line_number=line_num
|
||||
))
|
||||
|
||||
return symbols
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Symbol Ranking
|
||||
# ============================================================================
|
||||
|
||||
class SymbolRanker:
|
||||
"""
|
||||
Ranks symbols by importance using reference counting and graph analysis.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.symbol_refs: Dict[str, int] = defaultdict(int)
|
||||
self.file_refs: Dict[str, Set[str]] = defaultdict(set)
|
||||
|
||||
def analyze_references(
|
||||
self, file_maps: Dict[str, FileMap], all_content: Dict[str, str]
|
||||
) -> None:
|
||||
"""
|
||||
Analyze references between symbols across files.
|
||||
|
||||
Args:
|
||||
file_maps: Map of file path to FileMap
|
||||
all_content: Map of file path to content
|
||||
"""
|
||||
# Build symbol name set
|
||||
all_symbols = set()
|
||||
for file_map in file_maps.values():
|
||||
for symbol in file_map.symbols:
|
||||
all_symbols.add(symbol.name)
|
||||
|
||||
# Count references
|
||||
for file_path, content in all_content.items():
|
||||
for symbol_name in all_symbols:
|
||||
# Simple word boundary match
|
||||
pattern = rf'\b{re.escape(symbol_name)}\b'
|
||||
matches = len(re.findall(pattern, content))
|
||||
if matches > 0:
|
||||
self.symbol_refs[symbol_name] += matches
|
||||
self.file_refs[file_path].add(symbol_name)
|
||||
|
||||
# Update symbol reference counts
|
||||
for file_map in file_maps.values():
|
||||
for symbol in file_map.symbols:
|
||||
symbol.references = self.symbol_refs.get(symbol.name, 0)
|
||||
|
||||
def get_top_symbols(
|
||||
self, file_maps: Dict[str, FileMap], max_symbols: int = 50
|
||||
) -> List[Symbol]:
|
||||
"""Get the most important symbols across the codebase."""
|
||||
all_symbols = []
|
||||
for file_map in file_maps.values():
|
||||
all_symbols.extend(file_map.symbols)
|
||||
|
||||
# Sort by references and kind priority
|
||||
kind_priority = {"class": 3, "function": 2, "method": 1, "variable": 0}
|
||||
|
||||
sorted_symbols = sorted(
|
||||
all_symbols,
|
||||
key=lambda s: (s.references, kind_priority.get(s.kind, 0)),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
return sorted_symbols[:max_symbols]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Repository Map
|
||||
# ============================================================================
|
||||
|
||||
class RepoMap:
|
||||
"""
|
||||
Main class for building repository maps.
|
||||
|
||||
Provides intelligent codebase context for LLM interactions.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Optional[str] = None,
|
||||
config: Optional[RepoMapConfig] = None,
|
||||
verbose: bool = False
|
||||
):
|
||||
self.root = Path(root) if root else Path.cwd()
|
||||
self.config = config or RepoMapConfig()
|
||||
self.verbose = verbose
|
||||
|
||||
self.extractor = SymbolExtractor()
|
||||
self.ranker = SymbolRanker()
|
||||
|
||||
self._file_maps: Dict[str, FileMap] = {}
|
||||
self._all_content: Dict[str, str] = {}
|
||||
self._last_map: Optional[str] = None
|
||||
|
||||
def scan(self, paths: Optional[List[str]] = None) -> None:
|
||||
"""
|
||||
Scan the repository for symbols.
|
||||
|
||||
Args:
|
||||
paths: Optional list of specific paths to scan
|
||||
"""
|
||||
if paths:
|
||||
files_to_scan = [Path(p) for p in paths]
|
||||
else:
|
||||
files_to_scan = self._find_files()
|
||||
|
||||
for file_path in files_to_scan:
|
||||
try:
|
||||
self._scan_file(file_path)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error scanning {file_path}: {e}")
|
||||
|
||||
# Analyze references
|
||||
self.ranker.analyze_references(self._file_maps, self._all_content)
|
||||
|
||||
if self.verbose:
|
||||
logger.info(f"Scanned {len(self._file_maps)} files")
|
||||
|
||||
def _find_files(self) -> List[Path]:
|
||||
"""Find all relevant source files in the repository."""
|
||||
files = []
|
||||
|
||||
for ext in self.config.file_extensions:
|
||||
for file_path in self.root.rglob(f"*{ext}"):
|
||||
# Check exclusions
|
||||
if any(excl in str(file_path) for excl in self.config.exclude_patterns):
|
||||
continue
|
||||
files.append(file_path)
|
||||
|
||||
return files[:self.config.max_files]
|
||||
|
||||
def _scan_file(self, file_path: Path) -> None:
|
||||
"""Scan a single file for symbols."""
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read {file_path}: {e}")
|
||||
return
|
||||
|
||||
rel_path = str(file_path.relative_to(self.root))
|
||||
self._all_content[rel_path] = content
|
||||
|
||||
symbols = self.extractor.extract_symbols(rel_path, content)
|
||||
|
||||
file_map = FileMap(file_path=rel_path)
|
||||
for symbol in symbols[:self.config.max_symbols_per_file]:
|
||||
file_map.add_symbol(symbol)
|
||||
|
||||
self._file_maps[rel_path] = file_map
|
||||
|
||||
def get_map(
|
||||
self,
|
||||
focus_files: Optional[List[str]] = None,
|
||||
max_tokens: Optional[int] = None
|
||||
) -> str:
|
||||
"""
|
||||
Get the repository map as a string.
|
||||
|
||||
Args:
|
||||
focus_files: Files to prioritize in the map
|
||||
max_tokens: Maximum tokens for the map
|
||||
|
||||
Returns:
|
||||
Formatted repository map string
|
||||
"""
|
||||
if not self._file_maps:
|
||||
self.scan()
|
||||
|
||||
max_tokens = max_tokens or self.config.max_tokens
|
||||
|
||||
# Get top symbols
|
||||
top_symbols = self.ranker.get_top_symbols(self._file_maps)
|
||||
|
||||
# Group by file
|
||||
file_symbols: Dict[str, List[Symbol]] = defaultdict(list)
|
||||
for symbol in top_symbols:
|
||||
file_symbols[symbol.file_path].append(symbol)
|
||||
|
||||
# Prioritize focus files
|
||||
if focus_files:
|
||||
focus_set = set(focus_files)
|
||||
sorted_files = sorted(
|
||||
file_symbols.keys(),
|
||||
key=lambda f: (f not in focus_set, f)
|
||||
)
|
||||
else:
|
||||
sorted_files = sorted(file_symbols.keys())
|
||||
|
||||
# Build map
|
||||
lines = []
|
||||
estimated_tokens = 0
|
||||
|
||||
for file_path in sorted_files:
|
||||
symbols = file_symbols[file_path]
|
||||
|
||||
file_lines = [f"{file_path}:"]
|
||||
for symbol in symbols:
|
||||
if symbol.signature:
|
||||
file_lines.append(f" │{symbol.signature}")
|
||||
else:
|
||||
file_lines.append(f" │{symbol.kind} {symbol.name}")
|
||||
file_lines.append(" ⋮...")
|
||||
|
||||
file_text = "\n".join(file_lines)
|
||||
file_tokens = len(file_text) // 4 # Rough token estimate
|
||||
|
||||
if estimated_tokens + file_tokens > max_tokens:
|
||||
break
|
||||
|
||||
lines.extend(file_lines)
|
||||
estimated_tokens += file_tokens
|
||||
|
||||
self._last_map = "\n".join(lines)
|
||||
return self._last_map
|
||||
|
||||
def get_file_symbols(self, file_path: str) -> List[Symbol]:
|
||||
"""Get symbols for a specific file."""
|
||||
if file_path in self._file_maps:
|
||||
return self._file_maps[file_path].symbols
|
||||
return []
|
||||
|
||||
def get_symbol_context(self, symbol_name: str) -> Optional[str]:
|
||||
"""Get context around a specific symbol."""
|
||||
for file_path, file_map in self._file_maps.items():
|
||||
for symbol in file_map.symbols:
|
||||
if symbol.name == symbol_name:
|
||||
content = self._all_content.get(file_path, "")
|
||||
lines = content.split("\n")
|
||||
|
||||
# Get lines around the symbol
|
||||
start = max(0, symbol.line_number - 3)
|
||||
end = min(len(lines), symbol.line_number + 10)
|
||||
|
||||
context_lines = lines[start:end]
|
||||
return f"{file_path}:{symbol.line_number}\n" + "\n".join(context_lines)
|
||||
|
||||
return None
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Refresh the repository map."""
|
||||
self._file_maps.clear()
|
||||
self._all_content.clear()
|
||||
self.ranker = SymbolRanker()
|
||||
self.scan()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CLI Integration Handler
|
||||
# ============================================================================
|
||||
|
||||
class RepoMapHandler:
|
||||
"""
|
||||
Handler for integrating RepoMap with PraisonAI CLI.
|
||||
"""
|
||||
|
||||
def __init__(self, verbose: bool = False):
|
||||
self.verbose = verbose
|
||||
self._repo_map: Optional[RepoMap] = None
|
||||
|
||||
@property
|
||||
def feature_name(self) -> str:
|
||||
return "repo_map"
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
root: Optional[str] = None,
|
||||
config: Optional[RepoMapConfig] = None
|
||||
) -> RepoMap:
|
||||
"""Initialize the repository map."""
|
||||
self._repo_map = RepoMap(
|
||||
root=root,
|
||||
config=config,
|
||||
verbose=self.verbose
|
||||
)
|
||||
|
||||
if self.verbose:
|
||||
from rich import print as rprint
|
||||
rprint(f"[cyan]RepoMap initialized for: {self._repo_map.root}[/cyan]")
|
||||
|
||||
return self._repo_map
|
||||
|
||||
def get_map(self, focus_files: Optional[List[str]] = None) -> str:
|
||||
"""Get the repository map."""
|
||||
if not self._repo_map:
|
||||
self._repo_map = self.initialize()
|
||||
|
||||
return self._repo_map.get_map(focus_files=focus_files)
|
||||
|
||||
def get_context(self, symbol_name: str) -> Optional[str]:
|
||||
"""Get context for a symbol."""
|
||||
if not self._repo_map:
|
||||
self._repo_map = self.initialize()
|
||||
|
||||
return self._repo_map.get_symbol_context(symbol_name)
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Refresh the map."""
|
||||
if self._repo_map:
|
||||
self._repo_map.refresh()
|
||||
|
||||
def print_map(self) -> None:
|
||||
"""Print the repository map."""
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.syntax import Syntax
|
||||
|
||||
console = Console()
|
||||
map_str = self.get_map()
|
||||
|
||||
console.print(Panel(
|
||||
Syntax(map_str, "text", theme="monokai"),
|
||||
title="📁 Repository Map",
|
||||
border_style="blue"
|
||||
))
|
||||
@@ -3,12 +3,45 @@ Router Handler for CLI.
|
||||
|
||||
Provides smart model selection based on task complexity.
|
||||
Usage: praisonai "Complex task" --router
|
||||
|
||||
Supports custom model configuration from agents.yaml/workflow.yaml:
|
||||
|
||||
models:
|
||||
gpt-4o-mini:
|
||||
provider: openai
|
||||
complexity: [simple, moderate]
|
||||
cost_per_1k: 0.00075
|
||||
capabilities: [text, function-calling]
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from .base import FlagHandler
|
||||
|
||||
|
||||
class TaskComplexity(IntEnum):
|
||||
"""Enum for task complexity levels."""
|
||||
SIMPLE = 1
|
||||
MODERATE = 2
|
||||
COMPLEX = 3
|
||||
VERY_COMPLEX = 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelProfile:
|
||||
"""Profile for an LLM model with its characteristics."""
|
||||
name: str
|
||||
provider: str
|
||||
complexity_range: Tuple[TaskComplexity, TaskComplexity]
|
||||
cost_per_1k_tokens: float
|
||||
capabilities: List[str]
|
||||
context_window: int = 128000
|
||||
supports_tools: bool = True
|
||||
supports_streaming: bool = True
|
||||
strengths: Optional[List[str]] = None
|
||||
|
||||
|
||||
class RouterHandler(FlagHandler):
|
||||
"""
|
||||
Handler for --router flag.
|
||||
@@ -20,13 +53,82 @@ class RouterHandler(FlagHandler):
|
||||
praisonai "Complex analysis" --router
|
||||
"""
|
||||
|
||||
# Model tiers for routing
|
||||
MODEL_TIERS = {
|
||||
'simple': ['gpt-4o-mini', 'claude-3-haiku', 'gemini-1.5-flash'],
|
||||
'medium': ['gpt-4o', 'claude-3-sonnet', 'gemini-1.5-pro'],
|
||||
'complex': ['gpt-4-turbo', 'claude-3-opus', 'o1-preview'],
|
||||
# Default model tiers for routing (used when no custom models configured)
|
||||
# Note: Use full model names for API compatibility
|
||||
DEFAULT_MODEL_TIERS = {
|
||||
'simple': ['gpt-4o-mini', 'anthropic/claude-3-haiku-20240307', 'gemini/gemini-1.5-flash'],
|
||||
'medium': ['gpt-4o', 'anthropic/claude-3-5-sonnet-20241022', 'gemini/gemini-1.5-pro'],
|
||||
'complex': ['gpt-4-turbo', 'anthropic/claude-3-opus-20240229', 'o1-preview'],
|
||||
}
|
||||
|
||||
# Default model profiles
|
||||
DEFAULT_MODELS: List[ModelProfile] = [
|
||||
ModelProfile(
|
||||
name="gpt-4o-mini",
|
||||
provider="openai",
|
||||
complexity_range=(TaskComplexity.SIMPLE, TaskComplexity.MODERATE),
|
||||
cost_per_1k_tokens=0.00075,
|
||||
capabilities=["text", "function-calling"],
|
||||
context_window=128000
|
||||
),
|
||||
ModelProfile(
|
||||
name="gpt-4o",
|
||||
provider="openai",
|
||||
complexity_range=(TaskComplexity.MODERATE, TaskComplexity.COMPLEX),
|
||||
cost_per_1k_tokens=0.0075,
|
||||
capabilities=["text", "vision", "function-calling"],
|
||||
context_window=128000
|
||||
),
|
||||
ModelProfile(
|
||||
name="anthropic/claude-3-haiku-20240307",
|
||||
provider="anthropic",
|
||||
complexity_range=(TaskComplexity.SIMPLE, TaskComplexity.MODERATE),
|
||||
cost_per_1k_tokens=0.0008,
|
||||
capabilities=["text", "function-calling"],
|
||||
context_window=200000
|
||||
),
|
||||
ModelProfile(
|
||||
name="anthropic/claude-3-5-sonnet-20241022",
|
||||
provider="anthropic",
|
||||
complexity_range=(TaskComplexity.MODERATE, TaskComplexity.VERY_COMPLEX),
|
||||
cost_per_1k_tokens=0.009,
|
||||
capabilities=["text", "vision", "function-calling"],
|
||||
context_window=200000,
|
||||
strengths=["reasoning", "code-generation", "analysis"]
|
||||
),
|
||||
ModelProfile(
|
||||
name="gemini/gemini-1.5-flash",
|
||||
provider="google",
|
||||
complexity_range=(TaskComplexity.SIMPLE, TaskComplexity.MODERATE),
|
||||
cost_per_1k_tokens=0.000125,
|
||||
capabilities=["text", "vision", "function-calling"],
|
||||
context_window=1048576
|
||||
),
|
||||
ModelProfile(
|
||||
name="gemini/gemini-1.5-pro",
|
||||
provider="google",
|
||||
complexity_range=(TaskComplexity.COMPLEX, TaskComplexity.VERY_COMPLEX),
|
||||
cost_per_1k_tokens=0.00625,
|
||||
capabilities=["text", "vision", "function-calling"],
|
||||
context_window=2097152
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(self, verbose: bool = False):
|
||||
"""Initialize RouterHandler with model routing support."""
|
||||
super().__init__(verbose=verbose)
|
||||
self._model_router: Optional[List[ModelProfile]] = None
|
||||
self._custom_models: Dict[str, ModelProfile] = {}
|
||||
self._cost_threshold: Optional[float] = None
|
||||
self._model_tiers = self.DEFAULT_MODEL_TIERS.copy()
|
||||
|
||||
@property
|
||||
def model_router(self) -> List[ModelProfile]:
|
||||
"""Get the list of available model profiles."""
|
||||
if self._model_router is None:
|
||||
return self.DEFAULT_MODELS.copy()
|
||||
return self._model_router
|
||||
|
||||
@property
|
||||
def feature_name(self) -> str:
|
||||
return "router"
|
||||
@@ -94,17 +196,29 @@ class RouterHandler(FlagHandler):
|
||||
Selected model name
|
||||
"""
|
||||
complexity = self.analyze_complexity(prompt)
|
||||
models = self.MODEL_TIERS.get(complexity, self.MODEL_TIERS['medium'])
|
||||
|
||||
# If custom models are loaded, use ModelProfile-based selection
|
||||
if self._custom_models:
|
||||
return self._select_from_profiles(prompt, complexity, preferred_provider)
|
||||
|
||||
# Otherwise use tier-based selection
|
||||
models = self._model_tiers.get(complexity, self._model_tiers['medium'])
|
||||
|
||||
# Filter by provider if specified
|
||||
if preferred_provider:
|
||||
provider_prefixes = {
|
||||
'openai': ['gpt-', 'o1-'],
|
||||
'anthropic': ['claude-'],
|
||||
'google': ['gemini-']
|
||||
'anthropic': ['anthropic/', 'claude-'],
|
||||
'google': ['gemini/', 'gemini-']
|
||||
}
|
||||
prefixes = provider_prefixes.get(preferred_provider.lower(), [])
|
||||
filtered = [m for m in models if any(m.startswith(p) for p in prefixes)]
|
||||
filtered = [m for m in models if any(p in m for p in prefixes)]
|
||||
if filtered:
|
||||
models = filtered
|
||||
|
||||
# Apply cost threshold if set
|
||||
if self._cost_threshold:
|
||||
filtered = [m for m in models if self.get_model_cost(m) <= self._cost_threshold]
|
||||
if filtered:
|
||||
models = filtered
|
||||
|
||||
@@ -115,6 +229,48 @@ class RouterHandler(FlagHandler):
|
||||
|
||||
return selected
|
||||
|
||||
def _select_from_profiles(
|
||||
self,
|
||||
prompt: str,
|
||||
complexity: str,
|
||||
preferred_provider: str = None
|
||||
) -> str:
|
||||
"""Select model from custom ModelProfile objects."""
|
||||
# Map string complexity to TaskComplexity enum
|
||||
complexity_map = {
|
||||
'simple': TaskComplexity.SIMPLE,
|
||||
'medium': TaskComplexity.MODERATE,
|
||||
'complex': TaskComplexity.COMPLEX,
|
||||
}
|
||||
task_complexity = complexity_map.get(complexity, TaskComplexity.MODERATE)
|
||||
|
||||
# Get all models that support this complexity level
|
||||
candidates = []
|
||||
for name, profile in self._custom_models.items():
|
||||
min_c, max_c = profile.complexity_range
|
||||
if min_c.value <= task_complexity.value <= max_c.value:
|
||||
candidates.append(profile)
|
||||
|
||||
if not candidates:
|
||||
return 'gpt-4o-mini'
|
||||
|
||||
# Filter by provider if specified
|
||||
if preferred_provider:
|
||||
filtered = [m for m in candidates if m.provider == preferred_provider]
|
||||
if filtered:
|
||||
candidates = filtered
|
||||
|
||||
# Filter by cost threshold if set
|
||||
if self._cost_threshold:
|
||||
filtered = [m for m in candidates if m.cost_per_1k_tokens <= self._cost_threshold]
|
||||
if filtered:
|
||||
candidates = filtered
|
||||
|
||||
# Sort by cost (cheapest first)
|
||||
candidates.sort(key=lambda m: m.cost_per_1k_tokens)
|
||||
|
||||
return candidates[0].name if candidates else 'gpt-4o-mini'
|
||||
|
||||
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Apply router configuration.
|
||||
@@ -158,3 +314,153 @@ class RouterHandler(FlagHandler):
|
||||
return 'gpt-4o-mini' # Default
|
||||
|
||||
return self.select_model(prompt, provider)
|
||||
|
||||
# =========================================================================
|
||||
# Custom Model Configuration Methods
|
||||
# =========================================================================
|
||||
|
||||
def yaml_to_model_profile(self, name: str, config: Dict[str, Any]) -> ModelProfile:
|
||||
"""
|
||||
Convert YAML model configuration to ModelProfile object.
|
||||
|
||||
Args:
|
||||
name: Model name
|
||||
config: Model configuration from YAML
|
||||
|
||||
Returns:
|
||||
ModelProfile object
|
||||
"""
|
||||
# Parse complexity strings to TaskComplexity enum
|
||||
complexity_list = config.get('complexity', ['moderate'])
|
||||
complexity_map = {
|
||||
'simple': TaskComplexity.SIMPLE,
|
||||
'moderate': TaskComplexity.MODERATE,
|
||||
'complex': TaskComplexity.COMPLEX,
|
||||
'very_complex': TaskComplexity.VERY_COMPLEX,
|
||||
}
|
||||
|
||||
complexities = [complexity_map.get(c.lower(), TaskComplexity.MODERATE) for c in complexity_list]
|
||||
min_complexity = min(complexities, key=lambda x: x.value)
|
||||
max_complexity = max(complexities, key=lambda x: x.value)
|
||||
|
||||
return ModelProfile(
|
||||
name=name,
|
||||
provider=config.get('provider', 'unknown'),
|
||||
complexity_range=(min_complexity, max_complexity),
|
||||
cost_per_1k_tokens=config.get('cost_per_1k', 0.001),
|
||||
capabilities=config.get('capabilities', ['text']),
|
||||
context_window=config.get('context_window', 128000),
|
||||
supports_tools=config.get('supports_tools', True),
|
||||
supports_streaming=config.get('supports_streaming', True),
|
||||
strengths=config.get('strengths', None)
|
||||
)
|
||||
|
||||
def load_models_from_config(
|
||||
self,
|
||||
models_config: Dict[str, Dict[str, Any]],
|
||||
merge_with_defaults: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Load custom models from YAML configuration.
|
||||
|
||||
Args:
|
||||
models_config: Dictionary of model configurations from YAML
|
||||
merge_with_defaults: If True, merge with default models
|
||||
"""
|
||||
# Start with defaults if merging
|
||||
if merge_with_defaults:
|
||||
for profile in self.DEFAULT_MODELS:
|
||||
self._custom_models[profile.name] = profile
|
||||
|
||||
# Add custom models
|
||||
for name, config in models_config.items():
|
||||
profile = self.yaml_to_model_profile(name, config)
|
||||
self._custom_models[name] = profile
|
||||
|
||||
# Rebuild model tiers from profiles
|
||||
self._rebuild_model_tiers()
|
||||
|
||||
def _rebuild_model_tiers(self) -> None:
|
||||
"""Rebuild MODEL_TIERS from custom model profiles."""
|
||||
self._model_tiers = {
|
||||
'simple': [],
|
||||
'medium': [],
|
||||
'complex': [],
|
||||
}
|
||||
|
||||
for name, profile in self._custom_models.items():
|
||||
min_c, max_c = profile.complexity_range
|
||||
|
||||
if min_c.value <= TaskComplexity.SIMPLE.value <= max_c.value:
|
||||
self._model_tiers['simple'].append(name)
|
||||
if min_c.value <= TaskComplexity.MODERATE.value <= max_c.value:
|
||||
self._model_tiers['medium'].append(name)
|
||||
if min_c.value <= TaskComplexity.COMPLEX.value <= max_c.value:
|
||||
self._model_tiers['complex'].append(name)
|
||||
|
||||
# Sort by cost (cheapest first)
|
||||
for tier in self._model_tiers:
|
||||
self._model_tiers[tier].sort(
|
||||
key=lambda n: self._custom_models[n].cost_per_1k_tokens
|
||||
)
|
||||
|
||||
def get_available_models(self) -> List[str]:
|
||||
"""
|
||||
Get list of available model names.
|
||||
|
||||
Returns:
|
||||
List of model names
|
||||
"""
|
||||
if self._custom_models:
|
||||
return list(self._custom_models.keys())
|
||||
return [p.name for p in self.DEFAULT_MODELS]
|
||||
|
||||
def get_model_cost(self, model_name: str) -> float:
|
||||
"""
|
||||
Get cost per 1k tokens for a model.
|
||||
|
||||
Args:
|
||||
model_name: Name of the model
|
||||
|
||||
Returns:
|
||||
Cost per 1k tokens
|
||||
"""
|
||||
# Check custom models first
|
||||
if model_name in self._custom_models:
|
||||
return self._custom_models[model_name].cost_per_1k_tokens
|
||||
|
||||
# Check default models
|
||||
for profile in self.DEFAULT_MODELS:
|
||||
if profile.name == model_name:
|
||||
return profile.cost_per_1k_tokens
|
||||
|
||||
# Default cost for unknown models
|
||||
return 0.01
|
||||
|
||||
def set_cost_threshold(self, threshold: float) -> None:
|
||||
"""
|
||||
Set maximum cost per 1k tokens for model selection.
|
||||
|
||||
Args:
|
||||
threshold: Maximum cost per 1k tokens
|
||||
"""
|
||||
self._cost_threshold = threshold
|
||||
|
||||
def get_model_profile(self, model_name: str) -> Optional[ModelProfile]:
|
||||
"""
|
||||
Get ModelProfile for a model.
|
||||
|
||||
Args:
|
||||
model_name: Name of the model
|
||||
|
||||
Returns:
|
||||
ModelProfile or None if not found
|
||||
"""
|
||||
if model_name in self._custom_models:
|
||||
return self._custom_models[model_name]
|
||||
|
||||
for profile in self.DEFAULT_MODELS:
|
||||
if profile.name == model_name:
|
||||
return profile
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,515 @@
|
||||
"""
|
||||
Sandboxed Execution System for PraisonAI CLI.
|
||||
|
||||
Inspired by Codex CLI's sandbox modes for secure command execution.
|
||||
Provides isolated execution environment when enabled via --sandbox flag.
|
||||
|
||||
Architecture:
|
||||
- SandboxExecutor: Base class for sandboxed execution
|
||||
- SubprocessSandbox: Subprocess-based isolation with resource limits
|
||||
- SandboxPolicy: Configurable security policies
|
||||
|
||||
Note: Sandbox is ONLY activated when explicitly requested via CLI flag.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Set
|
||||
from pathlib import Path
|
||||
from enum import Enum
|
||||
import subprocess
|
||||
import logging
|
||||
import tempfile
|
||||
import shutil
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Sandbox Modes
|
||||
# ============================================================================
|
||||
|
||||
class SandboxMode(Enum):
|
||||
"""
|
||||
Sandbox execution modes.
|
||||
|
||||
- DISABLED: No sandboxing (default)
|
||||
- BASIC: Basic isolation with resource limits
|
||||
- STRICT: Strict isolation with filesystem restrictions
|
||||
- NETWORK_ISOLATED: No network access
|
||||
"""
|
||||
DISABLED = "disabled"
|
||||
BASIC = "basic"
|
||||
STRICT = "strict"
|
||||
NETWORK_ISOLATED = "network_isolated"
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, value: str) -> "SandboxMode":
|
||||
"""Parse mode from string."""
|
||||
value = value.lower().strip().replace("-", "_")
|
||||
for mode in cls:
|
||||
if mode.value == value:
|
||||
return mode
|
||||
raise ValueError(f"Unknown sandbox mode: {value}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Data Classes
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class SandboxPolicy:
|
||||
"""
|
||||
Security policy for sandbox execution.
|
||||
"""
|
||||
mode: SandboxMode = SandboxMode.DISABLED
|
||||
|
||||
# Resource limits
|
||||
max_memory_mb: int = 512
|
||||
max_cpu_seconds: int = 30
|
||||
max_file_size_mb: int = 10
|
||||
max_processes: int = 10
|
||||
|
||||
# Filesystem restrictions
|
||||
allowed_paths: Set[str] = field(default_factory=set)
|
||||
blocked_paths: Set[str] = field(default_factory=lambda: {
|
||||
"/etc", "/var", "/usr", "/bin", "/sbin",
|
||||
"/root", "/home", "/sys", "/proc"
|
||||
})
|
||||
temp_dir: Optional[str] = None
|
||||
|
||||
# Network restrictions
|
||||
allow_network: bool = True
|
||||
allowed_hosts: Set[str] = field(default_factory=set)
|
||||
|
||||
# Command restrictions
|
||||
blocked_commands: Set[str] = field(default_factory=lambda: {
|
||||
"rm", "rmdir", "mv", "dd", "mkfs", "fdisk",
|
||||
"sudo", "su", "chmod", "chown", "kill", "pkill"
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def for_mode(cls, mode: SandboxMode) -> "SandboxPolicy":
|
||||
"""Create policy for a given mode."""
|
||||
if mode == SandboxMode.DISABLED:
|
||||
return cls(mode=mode)
|
||||
|
||||
elif mode == SandboxMode.BASIC:
|
||||
return cls(
|
||||
mode=mode,
|
||||
max_memory_mb=512,
|
||||
max_cpu_seconds=60,
|
||||
allow_network=True
|
||||
)
|
||||
|
||||
elif mode == SandboxMode.STRICT:
|
||||
return cls(
|
||||
mode=mode,
|
||||
max_memory_mb=256,
|
||||
max_cpu_seconds=30,
|
||||
max_processes=5,
|
||||
allow_network=True,
|
||||
blocked_commands={
|
||||
"rm", "rmdir", "mv", "dd", "mkfs", "fdisk",
|
||||
"sudo", "su", "chmod", "chown", "kill", "pkill",
|
||||
"curl", "wget", "nc", "netcat", "ssh", "scp"
|
||||
}
|
||||
)
|
||||
|
||||
elif mode == SandboxMode.NETWORK_ISOLATED:
|
||||
return cls(
|
||||
mode=mode,
|
||||
max_memory_mb=512,
|
||||
max_cpu_seconds=60,
|
||||
allow_network=False
|
||||
)
|
||||
|
||||
return cls(mode=mode)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionResult:
|
||||
"""Result of sandboxed execution."""
|
||||
success: bool
|
||||
exit_code: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
duration_ms: float
|
||||
was_sandboxed: bool
|
||||
policy_violations: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def output(self) -> str:
|
||||
"""Get combined output."""
|
||||
return self.stdout + self.stderr
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Command Validator
|
||||
# ============================================================================
|
||||
|
||||
class CommandValidator:
|
||||
"""
|
||||
Validates commands against sandbox policy.
|
||||
"""
|
||||
|
||||
def __init__(self, policy: SandboxPolicy):
|
||||
self.policy = policy
|
||||
|
||||
def validate(self, command: str) -> List[str]:
|
||||
"""
|
||||
Validate a command against the policy.
|
||||
|
||||
Returns:
|
||||
List of policy violations (empty if valid)
|
||||
"""
|
||||
violations = []
|
||||
|
||||
if self.policy.mode == SandboxMode.DISABLED:
|
||||
return violations
|
||||
|
||||
# Parse command
|
||||
parts = command.split()
|
||||
if not parts:
|
||||
return violations
|
||||
|
||||
cmd_name = Path(parts[0]).name
|
||||
|
||||
# Check blocked commands
|
||||
if cmd_name in self.policy.blocked_commands:
|
||||
violations.append(f"Command '{cmd_name}' is blocked by sandbox policy")
|
||||
|
||||
# Check for dangerous patterns
|
||||
dangerous_patterns = [
|
||||
("rm -rf", "Recursive force delete is blocked"),
|
||||
("> /dev/", "Writing to device files is blocked"),
|
||||
("| sh", "Piping to shell is blocked"),
|
||||
("| bash", "Piping to bash is blocked"),
|
||||
("$(", "Command substitution is blocked"),
|
||||
("`", "Backtick command substitution is blocked"),
|
||||
]
|
||||
|
||||
for pattern, message in dangerous_patterns:
|
||||
if pattern in command:
|
||||
violations.append(message)
|
||||
|
||||
# Check path access
|
||||
for part in parts:
|
||||
if part.startswith("/"):
|
||||
for blocked in self.policy.blocked_paths:
|
||||
if part.startswith(blocked):
|
||||
violations.append(f"Access to '{blocked}' is blocked")
|
||||
break
|
||||
|
||||
return violations
|
||||
|
||||
def is_allowed(self, command: str) -> bool:
|
||||
"""Check if command is allowed."""
|
||||
return len(self.validate(command)) == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Subprocess Sandbox
|
||||
# ============================================================================
|
||||
|
||||
class SubprocessSandbox:
|
||||
"""
|
||||
Subprocess-based sandbox execution.
|
||||
|
||||
Provides basic isolation through:
|
||||
- Resource limits (timeout, memory via ulimit)
|
||||
- Working directory isolation
|
||||
- Environment variable control
|
||||
- Command validation
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
policy: Optional[SandboxPolicy] = None,
|
||||
working_dir: Optional[str] = None,
|
||||
verbose: bool = False
|
||||
):
|
||||
self.policy = policy or SandboxPolicy()
|
||||
self.working_dir = Path(working_dir) if working_dir else Path.cwd()
|
||||
self.verbose = verbose
|
||||
self.validator = CommandValidator(self.policy)
|
||||
|
||||
self._temp_dir: Optional[Path] = None
|
||||
|
||||
def _setup_temp_dir(self) -> Path:
|
||||
"""Set up temporary directory for isolated execution."""
|
||||
if self._temp_dir is None:
|
||||
self._temp_dir = Path(tempfile.mkdtemp(prefix="praisonai_sandbox_"))
|
||||
return self._temp_dir
|
||||
|
||||
def _cleanup_temp_dir(self) -> None:
|
||||
"""Clean up temporary directory."""
|
||||
if self._temp_dir and self._temp_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(self._temp_dir)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to cleanup temp dir: {e}")
|
||||
self._temp_dir = None
|
||||
|
||||
def _build_environment(self) -> Dict[str, str]:
|
||||
"""Build restricted environment variables."""
|
||||
import os
|
||||
|
||||
# Start with minimal environment
|
||||
env = {
|
||||
"PATH": "/usr/local/bin:/usr/bin:/bin",
|
||||
"HOME": str(self._temp_dir or self.working_dir),
|
||||
"TERM": "xterm",
|
||||
"LANG": "en_US.UTF-8",
|
||||
}
|
||||
|
||||
# Add some safe variables from current environment
|
||||
safe_vars = ["USER", "SHELL", "PYTHONPATH"]
|
||||
for var in safe_vars:
|
||||
if var in os.environ:
|
||||
env[var] = os.environ[var]
|
||||
|
||||
return env
|
||||
|
||||
def execute(
|
||||
self,
|
||||
command: str,
|
||||
timeout: Optional[float] = None,
|
||||
capture_output: bool = True
|
||||
) -> ExecutionResult:
|
||||
"""
|
||||
Execute a command in the sandbox.
|
||||
|
||||
Args:
|
||||
command: Command to execute
|
||||
timeout: Timeout in seconds (overrides policy)
|
||||
capture_output: Whether to capture stdout/stderr
|
||||
|
||||
Returns:
|
||||
ExecutionResult with execution details
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Check if sandbox is enabled
|
||||
if self.policy.mode == SandboxMode.DISABLED:
|
||||
return self._execute_unsandboxed(command, timeout, capture_output)
|
||||
|
||||
# Validate command
|
||||
violations = self.validator.validate(command)
|
||||
if violations:
|
||||
return ExecutionResult(
|
||||
success=False,
|
||||
exit_code=-1,
|
||||
stdout="",
|
||||
stderr="Command blocked by sandbox policy:\n" + "\n".join(violations),
|
||||
duration_ms=(time.time() - start_time) * 1000,
|
||||
was_sandboxed=True,
|
||||
policy_violations=violations
|
||||
)
|
||||
|
||||
# Set up sandbox
|
||||
if self.policy.mode == SandboxMode.STRICT:
|
||||
self._setup_temp_dir()
|
||||
cwd = self._temp_dir
|
||||
else:
|
||||
cwd = self.working_dir
|
||||
|
||||
# Build environment
|
||||
env = self._build_environment()
|
||||
|
||||
# Set timeout
|
||||
if timeout is None:
|
||||
timeout = float(self.policy.max_cpu_seconds)
|
||||
|
||||
# Execute
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
return ExecutionResult(
|
||||
success=result.returncode == 0,
|
||||
exit_code=result.returncode,
|
||||
stdout=result.stdout if capture_output else "",
|
||||
stderr=result.stderr if capture_output else "",
|
||||
duration_ms=(time.time() - start_time) * 1000,
|
||||
was_sandboxed=True
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return ExecutionResult(
|
||||
success=False,
|
||||
exit_code=-1,
|
||||
stdout="",
|
||||
stderr=f"Command timed out after {timeout}s",
|
||||
duration_ms=(time.time() - start_time) * 1000,
|
||||
was_sandboxed=True,
|
||||
policy_violations=["Timeout exceeded"]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return ExecutionResult(
|
||||
success=False,
|
||||
exit_code=-1,
|
||||
stdout="",
|
||||
stderr=str(e),
|
||||
duration_ms=(time.time() - start_time) * 1000,
|
||||
was_sandboxed=True,
|
||||
policy_violations=[str(e)]
|
||||
)
|
||||
|
||||
finally:
|
||||
if self.policy.mode == SandboxMode.STRICT:
|
||||
self._cleanup_temp_dir()
|
||||
|
||||
def _execute_unsandboxed(
|
||||
self,
|
||||
command: str,
|
||||
timeout: Optional[float],
|
||||
capture_output: bool
|
||||
) -> ExecutionResult:
|
||||
"""Execute without sandbox (when disabled)."""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=self.working_dir,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
return ExecutionResult(
|
||||
success=result.returncode == 0,
|
||||
exit_code=result.returncode,
|
||||
stdout=result.stdout if capture_output else "",
|
||||
stderr=result.stderr if capture_output else "",
|
||||
duration_ms=(time.time() - start_time) * 1000,
|
||||
was_sandboxed=False
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return ExecutionResult(
|
||||
success=False,
|
||||
exit_code=-1,
|
||||
stdout="",
|
||||
stderr=f"Command timed out after {timeout}s",
|
||||
duration_ms=(time.time() - start_time) * 1000,
|
||||
was_sandboxed=False
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return ExecutionResult(
|
||||
success=False,
|
||||
exit_code=-1,
|
||||
stdout="",
|
||||
stderr=str(e),
|
||||
duration_ms=(time.time() - start_time) * 1000,
|
||||
was_sandboxed=False
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CLI Integration Handler
|
||||
# ============================================================================
|
||||
|
||||
class SandboxExecutorHandler:
|
||||
"""
|
||||
Handler for integrating Sandbox Execution with PraisonAI CLI.
|
||||
|
||||
Note: Sandbox is ONLY activated when --sandbox flag is passed.
|
||||
"""
|
||||
|
||||
def __init__(self, verbose: bool = False):
|
||||
self.verbose = verbose
|
||||
self._sandbox: Optional[SubprocessSandbox] = None
|
||||
self._enabled: bool = False
|
||||
|
||||
@property
|
||||
def feature_name(self) -> str:
|
||||
return "sandbox_executor"
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
"""Check if sandbox is enabled."""
|
||||
return self._enabled
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
mode: str = "disabled",
|
||||
working_dir: Optional[str] = None,
|
||||
policy: Optional[SandboxPolicy] = None
|
||||
) -> SubprocessSandbox:
|
||||
"""
|
||||
Initialize the sandbox.
|
||||
|
||||
Args:
|
||||
mode: Sandbox mode (disabled, basic, strict, network_isolated)
|
||||
working_dir: Working directory for execution
|
||||
policy: Optional custom policy
|
||||
|
||||
Returns:
|
||||
Configured SubprocessSandbox
|
||||
"""
|
||||
try:
|
||||
sandbox_mode = SandboxMode.from_string(mode)
|
||||
except ValueError:
|
||||
logger.warning(f"Unknown sandbox mode '{mode}', defaulting to 'disabled'")
|
||||
sandbox_mode = SandboxMode.DISABLED
|
||||
|
||||
if policy is None:
|
||||
policy = SandboxPolicy.for_mode(sandbox_mode)
|
||||
|
||||
self._sandbox = SubprocessSandbox(
|
||||
policy=policy,
|
||||
working_dir=working_dir,
|
||||
verbose=self.verbose
|
||||
)
|
||||
|
||||
self._enabled = sandbox_mode != SandboxMode.DISABLED
|
||||
|
||||
if self.verbose and self._enabled:
|
||||
from rich import print as rprint
|
||||
rprint(f"[yellow]⚠ Sandbox enabled: {sandbox_mode.value}[/yellow]")
|
||||
|
||||
return self._sandbox
|
||||
|
||||
def get_sandbox(self) -> Optional[SubprocessSandbox]:
|
||||
"""Get the sandbox executor."""
|
||||
return self._sandbox
|
||||
|
||||
def execute(
|
||||
self,
|
||||
command: str,
|
||||
timeout: Optional[float] = None
|
||||
) -> ExecutionResult:
|
||||
"""
|
||||
Execute a command.
|
||||
|
||||
If sandbox is not initialized, runs without sandboxing.
|
||||
"""
|
||||
if not self._sandbox:
|
||||
self._sandbox = self.initialize()
|
||||
|
||||
return self._sandbox.execute(command, timeout=timeout)
|
||||
|
||||
def validate_command(self, command: str) -> List[str]:
|
||||
"""Validate a command against sandbox policy."""
|
||||
if not self._sandbox:
|
||||
return []
|
||||
|
||||
return self._sandbox.validator.validate(command)
|
||||
|
||||
def get_mode(self) -> str:
|
||||
"""Get current sandbox mode."""
|
||||
if self._sandbox:
|
||||
return self._sandbox.policy.mode.value
|
||||
return SandboxMode.DISABLED.value
|
||||
@@ -0,0 +1,650 @@
|
||||
"""
|
||||
Slash Commands System for PraisonAI CLI.
|
||||
|
||||
Inspired by Aider's command system and Gemini CLI's slash commands.
|
||||
Provides interactive commands like /help, /cost, /plan, /model, /clear, etc.
|
||||
|
||||
Architecture:
|
||||
- SlashCommand: Base class for all commands
|
||||
- SlashCommandRegistry: Manages command registration and lookup
|
||||
- SlashCommandParser: Parses user input for slash commands
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from enum import Enum
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CommandKind(Enum):
|
||||
"""Type of slash command."""
|
||||
BUILT_IN = "built_in"
|
||||
CUSTOM = "custom"
|
||||
MCP = "mcp"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlashCommand:
|
||||
"""
|
||||
Represents a slash command.
|
||||
|
||||
Attributes:
|
||||
name: Primary command name (e.g., "help", "cost")
|
||||
description: Short description for help text
|
||||
action: Callable that executes the command
|
||||
alt_names: Alternative names/aliases
|
||||
sub_commands: Nested sub-commands
|
||||
kind: Type of command (built-in, custom, MCP)
|
||||
auto_execute: Whether to execute without confirmation
|
||||
"""
|
||||
name: str
|
||||
description: str
|
||||
action: Optional[Callable[["CommandContext", str], Any]] = None
|
||||
alt_names: List[str] = field(default_factory=list)
|
||||
sub_commands: List["SlashCommand"] = field(default_factory=list)
|
||||
kind: CommandKind = CommandKind.BUILT_IN
|
||||
auto_execute: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate command after initialization."""
|
||||
if not self.name:
|
||||
raise ValueError("Command name cannot be empty")
|
||||
if not self.name.isalnum() and "_" not in self.name and "-" not in self.name:
|
||||
raise ValueError(f"Invalid command name: {self.name}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandContext:
|
||||
"""
|
||||
Context passed to command actions.
|
||||
|
||||
Contains all services and state needed by commands.
|
||||
"""
|
||||
# Core services
|
||||
agent: Any = None # PraisonAI Agent instance
|
||||
config: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Session state
|
||||
session_id: Optional[str] = None
|
||||
session_start_time: Optional[float] = None
|
||||
|
||||
# Metrics
|
||||
total_tokens: int = 0
|
||||
total_cost: float = 0.0
|
||||
prompt_count: int = 0
|
||||
|
||||
# UI callbacks
|
||||
print_fn: Callable[[str], None] = print
|
||||
input_fn: Callable[[str], str] = input
|
||||
|
||||
# Command invocation info
|
||||
raw_input: str = ""
|
||||
command_name: str = ""
|
||||
args: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedSlashCommand:
|
||||
"""Result of parsing a slash command."""
|
||||
command: Optional[SlashCommand] = None
|
||||
args: str = ""
|
||||
canonical_path: List[str] = field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class SlashCommandRegistry:
|
||||
"""
|
||||
Registry for slash commands.
|
||||
|
||||
Manages command registration, lookup, and help generation.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._commands: Dict[str, SlashCommand] = {}
|
||||
self._aliases: Dict[str, str] = {} # alias -> primary name
|
||||
|
||||
def register(self, command: SlashCommand) -> None:
|
||||
"""Register a slash command."""
|
||||
if command.name in self._commands:
|
||||
logger.warning(f"Overwriting existing command: {command.name}")
|
||||
|
||||
self._commands[command.name] = command
|
||||
|
||||
# Register aliases
|
||||
for alias in command.alt_names:
|
||||
if alias in self._aliases:
|
||||
logger.warning(f"Alias '{alias}' already registered")
|
||||
self._aliases[alias] = command.name
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""Unregister a command by name."""
|
||||
if name in self._commands:
|
||||
cmd = self._commands.pop(name)
|
||||
# Remove aliases
|
||||
for alias in cmd.alt_names:
|
||||
self._aliases.pop(alias, None)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get(self, name: str) -> Optional[SlashCommand]:
|
||||
"""Get a command by name or alias."""
|
||||
# Check primary name first
|
||||
if name in self._commands:
|
||||
return self._commands[name]
|
||||
# Check aliases
|
||||
if name in self._aliases:
|
||||
return self._commands.get(self._aliases[name])
|
||||
return None
|
||||
|
||||
def get_all(self) -> List[SlashCommand]:
|
||||
"""Get all registered commands."""
|
||||
return list(self._commands.values())
|
||||
|
||||
def get_names(self) -> List[str]:
|
||||
"""Get all command names (including aliases)."""
|
||||
names = list(self._commands.keys())
|
||||
names.extend(self._aliases.keys())
|
||||
return sorted(set(names))
|
||||
|
||||
|
||||
class SlashCommandParser:
|
||||
"""
|
||||
Parser for slash commands.
|
||||
|
||||
Handles parsing of user input into command and arguments.
|
||||
"""
|
||||
|
||||
def __init__(self, registry: SlashCommandRegistry):
|
||||
self.registry = registry
|
||||
|
||||
def is_slash_command(self, query: str) -> bool:
|
||||
"""Check if input is a slash command."""
|
||||
query = query.strip()
|
||||
if not query.startswith('/'):
|
||||
return False
|
||||
# Exclude code comments
|
||||
if query.startswith('//') or query.startswith('/*'):
|
||||
return False
|
||||
return True
|
||||
|
||||
def parse(self, query: str) -> ParsedSlashCommand:
|
||||
"""
|
||||
Parse a slash command string.
|
||||
|
||||
Args:
|
||||
query: Raw input string (e.g., "/help model" or "/cost")
|
||||
|
||||
Returns:
|
||||
ParsedSlashCommand with resolved command and arguments
|
||||
"""
|
||||
if not self.is_slash_command(query):
|
||||
return ParsedSlashCommand(error="Not a slash command")
|
||||
|
||||
trimmed = query.strip()
|
||||
parts = trimmed[1:].strip().split() # Remove leading /
|
||||
|
||||
if not parts:
|
||||
return ParsedSlashCommand(error="Empty command")
|
||||
|
||||
command_path = parts
|
||||
canonical_path: List[str] = []
|
||||
current_commands = self.registry.get_all()
|
||||
command_to_execute: Optional[SlashCommand] = None
|
||||
path_index = 0
|
||||
|
||||
for part in command_path:
|
||||
# First pass: exact match on primary name
|
||||
found_command = None
|
||||
for cmd in current_commands:
|
||||
if cmd.name == part:
|
||||
found_command = cmd
|
||||
break
|
||||
|
||||
# Second pass: check aliases
|
||||
if not found_command:
|
||||
for cmd in current_commands:
|
||||
if part in cmd.alt_names:
|
||||
found_command = cmd
|
||||
break
|
||||
|
||||
if found_command:
|
||||
command_to_execute = found_command
|
||||
canonical_path.append(found_command.name)
|
||||
path_index += 1
|
||||
if found_command.sub_commands:
|
||||
current_commands = found_command.sub_commands
|
||||
else:
|
||||
break
|
||||
else:
|
||||
break
|
||||
|
||||
args = ' '.join(parts[path_index:])
|
||||
|
||||
return ParsedSlashCommand(
|
||||
command=command_to_execute,
|
||||
args=args,
|
||||
canonical_path=canonical_path
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Built-in Commands
|
||||
# ============================================================================
|
||||
|
||||
def cmd_help(context: CommandContext, args: str) -> Dict[str, Any]:
|
||||
"""Show help for commands."""
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
if args:
|
||||
# Help for specific command
|
||||
registry = context.config.get("command_registry")
|
||||
if registry:
|
||||
cmd = registry.get(args)
|
||||
if cmd:
|
||||
console.print(f"\n[bold cyan]/{cmd.name}[/bold cyan]")
|
||||
console.print(f" {cmd.description}")
|
||||
if cmd.alt_names:
|
||||
console.print(f" Aliases: {', '.join(cmd.alt_names)}")
|
||||
if cmd.sub_commands:
|
||||
console.print("\n Sub-commands:")
|
||||
for sub in cmd.sub_commands:
|
||||
console.print(f" /{cmd.name} {sub.name} - {sub.description}")
|
||||
return {"type": "help", "command": cmd.name}
|
||||
else:
|
||||
console.print(f"[red]Unknown command: {args}[/red]")
|
||||
return {"type": "error", "message": f"Unknown command: {args}"}
|
||||
|
||||
# General help
|
||||
table = Table(title="Available Commands", show_header=True)
|
||||
table.add_column("Command", style="cyan")
|
||||
table.add_column("Description")
|
||||
|
||||
registry = context.config.get("command_registry")
|
||||
if registry:
|
||||
for cmd in registry.get_all():
|
||||
aliases = f" ({', '.join(cmd.alt_names)})" if cmd.alt_names else ""
|
||||
table.add_row(f"/{cmd.name}{aliases}", cmd.description)
|
||||
|
||||
console.print(table)
|
||||
return {"type": "help"}
|
||||
|
||||
|
||||
def cmd_cost(context: CommandContext, args: str) -> Dict[str, Any]:
|
||||
"""Show session cost and token usage."""
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
console = Console()
|
||||
|
||||
cost_info = f"""
|
||||
[bold]Session Statistics[/bold]
|
||||
|
||||
Total Tokens: {context.total_tokens:,}
|
||||
Total Cost: ${context.total_cost:.4f}
|
||||
Prompts: {context.prompt_count}
|
||||
"""
|
||||
|
||||
if context.session_start_time:
|
||||
import time
|
||||
duration = time.time() - context.session_start_time
|
||||
minutes = int(duration // 60)
|
||||
seconds = int(duration % 60)
|
||||
cost_info += f"Duration: {minutes}m {seconds}s\n"
|
||||
|
||||
console.print(Panel(cost_info, title="💰 Cost Tracker", border_style="green"))
|
||||
|
||||
return {
|
||||
"type": "stats",
|
||||
"tokens": context.total_tokens,
|
||||
"cost": context.total_cost,
|
||||
"prompts": context.prompt_count
|
||||
}
|
||||
|
||||
|
||||
def cmd_clear(context: CommandContext, args: str) -> Dict[str, Any]:
|
||||
"""Clear the conversation history."""
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
|
||||
# Reset agent conversation if available
|
||||
if context.agent and hasattr(context.agent, 'clear_history'):
|
||||
context.agent.clear_history()
|
||||
|
||||
console.print("[green]✓ Conversation cleared[/green]")
|
||||
return {"type": "clear"}
|
||||
|
||||
|
||||
def cmd_model(context: CommandContext, args: str) -> Dict[str, Any]:
|
||||
"""Show or change the current model."""
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
|
||||
if args:
|
||||
# Change model
|
||||
if context.agent and hasattr(context.agent, 'set_model'):
|
||||
context.agent.set_model(args)
|
||||
console.print(f"[green]✓ Model changed to: {args}[/green]")
|
||||
return {"type": "model_change", "model": args}
|
||||
else:
|
||||
console.print(f"[yellow]Model change requested: {args}[/yellow]")
|
||||
return {"type": "model_change", "model": args}
|
||||
else:
|
||||
# Show current model
|
||||
current_model = "unknown"
|
||||
if context.agent and hasattr(context.agent, 'llm'):
|
||||
current_model = getattr(context.agent.llm, 'model', 'unknown')
|
||||
console.print(f"[cyan]Current model: {current_model}[/cyan]")
|
||||
return {"type": "model_info", "model": current_model}
|
||||
|
||||
|
||||
def cmd_tokens(context: CommandContext, args: str) -> Dict[str, Any]:
|
||||
"""Show token usage breakdown."""
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
table = Table(title="Token Usage", show_header=True)
|
||||
table.add_column("Metric", style="cyan")
|
||||
table.add_column("Value", justify="right")
|
||||
|
||||
table.add_row("Total Tokens", f"{context.total_tokens:,}")
|
||||
table.add_row("Prompts Sent", str(context.prompt_count))
|
||||
if context.prompt_count > 0:
|
||||
avg = context.total_tokens / context.prompt_count
|
||||
table.add_row("Avg Tokens/Prompt", f"{avg:.0f}")
|
||||
|
||||
console.print(table)
|
||||
return {"type": "tokens", "total": context.total_tokens}
|
||||
|
||||
|
||||
def cmd_plan(context: CommandContext, args: str) -> Dict[str, Any]:
|
||||
"""Show or create an execution plan."""
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
|
||||
if args:
|
||||
console.print(f"[cyan]Creating plan for: {args}[/cyan]")
|
||||
# Trigger planning mode
|
||||
return {"type": "submit_prompt", "content": f"Create a detailed plan for: {args}"}
|
||||
else:
|
||||
console.print("[yellow]Usage: /plan <task description>[/yellow]")
|
||||
return {"type": "help", "command": "plan"}
|
||||
|
||||
|
||||
def cmd_undo(context: CommandContext, args: str) -> Dict[str, Any]:
|
||||
"""Undo the last change."""
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
|
||||
# Check for git integration
|
||||
try:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--stat", "HEAD~1"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd="."
|
||||
)
|
||||
if result.returncode == 0:
|
||||
console.print("[yellow]Last commit changes:[/yellow]")
|
||||
console.print(result.stdout)
|
||||
console.print("\n[cyan]Run 'git reset --soft HEAD~1' to undo[/cyan]")
|
||||
else:
|
||||
console.print("[red]No git history available[/red]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
|
||||
return {"type": "undo"}
|
||||
|
||||
|
||||
def cmd_diff(context: CommandContext, args: str) -> Dict[str, Any]:
|
||||
"""Show git diff of changes."""
|
||||
from rich.console import Console
|
||||
from rich.syntax import Syntax
|
||||
|
||||
console = Console()
|
||||
|
||||
try:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["git", "diff"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd="."
|
||||
)
|
||||
if result.stdout:
|
||||
syntax = Syntax(result.stdout, "diff", theme="monokai")
|
||||
console.print(syntax)
|
||||
else:
|
||||
console.print("[green]No uncommitted changes[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
|
||||
return {"type": "diff"}
|
||||
|
||||
|
||||
def cmd_commit(context: CommandContext, args: str) -> Dict[str, Any]:
|
||||
"""Commit changes with AI-generated message."""
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
|
||||
console.print("[cyan]Generating commit message...[/cyan]")
|
||||
|
||||
# This will trigger the commit command handler
|
||||
return {"type": "commit", "message": args if args else None}
|
||||
|
||||
|
||||
def cmd_exit(context: CommandContext, args: str) -> Dict[str, Any]:
|
||||
"""Exit the CLI."""
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
|
||||
console.print("[yellow]Goodbye![/yellow]")
|
||||
return {"type": "exit"}
|
||||
|
||||
|
||||
def cmd_settings(context: CommandContext, args: str) -> Dict[str, Any]:
|
||||
"""Show current settings."""
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
table = Table(title="Current Settings", show_header=True)
|
||||
table.add_column("Setting", style="cyan")
|
||||
table.add_column("Value")
|
||||
|
||||
for key, value in context.config.items():
|
||||
if key != "command_registry": # Skip internal objects
|
||||
table.add_row(key, str(value))
|
||||
|
||||
console.print(table)
|
||||
return {"type": "settings"}
|
||||
|
||||
|
||||
def cmd_map(context: CommandContext, args: str) -> Dict[str, Any]:
|
||||
"""Show repository map."""
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
|
||||
console.print("[cyan]Generating repository map...[/cyan]")
|
||||
# This will be implemented with RepoMap feature
|
||||
return {"type": "map"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Registry Setup
|
||||
# ============================================================================
|
||||
|
||||
def create_default_registry() -> SlashCommandRegistry:
|
||||
"""Create registry with default built-in commands."""
|
||||
registry = SlashCommandRegistry()
|
||||
|
||||
# Core commands
|
||||
registry.register(SlashCommand(
|
||||
name="help",
|
||||
description="Show help for commands",
|
||||
action=cmd_help,
|
||||
alt_names=["h", "?"]
|
||||
))
|
||||
|
||||
registry.register(SlashCommand(
|
||||
name="cost",
|
||||
description="Show session cost and token usage",
|
||||
action=cmd_cost,
|
||||
alt_names=["usage", "stats"]
|
||||
))
|
||||
|
||||
registry.register(SlashCommand(
|
||||
name="clear",
|
||||
description="Clear conversation history",
|
||||
action=cmd_clear,
|
||||
alt_names=["reset"]
|
||||
))
|
||||
|
||||
registry.register(SlashCommand(
|
||||
name="model",
|
||||
description="Show or change the current model",
|
||||
action=cmd_model,
|
||||
alt_names=["m"]
|
||||
))
|
||||
|
||||
registry.register(SlashCommand(
|
||||
name="tokens",
|
||||
description="Show token usage breakdown",
|
||||
action=cmd_tokens
|
||||
))
|
||||
|
||||
registry.register(SlashCommand(
|
||||
name="plan",
|
||||
description="Create an execution plan for a task",
|
||||
action=cmd_plan
|
||||
))
|
||||
|
||||
registry.register(SlashCommand(
|
||||
name="undo",
|
||||
description="Undo the last change",
|
||||
action=cmd_undo
|
||||
))
|
||||
|
||||
registry.register(SlashCommand(
|
||||
name="diff",
|
||||
description="Show git diff of changes",
|
||||
action=cmd_diff
|
||||
))
|
||||
|
||||
registry.register(SlashCommand(
|
||||
name="commit",
|
||||
description="Commit changes with AI-generated message",
|
||||
action=cmd_commit
|
||||
))
|
||||
|
||||
registry.register(SlashCommand(
|
||||
name="exit",
|
||||
description="Exit the CLI",
|
||||
action=cmd_exit,
|
||||
alt_names=["quit", "q"]
|
||||
))
|
||||
|
||||
registry.register(SlashCommand(
|
||||
name="settings",
|
||||
description="Show current settings",
|
||||
action=cmd_settings
|
||||
))
|
||||
|
||||
registry.register(SlashCommand(
|
||||
name="map",
|
||||
description="Show repository map",
|
||||
action=cmd_map,
|
||||
alt_names=["repo"]
|
||||
))
|
||||
|
||||
return registry
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Handler for CLI Integration
|
||||
# ============================================================================
|
||||
|
||||
class SlashCommandHandler:
|
||||
"""
|
||||
Handler for integrating slash commands with PraisonAI CLI.
|
||||
"""
|
||||
|
||||
def __init__(self, verbose: bool = False):
|
||||
self.verbose = verbose
|
||||
self.registry = create_default_registry()
|
||||
self.parser = SlashCommandParser(self.registry)
|
||||
self._context: Optional[CommandContext] = None
|
||||
|
||||
def set_context(self, context: CommandContext) -> None:
|
||||
"""Set the command context."""
|
||||
self._context = context
|
||||
context.config["command_registry"] = self.registry
|
||||
|
||||
def is_command(self, query: str) -> bool:
|
||||
"""Check if input is a slash command."""
|
||||
return self.parser.is_slash_command(query)
|
||||
|
||||
def execute(self, query: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Execute a slash command.
|
||||
|
||||
Returns:
|
||||
Command result dict or None if not a command
|
||||
"""
|
||||
if not self.is_command(query):
|
||||
return None
|
||||
|
||||
parsed = self.parser.parse(query)
|
||||
|
||||
if parsed.error:
|
||||
if self.verbose:
|
||||
print(f"[red]Error: {parsed.error}[/red]")
|
||||
return {"type": "error", "message": parsed.error}
|
||||
|
||||
if not parsed.command:
|
||||
return {"type": "error", "message": "Unknown command"}
|
||||
|
||||
if not parsed.command.action:
|
||||
return {"type": "error", "message": f"Command '{parsed.command.name}' has no action"}
|
||||
|
||||
# Create context if not set
|
||||
if not self._context:
|
||||
self._context = CommandContext()
|
||||
self._context.config["command_registry"] = self.registry
|
||||
|
||||
self._context.raw_input = query
|
||||
self._context.command_name = parsed.command.name
|
||||
self._context.args = parsed.args
|
||||
|
||||
try:
|
||||
result = parsed.command.action(self._context, parsed.args)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing command: {e}")
|
||||
return {"type": "error", "message": str(e)}
|
||||
|
||||
def register_command(self, command: SlashCommand) -> None:
|
||||
"""Register a custom command."""
|
||||
self.registry.register(command)
|
||||
|
||||
def get_completions(self, partial: str) -> List[str]:
|
||||
"""Get command completions for partial input."""
|
||||
if not partial.startswith('/'):
|
||||
return []
|
||||
|
||||
partial_cmd = partial[1:].lower()
|
||||
completions = []
|
||||
|
||||
for name in self.registry.get_names():
|
||||
if name.lower().startswith(partial_cmd):
|
||||
completions.append(f"/{name}")
|
||||
|
||||
return sorted(completions)
|
||||
@@ -199,6 +199,73 @@ steps:
|
||||
repeat:
|
||||
until: "approved"
|
||||
max_iterations: 3
|
||||
""",
|
||||
"model-routing": """# Model Routing Workflow
|
||||
name: Cost-Optimized Model Routing Workflow
|
||||
description: Uses custom models with automatic routing based on task complexity
|
||||
|
||||
# Custom models configuration - define your own models with costs and capabilities
|
||||
models:
|
||||
cheap-fast:
|
||||
provider: openai
|
||||
complexity: [simple]
|
||||
cost_per_1k: 0.0001
|
||||
capabilities: [text]
|
||||
context_window: 16000
|
||||
|
||||
balanced:
|
||||
provider: openai
|
||||
complexity: [moderate]
|
||||
cost_per_1k: 0.001
|
||||
capabilities: [text, function-calling]
|
||||
context_window: 128000
|
||||
|
||||
premium:
|
||||
provider: anthropic
|
||||
complexity: [complex, very_complex]
|
||||
cost_per_1k: 0.015
|
||||
capabilities: [text, vision, function-calling]
|
||||
context_window: 200000
|
||||
strengths: [reasoning, analysis, code-generation]
|
||||
|
||||
workflow:
|
||||
verbose: true
|
||||
router: true # Enable model routing
|
||||
routing_strategy: cost-optimized # Options: auto, cost-optimized, performance-optimized
|
||||
|
||||
agents:
|
||||
classifier:
|
||||
name: Classifier
|
||||
role: Request Classifier
|
||||
goal: Classify incoming requests by complexity
|
||||
instructions: "Classify the request as 'simple', 'moderate', or 'complex'."
|
||||
llm: cheap-fast # Always use cheap model for classification
|
||||
|
||||
researcher:
|
||||
name: Researcher
|
||||
role: Research Analyst
|
||||
goal: Research topics thoroughly
|
||||
instructions: "Research the topic and provide detailed findings."
|
||||
llm_routing: auto # Auto-select based on task complexity
|
||||
llm_models: [balanced, premium] # Models to choose from
|
||||
|
||||
writer:
|
||||
name: Writer
|
||||
role: Content Writer
|
||||
goal: Write high-quality content
|
||||
instructions: "Write clear, engaging content based on research."
|
||||
llm: premium # Always use premium for quality writing
|
||||
|
||||
steps:
|
||||
- agent: classifier
|
||||
action: "Classify complexity of: {{input}}"
|
||||
|
||||
- name: routing
|
||||
route:
|
||||
simple: [researcher]
|
||||
moderate: [researcher]
|
||||
complex: [researcher, writer]
|
||||
default: [researcher]
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -224,7 +291,7 @@ Workflow Commands:
|
||||
praisonai workflow create --template <name> - Create from template
|
||||
praisonai workflow auto "topic" --pattern <pattern> - Auto-generate workflow
|
||||
|
||||
Templates: simple, routing, parallel, loop, evaluator-optimizer
|
||||
Templates: simple, routing, parallel, loop, evaluator-optimizer, model-routing
|
||||
Patterns: sequential, routing, parallel
|
||||
|
||||
Example:
|
||||
@@ -610,7 +677,7 @@ Example:
|
||||
return None
|
||||
|
||||
# Validate pattern
|
||||
valid_patterns = ["sequential", "routing", "parallel", "loop"]
|
||||
valid_patterns = ["sequential", "routing", "parallel", "loop", "orchestrator-workers", "evaluator-optimizer"]
|
||||
if pattern not in valid_patterns:
|
||||
self.print_status(f"Unknown pattern: {pattern}", "error")
|
||||
self.print_status(f"Valid patterns: {', '.join(valid_patterns)}", "info")
|
||||
|
||||
@@ -2002,7 +2002,7 @@ class PraisonAI:
|
||||
return
|
||||
|
||||
# Validate pattern
|
||||
valid_patterns = ["sequential", "routing", "parallel", "loop"]
|
||||
valid_patterns = ["sequential", "routing", "parallel", "loop", "orchestrator-workers", "evaluator-optimizer"]
|
||||
if pattern not in valid_patterns:
|
||||
print(f"[red]Unknown pattern: {pattern}[/red]")
|
||||
print(f"[cyan]Valid patterns: {', '.join(valid_patterns)}[/cyan]")
|
||||
|
||||
@@ -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.3.40 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==2.3.45 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
PraisonAI Integrations - External CLI tool integrations.
|
||||
|
||||
This module provides integrations with external AI coding CLI tools:
|
||||
- Claude Code CLI
|
||||
- Gemini CLI
|
||||
- OpenAI Codex CLI
|
||||
- Cursor CLI
|
||||
|
||||
All integrations use lazy loading to avoid performance impact.
|
||||
|
||||
Usage:
|
||||
from praisonai.integrations import ClaudeCodeIntegration, GeminiCLIIntegration
|
||||
|
||||
# Create integration
|
||||
claude = ClaudeCodeIntegration(workspace="/path/to/project")
|
||||
|
||||
# Use as agent tool
|
||||
tool = claude.as_tool()
|
||||
|
||||
# Or execute directly
|
||||
result = await claude.execute("Refactor this code")
|
||||
"""
|
||||
|
||||
# Lazy imports to avoid performance impact
|
||||
__all__ = [
|
||||
'BaseCLIIntegration',
|
||||
'ClaudeCodeIntegration',
|
||||
'GeminiCLIIntegration',
|
||||
'CodexCLIIntegration',
|
||||
'CursorCLIIntegration',
|
||||
'get_available_integrations',
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy load integrations to minimize import overhead."""
|
||||
if name == 'BaseCLIIntegration':
|
||||
from .base import BaseCLIIntegration
|
||||
return BaseCLIIntegration
|
||||
elif name == 'ClaudeCodeIntegration':
|
||||
from .claude_code import ClaudeCodeIntegration
|
||||
return ClaudeCodeIntegration
|
||||
elif name == 'GeminiCLIIntegration':
|
||||
from .gemini_cli import GeminiCLIIntegration
|
||||
return GeminiCLIIntegration
|
||||
elif name == 'CodexCLIIntegration':
|
||||
from .codex_cli import CodexCLIIntegration
|
||||
return CodexCLIIntegration
|
||||
elif name == 'CursorCLIIntegration':
|
||||
from .cursor_cli import CursorCLIIntegration
|
||||
return CursorCLIIntegration
|
||||
elif name == 'get_available_integrations':
|
||||
from .base import get_available_integrations
|
||||
return get_available_integrations
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
Base class for external CLI tool integrations.
|
||||
|
||||
Provides a consistent interface for integrating with AI coding CLI tools
|
||||
like Claude Code, Gemini CLI, Codex CLI, and Cursor CLI.
|
||||
|
||||
Features:
|
||||
- Lazy availability checking with caching
|
||||
- Async subprocess execution
|
||||
- Tool wrapper for agent integration
|
||||
- Timeout handling
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import AsyncIterator, Optional, Dict, Any, Tuple, List
|
||||
import asyncio
|
||||
import shutil
|
||||
import os
|
||||
|
||||
|
||||
class BaseCLIIntegration(ABC):
|
||||
"""
|
||||
Abstract base class for external CLI tool integrations.
|
||||
|
||||
Provides:
|
||||
- Lazy availability checking with class-level caching
|
||||
- Async subprocess execution with timeout handling
|
||||
- Tool wrapper for PraisonAI agent integration
|
||||
|
||||
Subclasses must implement:
|
||||
- cli_command property: The CLI command name
|
||||
- execute method: Execute the CLI and return result
|
||||
- stream method: Stream output from the CLI
|
||||
|
||||
Example:
|
||||
class MyIntegration(BaseCLIIntegration):
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
return "my-cli"
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
return await self.execute_async(["my-cli", "-p", prompt])
|
||||
|
||||
async def stream(self, prompt: str, **options):
|
||||
async for line in self.stream_async(["my-cli", "-p", prompt]):
|
||||
yield line
|
||||
"""
|
||||
|
||||
# Class-level cache for availability checks (shared across instances)
|
||||
_availability_cache: Dict[str, bool] = {}
|
||||
|
||||
def __init__(self, workspace: str = ".", timeout: int = 300):
|
||||
"""
|
||||
Initialize the CLI integration.
|
||||
|
||||
Args:
|
||||
workspace: Working directory for CLI execution (default: current dir)
|
||||
timeout: Default timeout in seconds for CLI execution (default: 300)
|
||||
"""
|
||||
self.workspace = workspace
|
||||
self.timeout = timeout
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def cli_command(self) -> str:
|
||||
"""
|
||||
Return the CLI command name.
|
||||
|
||||
This is used for availability checking and tool naming.
|
||||
|
||||
Returns:
|
||||
str: The CLI command (e.g., "claude", "gemini", "codex", "cursor-agent")
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""
|
||||
Check if the CLI tool is installed and available.
|
||||
|
||||
Uses class-level caching to avoid repeated filesystem checks.
|
||||
|
||||
Returns:
|
||||
bool: True if the CLI is available, False otherwise
|
||||
"""
|
||||
if self.cli_command not in self._availability_cache:
|
||||
self._availability_cache[self.cli_command] = shutil.which(self.cli_command) is not None
|
||||
return self._availability_cache[self.cli_command]
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
"""
|
||||
Execute the CLI tool and return the result.
|
||||
|
||||
Args:
|
||||
prompt: The prompt/query to send to the CLI
|
||||
**options: Additional options for the CLI
|
||||
|
||||
Returns:
|
||||
str: The CLI output
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def stream(self, prompt: str, **options) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""
|
||||
Stream output from the CLI tool.
|
||||
|
||||
Args:
|
||||
prompt: The prompt/query to send to the CLI
|
||||
**options: Additional options for the CLI
|
||||
|
||||
Yields:
|
||||
dict: Parsed output events from the CLI
|
||||
"""
|
||||
pass
|
||||
|
||||
async def execute_async(self, cmd: List[str], timeout: Optional[int] = None) -> str:
|
||||
"""
|
||||
Execute a command asynchronously and return stdout.
|
||||
|
||||
Args:
|
||||
cmd: Command and arguments as a list
|
||||
timeout: Timeout in seconds (uses self.timeout if not specified)
|
||||
|
||||
Returns:
|
||||
str: The command's stdout
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the command times out
|
||||
"""
|
||||
timeout = timeout or self.timeout
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=self.workspace
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=timeout
|
||||
)
|
||||
return stdout.decode()
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
raise TimeoutError(f"Command timed out after {timeout}s: {' '.join(cmd)}")
|
||||
|
||||
async def execute_async_with_stderr(
|
||||
self,
|
||||
cmd: List[str],
|
||||
timeout: Optional[int] = None
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Execute a command asynchronously and return both stdout and stderr.
|
||||
|
||||
Args:
|
||||
cmd: Command and arguments as a list
|
||||
timeout: Timeout in seconds (uses self.timeout if not specified)
|
||||
|
||||
Returns:
|
||||
Tuple[str, str]: (stdout, stderr)
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the command times out
|
||||
"""
|
||||
timeout = timeout or self.timeout
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=self.workspace
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=timeout
|
||||
)
|
||||
return stdout.decode(), stderr.decode()
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
raise TimeoutError(f"Command timed out after {timeout}s: {' '.join(cmd)}")
|
||||
|
||||
async def stream_async(
|
||||
self,
|
||||
cmd: List[str],
|
||||
timeout: Optional[int] = None
|
||||
) -> AsyncIterator[str]:
|
||||
"""
|
||||
Stream stdout lines from a command asynchronously.
|
||||
|
||||
Args:
|
||||
cmd: Command and arguments as a list
|
||||
timeout: Timeout in seconds for the entire operation
|
||||
|
||||
Yields:
|
||||
str: Each line of output
|
||||
"""
|
||||
timeout = timeout or self.timeout
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=self.workspace
|
||||
)
|
||||
|
||||
try:
|
||||
async def read_lines():
|
||||
while True:
|
||||
line = await proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
yield line.decode().rstrip('\n')
|
||||
|
||||
async for line in read_lines():
|
||||
yield line
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
raise TimeoutError(f"Stream timed out after {timeout}s")
|
||||
finally:
|
||||
if proc.returncode is None:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
|
||||
def as_tool(self) -> callable:
|
||||
"""
|
||||
Return a callable suitable for use as a PraisonAI agent tool.
|
||||
|
||||
The returned function is synchronous and wraps the async execute method.
|
||||
Uses asyncio.run() for proper event loop handling (Python 3.7+).
|
||||
|
||||
Returns:
|
||||
callable: A function that can be used as an agent tool
|
||||
"""
|
||||
# Capture self for closure
|
||||
integration = self
|
||||
|
||||
def tool_func(query: str) -> str:
|
||||
"""Execute the CLI tool with the given query."""
|
||||
# Use asyncio.run() for clean event loop management
|
||||
# This creates a new event loop, runs the coroutine, and closes it
|
||||
try:
|
||||
# Check if we're already in an async context
|
||||
asyncio.get_running_loop()
|
||||
# If we're in an async context, use ThreadPoolExecutor to avoid nested loop
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(asyncio.run, integration.execute(query))
|
||||
return future.result()
|
||||
except RuntimeError:
|
||||
# No running loop, safe to use asyncio.run()
|
||||
return asyncio.run(integration.execute(query))
|
||||
|
||||
# Set function metadata for agent tool registration
|
||||
tool_func.__name__ = f"{self.cli_command}_tool"
|
||||
tool_func.__doc__ = f"Execute {self.cli_command} for coding tasks."
|
||||
|
||||
return tool_func
|
||||
|
||||
def get_env(self) -> Dict[str, str]:
|
||||
"""
|
||||
Get environment variables for CLI execution.
|
||||
|
||||
Subclasses can override this to add tool-specific environment variables.
|
||||
|
||||
Returns:
|
||||
dict: Environment variables to pass to the CLI
|
||||
"""
|
||||
return dict(os.environ)
|
||||
|
||||
|
||||
def get_available_integrations() -> Dict[str, bool]:
|
||||
"""
|
||||
Get a dictionary of all integrations and their availability status.
|
||||
|
||||
Returns:
|
||||
dict: Mapping of integration name to availability (True/False)
|
||||
"""
|
||||
from .claude_code import ClaudeCodeIntegration
|
||||
from .gemini_cli import GeminiCLIIntegration
|
||||
from .codex_cli import CodexCLIIntegration
|
||||
from .cursor_cli import CursorCLIIntegration
|
||||
|
||||
integrations = {
|
||||
'claude': ClaudeCodeIntegration(),
|
||||
'gemini': GeminiCLIIntegration(),
|
||||
'codex': CodexCLIIntegration(),
|
||||
'cursor': CursorCLIIntegration(),
|
||||
}
|
||||
|
||||
return {name: integration.is_available for name, integration in integrations.items()}
|
||||
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Claude Code CLI Integration.
|
||||
|
||||
Provides integration with Claude Code CLI for AI-powered coding tasks.
|
||||
|
||||
Features:
|
||||
- Headless mode execution with JSON output
|
||||
- Session continuation support
|
||||
- System prompt customization
|
||||
- Tool restrictions
|
||||
- Optional SDK integration (when claude-agent-sdk is installed)
|
||||
|
||||
Usage:
|
||||
from praisonai.integrations import ClaudeCodeIntegration
|
||||
|
||||
# Create integration
|
||||
claude = ClaudeCodeIntegration(workspace="/path/to/project")
|
||||
|
||||
# Execute a coding task
|
||||
result = await claude.execute("Refactor the auth module")
|
||||
|
||||
# Stream output
|
||||
async for event in claude.stream("Add error handling"):
|
||||
print(event)
|
||||
|
||||
# Use as agent tool
|
||||
tool = claude.as_tool()
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import AsyncIterator, Dict, Any, Optional, List
|
||||
|
||||
from .base import BaseCLIIntegration
|
||||
|
||||
|
||||
# Check if Claude Code SDK is available
|
||||
CLAUDE_SDK_AVAILABLE = False
|
||||
try:
|
||||
from claude_agent_sdk import query as claude_query, ClaudeAgentOptions
|
||||
CLAUDE_SDK_AVAILABLE = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
class ClaudeCodeIntegration(BaseCLIIntegration):
|
||||
"""
|
||||
Integration with Claude Code CLI.
|
||||
|
||||
Supports both subprocess-based execution and SDK-based execution
|
||||
(when claude-agent-sdk is installed).
|
||||
|
||||
Attributes:
|
||||
output_format: Output format ("json", "text", "stream-json")
|
||||
skip_permissions: Whether to skip permission prompts
|
||||
system_prompt: Custom system prompt to append
|
||||
allowed_tools: List of allowed tools (e.g., ["Read", "Write", "Bash"])
|
||||
disallowed_tools: List of disallowed tools
|
||||
use_sdk: Whether to use the SDK instead of subprocess
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: str = ".",
|
||||
timeout: int = 300,
|
||||
output_format: str = "json",
|
||||
skip_permissions: bool = True,
|
||||
system_prompt: Optional[str] = None,
|
||||
allowed_tools: Optional[List[str]] = None,
|
||||
disallowed_tools: Optional[List[str]] = None,
|
||||
use_sdk: bool = False,
|
||||
model: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize Claude Code integration.
|
||||
|
||||
Args:
|
||||
workspace: Working directory for CLI execution
|
||||
timeout: Timeout in seconds for CLI execution
|
||||
output_format: Output format ("json", "text", "stream-json")
|
||||
skip_permissions: Whether to skip permission prompts (--dangerously-skip-permissions)
|
||||
system_prompt: Custom system prompt to append
|
||||
allowed_tools: List of allowed tools
|
||||
disallowed_tools: List of disallowed tools
|
||||
use_sdk: Whether to use the SDK instead of subprocess
|
||||
model: Model to use (e.g., "sonnet", "opus")
|
||||
"""
|
||||
super().__init__(workspace=workspace, timeout=timeout)
|
||||
|
||||
self.output_format = output_format
|
||||
self.skip_permissions = skip_permissions
|
||||
self.system_prompt = system_prompt
|
||||
self.allowed_tools = allowed_tools
|
||||
self.disallowed_tools = disallowed_tools
|
||||
self.use_sdk = use_sdk and CLAUDE_SDK_AVAILABLE
|
||||
self.model = model
|
||||
|
||||
# Session management
|
||||
self._session_active = False
|
||||
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
"""Return the CLI command name."""
|
||||
return "claude"
|
||||
|
||||
@property
|
||||
def sdk_available(self) -> bool:
|
||||
"""Check if the Claude Code SDK is available."""
|
||||
return CLAUDE_SDK_AVAILABLE
|
||||
|
||||
def _build_command(
|
||||
self,
|
||||
prompt: str,
|
||||
continue_session: bool = False,
|
||||
**options
|
||||
) -> List[str]:
|
||||
"""
|
||||
Build the Claude CLI command.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to send
|
||||
continue_session: Whether to continue a previous session
|
||||
**options: Additional options
|
||||
|
||||
Returns:
|
||||
List of command arguments
|
||||
"""
|
||||
cmd = ["claude"]
|
||||
|
||||
# Add continue flag if needed
|
||||
if continue_session or self._session_active:
|
||||
cmd.append("--continue")
|
||||
|
||||
# Add skip permissions flag
|
||||
if self.skip_permissions:
|
||||
cmd.append("--dangerously-skip-permissions")
|
||||
|
||||
# Add print mode flag
|
||||
cmd.append("-p")
|
||||
|
||||
# Add prompt
|
||||
cmd.append(prompt)
|
||||
|
||||
# Add output format
|
||||
cmd.extend(["--output-format", self.output_format])
|
||||
|
||||
# Add model if specified
|
||||
if self.model:
|
||||
cmd.extend(["--model", self.model])
|
||||
|
||||
# Add system prompt if specified
|
||||
if self.system_prompt:
|
||||
cmd.extend(["--append-system-prompt", self.system_prompt])
|
||||
|
||||
# Add allowed tools if specified
|
||||
if self.allowed_tools:
|
||||
cmd.extend(["--allowedTools", ",".join(self.allowed_tools)])
|
||||
|
||||
# Add disallowed tools if specified
|
||||
if self.disallowed_tools:
|
||||
cmd.extend(["--disallowedTools", ",".join(self.disallowed_tools)])
|
||||
|
||||
return cmd
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
"""
|
||||
Execute Claude Code CLI and return the result.
|
||||
|
||||
Args:
|
||||
prompt: The prompt/query to send
|
||||
**options: Additional options (continue_session, etc.)
|
||||
|
||||
Returns:
|
||||
str: The CLI output (parsed from JSON if output_format is "json")
|
||||
"""
|
||||
if self.use_sdk:
|
||||
return await self._execute_sdk(prompt, **options)
|
||||
|
||||
return await self._execute_subprocess(prompt, **options)
|
||||
|
||||
async def _execute_subprocess(self, prompt: str, **options) -> str:
|
||||
"""Execute using subprocess."""
|
||||
cmd = self._build_command(prompt, **options)
|
||||
|
||||
output = await self.execute_async(cmd)
|
||||
|
||||
# Mark session as active for continuation
|
||||
self._session_active = True
|
||||
|
||||
# Parse JSON output if applicable
|
||||
if self.output_format == "json":
|
||||
try:
|
||||
data = json.loads(output)
|
||||
# Extract the main result
|
||||
if isinstance(data, dict):
|
||||
return data.get("result", data.get("content", str(data)))
|
||||
return str(data)
|
||||
except json.JSONDecodeError:
|
||||
return output
|
||||
|
||||
return output
|
||||
|
||||
async def _execute_sdk(self, prompt: str, **options) -> str:
|
||||
"""Execute using the Claude Code SDK."""
|
||||
if not CLAUDE_SDK_AVAILABLE:
|
||||
raise RuntimeError("Claude Code SDK not available. Install with: pip install claude-agent-sdk")
|
||||
|
||||
sdk_options = ClaudeAgentOptions(
|
||||
cwd=self.workspace,
|
||||
system_prompt=self.system_prompt,
|
||||
)
|
||||
|
||||
if self.allowed_tools:
|
||||
sdk_options.allowed_tools = self.allowed_tools
|
||||
|
||||
if self.skip_permissions:
|
||||
sdk_options.permission_mode = 'acceptEdits'
|
||||
|
||||
result_parts = []
|
||||
async for message in claude_query(prompt=prompt, options=sdk_options):
|
||||
if hasattr(message, 'content'):
|
||||
for block in message.content:
|
||||
if hasattr(block, 'text'):
|
||||
result_parts.append(block.text)
|
||||
|
||||
return '\n'.join(result_parts)
|
||||
|
||||
async def stream(self, prompt: str, **options) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""
|
||||
Stream output from Claude Code CLI.
|
||||
|
||||
Args:
|
||||
prompt: The prompt/query to send
|
||||
**options: Additional options
|
||||
|
||||
Yields:
|
||||
dict: Parsed JSON events from the CLI
|
||||
"""
|
||||
# Use stream-json format for streaming
|
||||
original_format = self.output_format
|
||||
self.output_format = "stream-json"
|
||||
|
||||
try:
|
||||
cmd = self._build_command(prompt, **options)
|
||||
|
||||
async for line in self.stream_async(cmd):
|
||||
if line.strip():
|
||||
try:
|
||||
event = json.loads(line)
|
||||
yield event
|
||||
except json.JSONDecodeError:
|
||||
yield {"type": "text", "content": line}
|
||||
finally:
|
||||
self.output_format = original_format
|
||||
|
||||
def reset_session(self):
|
||||
"""Reset the session state."""
|
||||
self._session_active = False
|
||||
|
||||
def get_env(self) -> Dict[str, str]:
|
||||
"""Get environment variables for CLI execution."""
|
||||
env = super().get_env()
|
||||
|
||||
# Add Anthropic API key if available
|
||||
if "ANTHROPIC_API_KEY" in os.environ:
|
||||
env["ANTHROPIC_API_KEY"] = os.environ["ANTHROPIC_API_KEY"]
|
||||
elif "CLAUDE_API_KEY" in os.environ:
|
||||
env["ANTHROPIC_API_KEY"] = os.environ["CLAUDE_API_KEY"]
|
||||
|
||||
return env
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
OpenAI Codex CLI Integration.
|
||||
|
||||
Provides integration with OpenAI's Codex CLI for AI-powered coding tasks.
|
||||
|
||||
Features:
|
||||
- Non-interactive execution with `codex exec`
|
||||
- Full auto mode for file modifications
|
||||
- Sandbox modes for security
|
||||
- JSON streaming output
|
||||
- Structured output with schemas
|
||||
|
||||
Usage:
|
||||
from praisonai.integrations import CodexCLIIntegration
|
||||
|
||||
# Create integration
|
||||
codex = CodexCLIIntegration(workspace="/path/to/project")
|
||||
|
||||
# Execute a coding task
|
||||
result = await codex.execute("Fix the authentication bug")
|
||||
|
||||
# Execute with full auto (allows file modifications)
|
||||
codex_auto = CodexCLIIntegration(full_auto=True)
|
||||
result = await codex_auto.execute("Refactor the module")
|
||||
|
||||
# Use as agent tool
|
||||
tool = codex.as_tool()
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import AsyncIterator, Dict, Any, Optional, List
|
||||
|
||||
from .base import BaseCLIIntegration
|
||||
|
||||
|
||||
class CodexCLIIntegration(BaseCLIIntegration):
|
||||
"""
|
||||
Integration with OpenAI's Codex CLI.
|
||||
|
||||
Attributes:
|
||||
full_auto: Whether to allow file modifications
|
||||
sandbox: Sandbox mode ("default", "danger-full-access")
|
||||
json_output: Whether to use JSON streaming output
|
||||
output_schema: Path to JSON schema for structured output
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: str = ".",
|
||||
timeout: int = 300,
|
||||
full_auto: bool = False,
|
||||
sandbox: str = "default",
|
||||
json_output: bool = False,
|
||||
output_schema: Optional[str] = None,
|
||||
output_file: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize Codex CLI integration.
|
||||
|
||||
Args:
|
||||
workspace: Working directory for CLI execution
|
||||
timeout: Timeout in seconds for CLI execution
|
||||
full_auto: Whether to allow file modifications (--full-auto)
|
||||
sandbox: Sandbox mode ("default", "danger-full-access")
|
||||
json_output: Whether to use JSON streaming output (--json)
|
||||
output_schema: Path to JSON schema for structured output
|
||||
output_file: Path to save the final output (-o)
|
||||
"""
|
||||
super().__init__(workspace=workspace, timeout=timeout)
|
||||
|
||||
self.full_auto = full_auto
|
||||
self.sandbox = sandbox
|
||||
self.json_output = json_output
|
||||
self.output_schema = output_schema
|
||||
self.output_file = output_file
|
||||
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
"""Return the CLI command name."""
|
||||
return "codex"
|
||||
|
||||
def _build_command(self, task: str, **options) -> List[str]:
|
||||
"""
|
||||
Build the Codex CLI command.
|
||||
|
||||
Args:
|
||||
task: The task to execute
|
||||
**options: Additional options
|
||||
|
||||
Returns:
|
||||
List of command arguments
|
||||
"""
|
||||
cmd = ["codex", "exec"]
|
||||
|
||||
# Add task
|
||||
cmd.append(task)
|
||||
|
||||
# Add full auto flag if enabled
|
||||
if self.full_auto:
|
||||
cmd.append("--full-auto")
|
||||
|
||||
# Add sandbox mode if not default
|
||||
if self.sandbox and self.sandbox != "default":
|
||||
cmd.extend(["--sandbox", self.sandbox])
|
||||
|
||||
# Add JSON output flag if enabled
|
||||
if self.json_output:
|
||||
cmd.append("--json")
|
||||
|
||||
# Add output schema if specified
|
||||
if self.output_schema:
|
||||
cmd.extend(["--output-schema", self.output_schema])
|
||||
|
||||
# Add output file if specified
|
||||
if self.output_file:
|
||||
cmd.extend(["-o", self.output_file])
|
||||
|
||||
return cmd
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
"""
|
||||
Execute Codex CLI and return the result.
|
||||
|
||||
Args:
|
||||
prompt: The task/prompt to execute
|
||||
**options: Additional options
|
||||
|
||||
Returns:
|
||||
str: The CLI output
|
||||
"""
|
||||
cmd = self._build_command(prompt, **options)
|
||||
|
||||
output = await self.execute_async(cmd)
|
||||
|
||||
# Parse JSON Lines output if json_output is enabled
|
||||
if self.json_output:
|
||||
return self._parse_json_events(output)
|
||||
|
||||
return output
|
||||
|
||||
def _parse_json_events(self, output: str) -> str:
|
||||
"""
|
||||
Parse JSON Lines output and extract the final result.
|
||||
|
||||
Args:
|
||||
output: Raw JSON Lines output
|
||||
|
||||
Returns:
|
||||
str: The extracted result
|
||||
"""
|
||||
result_parts = []
|
||||
|
||||
for line in output.strip().split('\n'):
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
event = json.loads(line)
|
||||
event_type = event.get("type", "")
|
||||
|
||||
# Extract agent messages
|
||||
if event_type == "item.completed":
|
||||
item = event.get("item", {})
|
||||
if item.get("type") == "agent_message":
|
||||
text = item.get("text", "")
|
||||
if text:
|
||||
result_parts.append(text)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return '\n'.join(result_parts) if result_parts else output
|
||||
|
||||
async def stream(self, prompt: str, **options) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""
|
||||
Stream output from Codex CLI.
|
||||
|
||||
Args:
|
||||
prompt: The task/prompt to execute
|
||||
**options: Additional options
|
||||
|
||||
Yields:
|
||||
dict: Parsed JSON events from the CLI
|
||||
"""
|
||||
# Ensure JSON output is enabled for streaming
|
||||
original_json = self.json_output
|
||||
self.json_output = True
|
||||
|
||||
try:
|
||||
cmd = self._build_command(prompt, **options)
|
||||
|
||||
async for line in self.stream_async(cmd):
|
||||
if line.strip():
|
||||
try:
|
||||
event = json.loads(line)
|
||||
yield event
|
||||
except json.JSONDecodeError:
|
||||
yield {"type": "text", "content": line}
|
||||
finally:
|
||||
self.json_output = original_json
|
||||
|
||||
async def execute_with_schema(
|
||||
self,
|
||||
prompt: str,
|
||||
schema_path: str,
|
||||
output_path: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute Codex with structured output schema.
|
||||
|
||||
Args:
|
||||
prompt: The task/prompt to execute
|
||||
schema_path: Path to the JSON schema file
|
||||
output_path: Optional path to save the output
|
||||
|
||||
Returns:
|
||||
dict: Parsed structured output
|
||||
"""
|
||||
original_schema = self.output_schema
|
||||
original_output = self.output_file
|
||||
|
||||
self.output_schema = schema_path
|
||||
if output_path:
|
||||
self.output_file = output_path
|
||||
|
||||
try:
|
||||
cmd = self._build_command(prompt)
|
||||
output = await self.execute_async(cmd)
|
||||
|
||||
# If output file was specified, read from it
|
||||
if output_path and os.path.exists(output_path):
|
||||
with open(output_path, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
# Otherwise parse the output
|
||||
try:
|
||||
return json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
return {"result": output}
|
||||
finally:
|
||||
self.output_schema = original_schema
|
||||
self.output_file = original_output
|
||||
|
||||
def get_env(self) -> Dict[str, str]:
|
||||
"""Get environment variables for CLI execution."""
|
||||
env = super().get_env()
|
||||
|
||||
# Codex uses ChatGPT authentication or API key
|
||||
# The CLI handles authentication internally
|
||||
|
||||
return env
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Cursor CLI Integration.
|
||||
|
||||
Provides integration with Cursor's CLI (cursor-agent) for AI-powered coding tasks.
|
||||
|
||||
Features:
|
||||
- Headless mode execution with JSON output
|
||||
- Force mode for file modifications
|
||||
- Model selection
|
||||
- Session resume support
|
||||
- Streaming output with partial deltas
|
||||
|
||||
Usage:
|
||||
from praisonai.integrations import CursorCLIIntegration
|
||||
|
||||
# Create integration
|
||||
cursor = CursorCLIIntegration(workspace="/path/to/project")
|
||||
|
||||
# Execute a coding task
|
||||
result = await cursor.execute("Fix the authentication bug")
|
||||
|
||||
# Execute with force mode (allows file modifications)
|
||||
cursor_force = CursorCLIIntegration(force=True)
|
||||
result = await cursor_force.execute("Refactor the module")
|
||||
|
||||
# Use as agent tool
|
||||
tool = cursor.as_tool()
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import AsyncIterator, Dict, Any, Optional, List
|
||||
|
||||
from .base import BaseCLIIntegration
|
||||
|
||||
|
||||
class CursorCLIIntegration(BaseCLIIntegration):
|
||||
"""
|
||||
Integration with Cursor CLI (cursor-agent).
|
||||
|
||||
Attributes:
|
||||
output_format: Output format ("json", "text", "stream-json")
|
||||
force: Whether to allow file modifications
|
||||
model: Model to use (e.g., "gpt-5")
|
||||
stream_partial: Whether to stream partial output
|
||||
resume_session: Session ID to resume
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: str = ".",
|
||||
timeout: int = 300,
|
||||
output_format: str = "json",
|
||||
force: bool = False,
|
||||
model: Optional[str] = None,
|
||||
stream_partial: bool = False,
|
||||
resume_session: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize Cursor CLI integration.
|
||||
|
||||
Args:
|
||||
workspace: Working directory for CLI execution
|
||||
timeout: Timeout in seconds for CLI execution
|
||||
output_format: Output format ("json", "text", "stream-json")
|
||||
force: Whether to allow file modifications (--force)
|
||||
model: Model to use (e.g., "gpt-5")
|
||||
stream_partial: Whether to stream partial output (--stream-partial-output)
|
||||
resume_session: Session ID to resume (--resume)
|
||||
"""
|
||||
super().__init__(workspace=workspace, timeout=timeout)
|
||||
|
||||
self.output_format = output_format
|
||||
self.force = force
|
||||
self.model = model
|
||||
self.stream_partial = stream_partial
|
||||
self.resume_session = resume_session
|
||||
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
"""Return the CLI command name."""
|
||||
return "cursor-agent"
|
||||
|
||||
def _build_command(self, prompt: str, **options) -> List[str]:
|
||||
"""
|
||||
Build the Cursor CLI command.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to send
|
||||
**options: Additional options
|
||||
|
||||
Returns:
|
||||
List of command arguments
|
||||
"""
|
||||
cmd = ["cursor-agent"]
|
||||
|
||||
# Add print mode flag
|
||||
cmd.append("-p")
|
||||
|
||||
# Add force flag if enabled
|
||||
if self.force:
|
||||
cmd.append("--force")
|
||||
|
||||
# Add model if specified
|
||||
if self.model:
|
||||
cmd.extend(["-m", self.model])
|
||||
|
||||
# Add output format
|
||||
cmd.extend(["--output-format", self.output_format])
|
||||
|
||||
# Add stream partial flag if enabled
|
||||
if self.stream_partial:
|
||||
cmd.append("--stream-partial-output")
|
||||
|
||||
# Add resume session if specified
|
||||
if self.resume_session:
|
||||
cmd.extend(["--resume", self.resume_session])
|
||||
|
||||
# Add prompt (must be last)
|
||||
cmd.append(prompt)
|
||||
|
||||
return cmd
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
"""
|
||||
Execute Cursor CLI and return the result.
|
||||
|
||||
Args:
|
||||
prompt: The prompt/query to send
|
||||
**options: Additional options
|
||||
|
||||
Returns:
|
||||
str: The CLI output (parsed from JSON if output_format is "json")
|
||||
"""
|
||||
cmd = self._build_command(prompt, **options)
|
||||
|
||||
output = await self.execute_async(cmd)
|
||||
|
||||
# Parse JSON output if applicable
|
||||
if self.output_format == "json":
|
||||
try:
|
||||
data = json.loads(output)
|
||||
# Extract the main result
|
||||
if isinstance(data, dict):
|
||||
return data.get("result", data.get("content", str(data)))
|
||||
return str(data)
|
||||
except json.JSONDecodeError:
|
||||
return output
|
||||
|
||||
return output
|
||||
|
||||
async def stream(self, prompt: str, **options) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""
|
||||
Stream output from Cursor CLI.
|
||||
|
||||
Args:
|
||||
prompt: The prompt/query to send
|
||||
**options: Additional options
|
||||
|
||||
Yields:
|
||||
dict: Parsed JSON events from the CLI
|
||||
"""
|
||||
# Use stream-json format for streaming
|
||||
original_format = self.output_format
|
||||
original_partial = self.stream_partial
|
||||
|
||||
self.output_format = "stream-json"
|
||||
self.stream_partial = True
|
||||
|
||||
try:
|
||||
cmd = self._build_command(prompt, **options)
|
||||
|
||||
async for line in self.stream_async(cmd):
|
||||
if line.strip():
|
||||
try:
|
||||
event = json.loads(line)
|
||||
yield event
|
||||
except json.JSONDecodeError:
|
||||
yield {"type": "text", "content": line}
|
||||
finally:
|
||||
self.output_format = original_format
|
||||
self.stream_partial = original_partial
|
||||
|
||||
def get_env(self) -> Dict[str, str]:
|
||||
"""Get environment variables for CLI execution."""
|
||||
env = super().get_env()
|
||||
|
||||
# Add Cursor API key if available
|
||||
if "CURSOR_API_KEY" in os.environ:
|
||||
env["CURSOR_API_KEY"] = os.environ["CURSOR_API_KEY"]
|
||||
|
||||
return env
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Gemini CLI Integration.
|
||||
|
||||
Provides integration with Google's Gemini CLI for AI-powered coding tasks.
|
||||
|
||||
Features:
|
||||
- Headless mode execution with JSON output
|
||||
- Model selection (gemini-2.5-pro, gemini-2.5-flash, etc.)
|
||||
- Multi-directory context support
|
||||
- Google Search grounding
|
||||
- Usage statistics
|
||||
|
||||
Usage:
|
||||
from praisonai.integrations import GeminiCLIIntegration
|
||||
|
||||
# Create integration
|
||||
gemini = GeminiCLIIntegration(workspace="/path/to/project")
|
||||
|
||||
# Execute a coding task
|
||||
result = await gemini.execute("Analyze this codebase")
|
||||
|
||||
# Get result with stats
|
||||
result, stats = await gemini.execute_with_stats("Explain the architecture")
|
||||
|
||||
# Use as agent tool
|
||||
tool = gemini.as_tool()
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import AsyncIterator, Dict, Any, Optional, List, Tuple
|
||||
|
||||
from .base import BaseCLIIntegration
|
||||
|
||||
|
||||
class GeminiCLIIntegration(BaseCLIIntegration):
|
||||
"""
|
||||
Integration with Google's Gemini CLI.
|
||||
|
||||
Attributes:
|
||||
output_format: Output format ("json", "text", "stream-json")
|
||||
model: Gemini model to use
|
||||
include_directories: Additional directories to include in context
|
||||
sandbox: Whether to run in sandbox mode
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: str = ".",
|
||||
timeout: int = 300,
|
||||
output_format: str = "json",
|
||||
model: str = "gemini-2.5-pro",
|
||||
include_directories: Optional[List[str]] = None,
|
||||
sandbox: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize Gemini CLI integration.
|
||||
|
||||
Args:
|
||||
workspace: Working directory for CLI execution
|
||||
timeout: Timeout in seconds for CLI execution
|
||||
output_format: Output format ("json", "text", "stream-json")
|
||||
model: Gemini model to use (e.g., "gemini-2.5-pro", "gemini-2.5-flash")
|
||||
include_directories: Additional directories to include in context
|
||||
sandbox: Whether to run in sandbox mode
|
||||
"""
|
||||
super().__init__(workspace=workspace, timeout=timeout)
|
||||
|
||||
self.output_format = output_format
|
||||
self.model = model
|
||||
self.include_directories = include_directories
|
||||
self.sandbox = sandbox
|
||||
|
||||
# Store last stats for retrieval
|
||||
self._last_stats: Optional[Dict[str, Any]] = None
|
||||
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
"""Return the CLI command name."""
|
||||
return "gemini"
|
||||
|
||||
def _build_command(self, prompt: str, **options) -> List[str]:
|
||||
"""
|
||||
Build the Gemini CLI command.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to send
|
||||
**options: Additional options
|
||||
|
||||
Returns:
|
||||
List of command arguments
|
||||
"""
|
||||
cmd = ["gemini"]
|
||||
|
||||
# Add print mode flag
|
||||
cmd.append("-p")
|
||||
|
||||
# Add prompt
|
||||
cmd.append(prompt)
|
||||
|
||||
# Add model
|
||||
cmd.extend(["-m", self.model])
|
||||
|
||||
# Add output format
|
||||
cmd.extend(["--output-format", self.output_format])
|
||||
|
||||
# Add include directories if specified
|
||||
if self.include_directories:
|
||||
cmd.extend(["--include-directories", ",".join(self.include_directories)])
|
||||
|
||||
# Add sandbox flag if enabled
|
||||
if self.sandbox:
|
||||
cmd.append("--sandbox")
|
||||
|
||||
return cmd
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
"""
|
||||
Execute Gemini CLI and return the result.
|
||||
|
||||
Args:
|
||||
prompt: The prompt/query to send
|
||||
**options: Additional options
|
||||
|
||||
Returns:
|
||||
str: The CLI output (parsed from JSON if output_format is "json")
|
||||
"""
|
||||
cmd = self._build_command(prompt, **options)
|
||||
|
||||
output = await self.execute_async(cmd)
|
||||
|
||||
# Parse JSON output if applicable
|
||||
if self.output_format == "json":
|
||||
try:
|
||||
data = json.loads(output)
|
||||
# Store stats for later retrieval
|
||||
self._last_stats = data.get("stats")
|
||||
# Extract the main response
|
||||
return data.get("response", str(data))
|
||||
except json.JSONDecodeError:
|
||||
return output
|
||||
|
||||
return output
|
||||
|
||||
async def execute_with_stats(self, prompt: str, **options) -> Tuple[str, Optional[Dict[str, Any]]]:
|
||||
"""
|
||||
Execute Gemini CLI and return both result and usage stats.
|
||||
|
||||
Args:
|
||||
prompt: The prompt/query to send
|
||||
**options: Additional options
|
||||
|
||||
Returns:
|
||||
Tuple[str, dict]: (result, stats) where stats contains usage information
|
||||
"""
|
||||
# Ensure JSON format for stats
|
||||
original_format = self.output_format
|
||||
self.output_format = "json"
|
||||
|
||||
try:
|
||||
cmd = self._build_command(prompt, **options)
|
||||
output = await self.execute_async(cmd)
|
||||
|
||||
try:
|
||||
data = json.loads(output)
|
||||
response = data.get("response", str(data))
|
||||
stats = data.get("stats")
|
||||
return response, stats
|
||||
except json.JSONDecodeError:
|
||||
return output, None
|
||||
finally:
|
||||
self.output_format = original_format
|
||||
|
||||
async def stream(self, prompt: str, **options) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""
|
||||
Stream output from Gemini CLI.
|
||||
|
||||
Args:
|
||||
prompt: The prompt/query to send
|
||||
**options: Additional options
|
||||
|
||||
Yields:
|
||||
dict: Parsed JSON events from the CLI
|
||||
"""
|
||||
# Use stream-json format for streaming
|
||||
original_format = self.output_format
|
||||
self.output_format = "stream-json"
|
||||
|
||||
try:
|
||||
cmd = self._build_command(prompt, **options)
|
||||
|
||||
async for line in self.stream_async(cmd):
|
||||
if line.strip():
|
||||
try:
|
||||
event = json.loads(line)
|
||||
yield event
|
||||
except json.JSONDecodeError:
|
||||
yield {"type": "text", "content": line}
|
||||
finally:
|
||||
self.output_format = original_format
|
||||
|
||||
def get_last_stats(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get the stats from the last execution.
|
||||
|
||||
Returns:
|
||||
dict: Usage statistics or None if not available
|
||||
"""
|
||||
return self._last_stats
|
||||
|
||||
def get_env(self) -> Dict[str, str]:
|
||||
"""Get environment variables for CLI execution."""
|
||||
env = super().get_env()
|
||||
|
||||
# Add Google API key if available
|
||||
if "GOOGLE_API_KEY" in os.environ:
|
||||
env["GOOGLE_API_KEY"] = os.environ["GOOGLE_API_KEY"]
|
||||
elif "GEMINI_API_KEY" in os.environ:
|
||||
# Map GEMINI_API_KEY to GOOGLE_API_KEY
|
||||
env["GOOGLE_API_KEY"] = os.environ["GEMINI_API_KEY"]
|
||||
|
||||
return env
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.3.40"
|
||||
__version__ = "2.3.45"
|
||||
@@ -7,12 +7,12 @@
|
||||
"url": "https://github.com/MervinPraison/PraisonAI",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "2.3.39",
|
||||
"version": "2.3.42",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"identifier": "praisonai",
|
||||
"version": "2.3.39",
|
||||
"version": "2.3.42",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
End-to-End Tests for CLI Features with Real API Keys.
|
||||
|
||||
These tests require actual API keys to be set in environment variables:
|
||||
- OPENAI_API_KEY
|
||||
- ANTHROPIC_API_KEY (optional)
|
||||
- GEMINI_API_KEY (optional)
|
||||
|
||||
Run with: pytest tests/e2e/test_cli_features_e2e.py -v --tb=short
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
# Skip all tests if no API key is available
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.environ.get("OPENAI_API_KEY"),
|
||||
reason="OPENAI_API_KEY not set"
|
||||
)
|
||||
|
||||
|
||||
class TestSlashCommandsE2E:
|
||||
"""End-to-end tests for slash commands."""
|
||||
|
||||
def test_slash_command_handler_initialization(self):
|
||||
"""Test that slash command handler initializes correctly."""
|
||||
from praisonai.cli.features import SlashCommandHandler
|
||||
|
||||
handler = SlashCommandHandler(verbose=True)
|
||||
assert handler.registry is not None
|
||||
assert len(handler.registry.get_all()) > 0
|
||||
|
||||
def test_help_command_execution(self):
|
||||
"""Test /help command execution."""
|
||||
from praisonai.cli.features import SlashCommandHandler
|
||||
|
||||
handler = SlashCommandHandler()
|
||||
|
||||
with patch('rich.console.Console.print'):
|
||||
result = handler.execute("/help")
|
||||
|
||||
assert result is not None
|
||||
assert result["type"] == "help"
|
||||
|
||||
def test_cost_command_with_context(self):
|
||||
"""Test /cost command with session context."""
|
||||
from praisonai.cli.features.slash_commands import (
|
||||
SlashCommandHandler,
|
||||
CommandContext
|
||||
)
|
||||
|
||||
handler = SlashCommandHandler()
|
||||
ctx = CommandContext(
|
||||
total_tokens=5000,
|
||||
total_cost=0.015,
|
||||
prompt_count=10,
|
||||
session_start_time=time.time() - 300
|
||||
)
|
||||
handler.set_context(ctx)
|
||||
|
||||
with patch('rich.console.Console.print'):
|
||||
result = handler.execute("/cost")
|
||||
|
||||
assert result["type"] == "stats"
|
||||
assert result["tokens"] == 5000
|
||||
assert result["cost"] == 0.015
|
||||
|
||||
def test_command_completions(self):
|
||||
"""Test command auto-completion."""
|
||||
from praisonai.cli.features import SlashCommandHandler
|
||||
|
||||
handler = SlashCommandHandler()
|
||||
|
||||
completions = handler.get_completions("/he")
|
||||
assert "/help" in completions
|
||||
|
||||
completions = handler.get_completions("/mo")
|
||||
assert "/model" in completions
|
||||
|
||||
|
||||
class TestAutonomyModeE2E:
|
||||
"""End-to-end tests for autonomy modes."""
|
||||
|
||||
def test_autonomy_mode_initialization(self):
|
||||
"""Test autonomy mode handler initialization."""
|
||||
from praisonai.cli.features import AutonomyModeHandler
|
||||
|
||||
handler = AutonomyModeHandler(verbose=True)
|
||||
manager = handler.initialize(mode="suggest")
|
||||
|
||||
assert manager is not None
|
||||
assert handler.get_mode() == "suggest"
|
||||
|
||||
def test_mode_transitions(self):
|
||||
"""Test transitioning between autonomy modes."""
|
||||
from praisonai.cli.features import AutonomyModeHandler
|
||||
|
||||
handler = AutonomyModeHandler()
|
||||
handler.initialize(mode="suggest")
|
||||
|
||||
# Test all mode transitions
|
||||
for mode in ["suggest", "auto_edit", "full_auto"]:
|
||||
handler.set_mode(mode)
|
||||
assert handler.get_mode() == mode
|
||||
|
||||
def test_approval_flow_suggest_mode(self):
|
||||
"""Test approval flow in suggest mode."""
|
||||
from praisonai.cli.features.autonomy_mode import (
|
||||
AutonomyModeHandler,
|
||||
ActionRequest,
|
||||
ActionType,
|
||||
ApprovalResult
|
||||
)
|
||||
|
||||
# Mock approval callback
|
||||
def mock_approval(action):
|
||||
return ApprovalResult(approved=True)
|
||||
|
||||
handler = AutonomyModeHandler()
|
||||
handler.initialize(mode="suggest", approval_callback=mock_approval)
|
||||
|
||||
# File read should be auto-approved
|
||||
read_action = ActionRequest(ActionType.FILE_READ, "Read file")
|
||||
result = handler.request_approval(read_action)
|
||||
assert result.approved is True
|
||||
|
||||
# File write should require approval (via callback)
|
||||
write_action = ActionRequest(ActionType.FILE_WRITE, "Write file")
|
||||
result = handler.request_approval(write_action)
|
||||
assert result.approved is True
|
||||
|
||||
def test_full_auto_mode(self):
|
||||
"""Test full auto mode approves everything."""
|
||||
from praisonai.cli.features.autonomy_mode import (
|
||||
AutonomyModeHandler,
|
||||
ActionRequest,
|
||||
ActionType
|
||||
)
|
||||
|
||||
handler = AutonomyModeHandler()
|
||||
handler.initialize(mode="full_auto")
|
||||
|
||||
# All actions should be auto-approved
|
||||
actions = [
|
||||
ActionRequest(ActionType.FILE_WRITE, "Write"),
|
||||
ActionRequest(ActionType.SHELL_COMMAND, "Command"),
|
||||
ActionRequest(ActionType.FILE_DELETE, "Delete"),
|
||||
]
|
||||
|
||||
for action in actions:
|
||||
result = handler.request_approval(action)
|
||||
assert result.approved is True
|
||||
|
||||
|
||||
class TestCostTrackerE2E:
|
||||
"""End-to-end tests for cost tracking."""
|
||||
|
||||
def test_cost_tracker_initialization(self):
|
||||
"""Test cost tracker initialization."""
|
||||
from praisonai.cli.features import CostTrackerHandler
|
||||
|
||||
handler = CostTrackerHandler(verbose=True)
|
||||
tracker = handler.initialize(session_id="e2e-test")
|
||||
|
||||
assert tracker is not None
|
||||
assert tracker.session_id == "e2e-test"
|
||||
|
||||
def test_track_multiple_requests(self):
|
||||
"""Test tracking multiple requests."""
|
||||
from praisonai.cli.features import CostTrackerHandler
|
||||
|
||||
handler = CostTrackerHandler()
|
||||
handler.initialize()
|
||||
|
||||
# Track several requests
|
||||
models = ["gpt-4o", "gpt-4o-mini", "gpt-4o"]
|
||||
for i, model in enumerate(models):
|
||||
handler.track_request(
|
||||
model=model,
|
||||
input_tokens=1000 * (i + 1),
|
||||
output_tokens=500 * (i + 1)
|
||||
)
|
||||
|
||||
assert handler.get_tokens() == 9000 # (1500 + 3000 + 4500)
|
||||
assert handler.get_cost() > 0
|
||||
|
||||
def test_cost_calculation_accuracy(self):
|
||||
"""Test cost calculation is accurate."""
|
||||
from praisonai.cli.features.cost_tracker import CostTracker
|
||||
|
||||
tracker = CostTracker()
|
||||
|
||||
# gpt-4o: $2.50/1M input, $10.00/1M output
|
||||
stats = tracker.track_request(
|
||||
model="gpt-4o",
|
||||
input_tokens=100_000,
|
||||
output_tokens=50_000
|
||||
)
|
||||
|
||||
# Expected: (100K/1M * 2.50) + (50K/1M * 10.00) = 0.25 + 0.50 = 0.75
|
||||
assert abs(stats.cost - 0.75) < 0.01
|
||||
|
||||
def test_session_export(self):
|
||||
"""Test session data export."""
|
||||
from praisonai.cli.features.cost_tracker import CostTracker
|
||||
import json
|
||||
|
||||
tracker = CostTracker(session_id="export-test")
|
||||
tracker.track_request("gpt-4o", 1000, 500)
|
||||
tracker.track_request("gpt-4o-mini", 2000, 1000)
|
||||
|
||||
json_str = tracker.export_json()
|
||||
data = json.loads(json_str)
|
||||
|
||||
assert data["session"]["session_id"] == "export-test"
|
||||
assert len(data["requests"]) == 2
|
||||
|
||||
|
||||
class TestIntegratedFeaturesE2E:
|
||||
"""End-to-end tests for integrated features."""
|
||||
|
||||
def test_all_features_together(self):
|
||||
"""Test all features working together."""
|
||||
from praisonai.cli.features import (
|
||||
SlashCommandHandler,
|
||||
AutonomyModeHandler,
|
||||
CostTrackerHandler
|
||||
)
|
||||
from praisonai.cli.features.slash_commands import CommandContext
|
||||
from praisonai.cli.features.autonomy_mode import (
|
||||
ActionRequest,
|
||||
ActionType,
|
||||
)
|
||||
|
||||
# Initialize all handlers
|
||||
slash_handler = SlashCommandHandler()
|
||||
autonomy_handler = AutonomyModeHandler()
|
||||
cost_handler = CostTrackerHandler()
|
||||
|
||||
# Set up autonomy mode
|
||||
autonomy_handler.initialize(mode="auto_edit")
|
||||
|
||||
# Initialize cost tracking
|
||||
cost_handler.initialize(session_id="integrated-test")
|
||||
|
||||
# Track some requests
|
||||
cost_handler.track_request("gpt-4o", 1000, 500)
|
||||
cost_handler.track_request("gpt-4o", 2000, 1000)
|
||||
|
||||
# Set up slash command context with cost data
|
||||
ctx = CommandContext(
|
||||
total_tokens=cost_handler.get_tokens(),
|
||||
total_cost=cost_handler.get_cost(),
|
||||
prompt_count=2
|
||||
)
|
||||
slash_handler.set_context(ctx)
|
||||
|
||||
# Execute cost command
|
||||
with patch('rich.console.Console.print'):
|
||||
result = slash_handler.execute("/cost")
|
||||
|
||||
assert result["tokens"] == 4500
|
||||
assert result["cost"] > 0
|
||||
|
||||
# Test autonomy mode
|
||||
write_action = ActionRequest(ActionType.FILE_WRITE, "Write file")
|
||||
approval = autonomy_handler.request_approval(write_action)
|
||||
assert approval.approved is True # Auto-approved in auto_edit mode
|
||||
|
||||
|
||||
class TestRealAPIIntegration:
|
||||
"""Tests that use real API calls."""
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("OPENAI_API_KEY"),
|
||||
reason="OPENAI_API_KEY required for this test"
|
||||
)
|
||||
def test_cost_tracking_with_real_agent(self):
|
||||
"""Test cost tracking with a real agent call."""
|
||||
from praisonai.cli.features import CostTrackerHandler
|
||||
|
||||
handler = CostTrackerHandler()
|
||||
handler.initialize()
|
||||
|
||||
# Simulate what would happen after an agent call
|
||||
# In real usage, this would be called after agent.start()
|
||||
handler.track_request(
|
||||
model="gpt-4o-mini",
|
||||
input_tokens=150,
|
||||
output_tokens=50,
|
||||
duration_ms=500.0
|
||||
)
|
||||
|
||||
summary = handler.get_summary()
|
||||
|
||||
assert summary["total_requests"] == 1
|
||||
assert summary["total_tokens"] == 200
|
||||
assert summary["total_cost"] > 0
|
||||
|
||||
# Print summary for verification
|
||||
print("\nCost tracking test:")
|
||||
print(f" Tokens: {summary['total_tokens']}")
|
||||
print(f" Cost: ${summary['total_cost']:.6f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Integration tests for AutoGenerator and WorkflowAutoGenerator.
|
||||
|
||||
These tests require a real OpenAI API key and make actual API calls.
|
||||
Run with: OPENAI_API_KEY=your-key pytest tests/integration/test_auto_generator_integration.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import yaml
|
||||
|
||||
# Add the source path for imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
# Skip if no API key
|
||||
if not os.environ.get('OPENAI_API_KEY'):
|
||||
pytest.skip("OPENAI_API_KEY not set", allow_module_level=True)
|
||||
|
||||
try:
|
||||
from praisonai.auto import AutoGenerator, WorkflowAutoGenerator
|
||||
except ImportError as e:
|
||||
pytest.skip(f"Could not import required modules: {e}", allow_module_level=True)
|
||||
|
||||
|
||||
class TestAutoGeneratorIntegration:
|
||||
"""Integration tests for AutoGenerator with real API calls."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_generate_agents_yaml(self):
|
||||
"""Test generating agents.yaml with real API call."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
||||
agent_file = f.name
|
||||
|
||||
try:
|
||||
generator = AutoGenerator(
|
||||
topic="Create a simple greeting bot",
|
||||
agent_file=agent_file,
|
||||
framework="praisonai"
|
||||
)
|
||||
|
||||
result_path = generator.generate()
|
||||
|
||||
# Verify file was created
|
||||
assert os.path.exists(result_path)
|
||||
|
||||
# Verify file content
|
||||
with open(result_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
# Check structure
|
||||
assert 'framework' in data
|
||||
assert 'topic' in data
|
||||
assert 'roles' in data
|
||||
assert len(data['roles']) > 0
|
||||
|
||||
# Check that tools are preserved (not empty list)
|
||||
for role_id, role_data in data['roles'].items():
|
||||
assert 'tools' in role_data
|
||||
# Tools should be a list (may be empty if LLM didn't suggest any)
|
||||
assert isinstance(role_data['tools'], list)
|
||||
|
||||
print(f"✓ Generated agents.yaml successfully")
|
||||
print(f" Roles: {list(data['roles'].keys())}")
|
||||
|
||||
finally:
|
||||
if os.path.exists(agent_file):
|
||||
os.unlink(agent_file)
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_generate_with_merge(self):
|
||||
"""Test generating and merging agents."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
||||
agent_file = f.name
|
||||
|
||||
try:
|
||||
# First generation
|
||||
generator1 = AutoGenerator(
|
||||
topic="Create a researcher",
|
||||
agent_file=agent_file,
|
||||
framework="praisonai"
|
||||
)
|
||||
generator1.generate()
|
||||
|
||||
# Second generation with merge
|
||||
generator2 = AutoGenerator(
|
||||
topic="Create a writer",
|
||||
agent_file=agent_file,
|
||||
framework="praisonai"
|
||||
)
|
||||
generator2.generate(merge=True)
|
||||
|
||||
# Verify merged content
|
||||
with open(agent_file, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
# Should have roles from both generations
|
||||
assert len(data['roles']) >= 2
|
||||
|
||||
print(f"✓ Merged agents.yaml successfully")
|
||||
print(f" Total roles: {len(data['roles'])}")
|
||||
|
||||
finally:
|
||||
if os.path.exists(agent_file):
|
||||
os.unlink(agent_file)
|
||||
|
||||
|
||||
class TestWorkflowAutoGeneratorIntegration:
|
||||
"""Integration tests for WorkflowAutoGenerator with real API calls."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_generate_sequential_workflow(self):
|
||||
"""Test generating sequential workflow."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
||||
workflow_file = f.name
|
||||
|
||||
try:
|
||||
generator = WorkflowAutoGenerator(
|
||||
topic="Research and summarize AI news",
|
||||
workflow_file=workflow_file
|
||||
)
|
||||
|
||||
result_path = generator.generate(pattern="sequential")
|
||||
|
||||
# Verify file was created
|
||||
assert os.path.exists(result_path)
|
||||
|
||||
# Verify file content
|
||||
with open(result_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
# Check structure
|
||||
assert 'name' in data
|
||||
assert 'agents' in data
|
||||
assert 'steps' in data
|
||||
assert len(data['agents']) > 0
|
||||
assert len(data['steps']) > 0
|
||||
|
||||
print(f"✓ Generated sequential workflow successfully")
|
||||
print(f" Agents: {list(data['agents'].keys())}")
|
||||
print(f" Steps: {len(data['steps'])}")
|
||||
|
||||
finally:
|
||||
if os.path.exists(workflow_file):
|
||||
os.unlink(workflow_file)
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_generate_parallel_workflow(self):
|
||||
"""Test generating parallel workflow."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
||||
workflow_file = f.name
|
||||
|
||||
try:
|
||||
generator = WorkflowAutoGenerator(
|
||||
topic="Multi-source research on climate change",
|
||||
workflow_file=workflow_file
|
||||
)
|
||||
|
||||
result_path = generator.generate(pattern="parallel")
|
||||
|
||||
# Verify file was created
|
||||
assert os.path.exists(result_path)
|
||||
|
||||
# Verify file content
|
||||
with open(result_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
# Check structure
|
||||
assert 'agents' in data
|
||||
assert 'steps' in data
|
||||
|
||||
print(f"✓ Generated parallel workflow successfully")
|
||||
print(f" Agents: {list(data['agents'].keys())}")
|
||||
|
||||
finally:
|
||||
if os.path.exists(workflow_file):
|
||||
os.unlink(workflow_file)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v', '-m', 'integration'])
|
||||
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Integration tests for CLI tool integrations.
|
||||
|
||||
These tests require the actual CLI tools to be installed and API keys to be set.
|
||||
Run with: pytest tests/integration/test_cli_integrations.py -v
|
||||
|
||||
Environment variables required:
|
||||
- OPENAI_API_KEY: For general testing
|
||||
- ANTHROPIC_API_KEY or CLAUDE_API_KEY: For Claude Code
|
||||
- GEMINI_API_KEY or GOOGLE_API_KEY: For Gemini CLI
|
||||
- CURSOR_API_KEY: For Cursor CLI
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
|
||||
# Skip all tests if no API keys are set
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.environ.get("OPENAI_API_KEY"),
|
||||
reason="OPENAI_API_KEY not set"
|
||||
)
|
||||
|
||||
|
||||
class TestIntegrationAvailability:
|
||||
"""Test that integrations can be imported and checked."""
|
||||
|
||||
def test_import_all_integrations(self):
|
||||
"""Test importing all integrations."""
|
||||
from praisonai.integrations import (
|
||||
BaseCLIIntegration,
|
||||
ClaudeCodeIntegration,
|
||||
GeminiCLIIntegration,
|
||||
CodexCLIIntegration,
|
||||
CursorCLIIntegration,
|
||||
)
|
||||
|
||||
assert BaseCLIIntegration is not None
|
||||
assert ClaudeCodeIntegration is not None
|
||||
assert GeminiCLIIntegration is not None
|
||||
assert CodexCLIIntegration is not None
|
||||
assert CursorCLIIntegration is not None
|
||||
|
||||
def test_get_available_integrations(self):
|
||||
"""Test getting available integrations."""
|
||||
from praisonai.integrations import get_available_integrations
|
||||
|
||||
availability = get_available_integrations()
|
||||
|
||||
assert isinstance(availability, dict)
|
||||
assert "claude" in availability
|
||||
assert "gemini" in availability
|
||||
assert "codex" in availability
|
||||
assert "cursor" in availability
|
||||
|
||||
print("\n📊 Integration Availability:")
|
||||
for name, available in availability.items():
|
||||
status = "✅" if available else "❌"
|
||||
print(f" {status} {name}")
|
||||
|
||||
|
||||
class TestClaudeCodeIntegrationReal:
|
||||
"""Real integration tests for Claude Code CLI."""
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("ANTHROPIC_API_KEY") and not os.environ.get("CLAUDE_API_KEY"),
|
||||
reason="ANTHROPIC_API_KEY or CLAUDE_API_KEY not set"
|
||||
)
|
||||
def test_claude_integration_creation(self):
|
||||
"""Test creating Claude Code integration."""
|
||||
from praisonai.integrations import ClaudeCodeIntegration
|
||||
|
||||
integration = ClaudeCodeIntegration(
|
||||
workspace=".",
|
||||
output_format="json",
|
||||
skip_permissions=True
|
||||
)
|
||||
|
||||
assert integration.cli_command == "claude"
|
||||
print(f"\n🔧 Claude CLI available: {integration.is_available}")
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("ANTHROPIC_API_KEY") and not os.environ.get("CLAUDE_API_KEY"),
|
||||
reason="ANTHROPIC_API_KEY or CLAUDE_API_KEY not set"
|
||||
)
|
||||
def test_claude_as_tool(self):
|
||||
"""Test Claude Code as agent tool."""
|
||||
from praisonai.integrations import ClaudeCodeIntegration
|
||||
|
||||
integration = ClaudeCodeIntegration()
|
||||
tool = integration.as_tool()
|
||||
|
||||
assert callable(tool)
|
||||
assert tool.__name__ == "claude_tool"
|
||||
print(f"\n🔧 Claude tool created: {tool.__name__}")
|
||||
|
||||
|
||||
class TestGeminiCLIIntegrationReal:
|
||||
"""Real integration tests for Gemini CLI."""
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("GEMINI_API_KEY") and not os.environ.get("GOOGLE_API_KEY"),
|
||||
reason="GEMINI_API_KEY or GOOGLE_API_KEY not set"
|
||||
)
|
||||
def test_gemini_integration_creation(self):
|
||||
"""Test creating Gemini CLI integration."""
|
||||
from praisonai.integrations import GeminiCLIIntegration
|
||||
|
||||
integration = GeminiCLIIntegration(
|
||||
workspace=".",
|
||||
model="gemini-2.5-pro",
|
||||
output_format="json"
|
||||
)
|
||||
|
||||
assert integration.cli_command == "gemini"
|
||||
assert integration.model == "gemini-2.5-pro"
|
||||
print(f"\n🔧 Gemini CLI available: {integration.is_available}")
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("GEMINI_API_KEY") and not os.environ.get("GOOGLE_API_KEY"),
|
||||
reason="GEMINI_API_KEY or GOOGLE_API_KEY not set"
|
||||
)
|
||||
def test_gemini_as_tool(self):
|
||||
"""Test Gemini CLI as agent tool."""
|
||||
from praisonai.integrations import GeminiCLIIntegration
|
||||
|
||||
integration = GeminiCLIIntegration()
|
||||
tool = integration.as_tool()
|
||||
|
||||
assert callable(tool)
|
||||
assert tool.__name__ == "gemini_tool"
|
||||
print(f"\n🔧 Gemini tool created: {tool.__name__}")
|
||||
|
||||
|
||||
class TestCursorCLIIntegrationReal:
|
||||
"""Real integration tests for Cursor CLI."""
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("CURSOR_API_KEY"),
|
||||
reason="CURSOR_API_KEY not set"
|
||||
)
|
||||
def test_cursor_integration_creation(self):
|
||||
"""Test creating Cursor CLI integration."""
|
||||
from praisonai.integrations import CursorCLIIntegration
|
||||
|
||||
integration = CursorCLIIntegration(
|
||||
workspace=".",
|
||||
output_format="json",
|
||||
force=False
|
||||
)
|
||||
|
||||
assert integration.cli_command == "cursor-agent"
|
||||
print(f"\n🔧 Cursor CLI available: {integration.is_available}")
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("CURSOR_API_KEY"),
|
||||
reason="CURSOR_API_KEY not set"
|
||||
)
|
||||
def test_cursor_as_tool(self):
|
||||
"""Test Cursor CLI as agent tool."""
|
||||
from praisonai.integrations import CursorCLIIntegration
|
||||
|
||||
integration = CursorCLIIntegration()
|
||||
tool = integration.as_tool()
|
||||
|
||||
assert callable(tool)
|
||||
assert tool.__name__ == "cursor-agent_tool"
|
||||
print(f"\n🔧 Cursor tool created: {tool.__name__}")
|
||||
|
||||
|
||||
class TestExternalAgentsHandler:
|
||||
"""Test the ExternalAgentsHandler."""
|
||||
|
||||
def test_handler_creation(self):
|
||||
"""Test creating the handler."""
|
||||
from praisonai.cli.features import ExternalAgentsHandler
|
||||
|
||||
handler = ExternalAgentsHandler(verbose=True)
|
||||
|
||||
assert handler.feature_name == "external_agents"
|
||||
assert handler.flag_name == "external-agent"
|
||||
|
||||
def test_handler_list_integrations(self):
|
||||
"""Test listing integrations."""
|
||||
from praisonai.cli.features import ExternalAgentsHandler
|
||||
|
||||
handler = ExternalAgentsHandler()
|
||||
integrations = handler.list_integrations()
|
||||
|
||||
assert "claude" in integrations
|
||||
assert "gemini" in integrations
|
||||
assert "codex" in integrations
|
||||
assert "cursor" in integrations
|
||||
|
||||
print("\n📋 Available integrations:", integrations)
|
||||
|
||||
def test_handler_check_availability(self):
|
||||
"""Test checking availability."""
|
||||
from praisonai.cli.features import ExternalAgentsHandler
|
||||
|
||||
handler = ExternalAgentsHandler()
|
||||
availability = handler.check_availability()
|
||||
|
||||
print("\n📊 Handler Availability Check:")
|
||||
for name, available in availability.items():
|
||||
status = "✅" if available else "❌"
|
||||
print(f" {status} {name}")
|
||||
|
||||
|
||||
class TestPerformanceImpact:
|
||||
"""Test that integrations don't impact performance."""
|
||||
|
||||
def test_lazy_loading_integrations(self):
|
||||
"""Test that integrations are lazy loaded."""
|
||||
import time
|
||||
|
||||
# Measure import time
|
||||
start = time.time()
|
||||
from praisonai.integrations import BaseCLIIntegration
|
||||
import_time = time.time() - start
|
||||
|
||||
print(f"\n⏱️ Import time: {import_time*1000:.2f}ms")
|
||||
|
||||
# Should be fast (< 100ms)
|
||||
assert import_time < 0.1, f"Import took too long: {import_time}s"
|
||||
|
||||
def test_lazy_loading_handler(self):
|
||||
"""Test that handler is lazy loaded."""
|
||||
import time
|
||||
|
||||
# Measure import time
|
||||
start = time.time()
|
||||
from praisonai.cli.features import ExternalAgentsHandler
|
||||
import_time = time.time() - start
|
||||
|
||||
print(f"\n⏱️ Handler import time: {import_time*1000:.2f}ms")
|
||||
|
||||
# Should be fast (< 100ms)
|
||||
assert import_time < 0.1, f"Import took too long: {import_time}s"
|
||||
|
||||
def test_availability_caching(self):
|
||||
"""Test that availability checks are cached."""
|
||||
import time
|
||||
from praisonai.integrations import ClaudeCodeIntegration
|
||||
|
||||
integration = ClaudeCodeIntegration()
|
||||
|
||||
# First check
|
||||
start = time.time()
|
||||
_ = integration.is_available
|
||||
first_time = time.time() - start
|
||||
|
||||
# Second check (should be cached)
|
||||
start = time.time()
|
||||
_ = integration.is_available
|
||||
second_time = time.time() - start
|
||||
|
||||
print(f"\n⏱️ First availability check: {first_time*1000:.4f}ms")
|
||||
print(f"⏱️ Second availability check: {second_time*1000:.4f}ms")
|
||||
|
||||
# Both should be very fast (< 10ms) due to caching
|
||||
assert first_time < 0.01, f"First check too slow: {first_time}s"
|
||||
assert second_time < 0.01, f"Second check too slow: {second_time}s"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Real integration tests for Model Routing with actual API calls.
|
||||
|
||||
Tests the RouterHandler with real LLM providers.
|
||||
Requires API keys: OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
|
||||
from praisonai.cli.features.router import RouterHandler
|
||||
|
||||
|
||||
class TestRouterHandlerReal:
|
||||
"""Real integration tests for RouterHandler."""
|
||||
|
||||
def test_router_handler_initialization(self):
|
||||
"""Test RouterHandler initializes correctly."""
|
||||
handler = RouterHandler(verbose=True)
|
||||
|
||||
assert handler is not None
|
||||
assert handler.model_router is not None
|
||||
assert len(handler.model_router) > 0
|
||||
print(f"✅ RouterHandler initialized with {len(handler.model_router)} default models")
|
||||
|
||||
def test_complexity_analysis(self):
|
||||
"""Test complexity analysis for different prompts."""
|
||||
handler = RouterHandler()
|
||||
|
||||
# Simple prompts
|
||||
simple_prompts = [
|
||||
"What is 2+2?",
|
||||
"Define photosynthesis",
|
||||
"List the planets",
|
||||
]
|
||||
|
||||
for prompt in simple_prompts:
|
||||
complexity = handler.analyze_complexity(prompt)
|
||||
print(f" '{prompt[:30]}...' -> {complexity}")
|
||||
assert complexity == 'simple', f"Expected 'simple' for: {prompt}"
|
||||
|
||||
print("✅ Simple prompts correctly classified")
|
||||
|
||||
# Complex prompts
|
||||
complex_prompts = [
|
||||
"Analyze and research the comprehensive impact of AI on healthcare systems globally",
|
||||
"Compare and evaluate different machine learning architectures for natural language processing",
|
||||
]
|
||||
|
||||
for prompt in complex_prompts:
|
||||
complexity = handler.analyze_complexity(prompt)
|
||||
print(f" '{prompt[:40]}...' -> {complexity}")
|
||||
assert complexity == 'complex', f"Expected 'complex' for: {prompt}"
|
||||
|
||||
print("✅ Complex prompts correctly classified")
|
||||
|
||||
def test_model_selection_by_complexity(self):
|
||||
"""Test model selection based on complexity."""
|
||||
handler = RouterHandler(verbose=True)
|
||||
|
||||
# Simple task - should select cheap model
|
||||
simple_model = handler.select_model("What is the capital of France?")
|
||||
print(f"Simple task model: {simple_model}")
|
||||
assert simple_model in ['gpt-4o-mini', 'claude-3-haiku', 'gemini-1.5-flash']
|
||||
|
||||
# Complex task - should select powerful model
|
||||
complex_model = handler.select_model(
|
||||
"Analyze and research the comprehensive impact of quantum computing on cybersecurity"
|
||||
)
|
||||
print(f"Complex task model: {complex_model}")
|
||||
assert complex_model in ['gpt-4-turbo', 'claude-3-opus', 'o1-preview']
|
||||
|
||||
print("✅ Model selection by complexity works correctly")
|
||||
|
||||
def test_model_selection_with_provider_preference(self):
|
||||
"""Test model selection with provider preference."""
|
||||
handler = RouterHandler(verbose=True)
|
||||
|
||||
# Prefer OpenAI
|
||||
openai_model = handler.select_model(
|
||||
"Write a simple greeting",
|
||||
preferred_provider='openai'
|
||||
)
|
||||
print(f"OpenAI preferred: {openai_model}")
|
||||
assert 'gpt' in openai_model.lower() or 'o1' in openai_model.lower()
|
||||
|
||||
# Prefer Anthropic
|
||||
anthropic_model = handler.select_model(
|
||||
"Write a simple greeting",
|
||||
preferred_provider='anthropic'
|
||||
)
|
||||
print(f"Anthropic preferred: {anthropic_model}")
|
||||
assert 'claude' in anthropic_model.lower()
|
||||
|
||||
# Prefer Google
|
||||
google_model = handler.select_model(
|
||||
"Write a simple greeting",
|
||||
preferred_provider='google'
|
||||
)
|
||||
print(f"Google preferred: {google_model}")
|
||||
assert 'gemini' in google_model.lower()
|
||||
|
||||
print("✅ Provider preference works correctly")
|
||||
|
||||
def test_custom_models_from_yaml(self):
|
||||
"""Test loading custom models from YAML-like config."""
|
||||
handler = RouterHandler(verbose=True)
|
||||
|
||||
# Define custom models (as would be in YAML)
|
||||
custom_models = {
|
||||
'my-cheap-model': {
|
||||
'provider': 'openai',
|
||||
'complexity': ['simple'],
|
||||
'cost_per_1k': 0.0001,
|
||||
'capabilities': ['text']
|
||||
},
|
||||
'my-balanced-model': {
|
||||
'provider': 'openai',
|
||||
'complexity': ['moderate'],
|
||||
'cost_per_1k': 0.001,
|
||||
'capabilities': ['text', 'function-calling']
|
||||
},
|
||||
'my-premium-model': {
|
||||
'provider': 'anthropic',
|
||||
'complexity': ['complex', 'very_complex'],
|
||||
'cost_per_1k': 0.015,
|
||||
'capabilities': ['text', 'vision', 'function-calling']
|
||||
}
|
||||
}
|
||||
|
||||
# Load custom models
|
||||
handler.load_models_from_config(custom_models)
|
||||
|
||||
# Verify models are loaded
|
||||
available = handler.get_available_models()
|
||||
print(f"Available models: {available}")
|
||||
|
||||
assert 'my-cheap-model' in available
|
||||
assert 'my-balanced-model' in available
|
||||
assert 'my-premium-model' in available
|
||||
|
||||
# Test selection with custom models
|
||||
simple_model = handler.select_model("What is 2+2?")
|
||||
print(f"Simple task -> {simple_model}")
|
||||
assert simple_model == 'my-cheap-model'
|
||||
|
||||
complex_model = handler.select_model(
|
||||
"Analyze and research the comprehensive impact of AI"
|
||||
)
|
||||
print(f"Complex task -> {complex_model}")
|
||||
assert complex_model == 'my-premium-model'
|
||||
|
||||
print("✅ Custom models from YAML config work correctly")
|
||||
|
||||
def test_cost_threshold(self):
|
||||
"""Test cost threshold filtering."""
|
||||
handler = RouterHandler(verbose=True)
|
||||
|
||||
# Load models with different costs
|
||||
custom_models = {
|
||||
'cheap': {
|
||||
'provider': 'openai',
|
||||
'complexity': ['simple', 'moderate', 'complex'],
|
||||
'cost_per_1k': 0.0001,
|
||||
'capabilities': ['text']
|
||||
},
|
||||
'expensive': {
|
||||
'provider': 'anthropic',
|
||||
'complexity': ['simple', 'moderate', 'complex'],
|
||||
'cost_per_1k': 0.05,
|
||||
'capabilities': ['text']
|
||||
}
|
||||
}
|
||||
|
||||
handler.load_models_from_config(custom_models)
|
||||
|
||||
# Set cost threshold
|
||||
handler.set_cost_threshold(0.001)
|
||||
|
||||
# Should only select cheap model
|
||||
model = handler.select_model("Any task here")
|
||||
print(f"With cost threshold 0.001: {model}")
|
||||
assert model == 'cheap'
|
||||
|
||||
# Verify cost
|
||||
cost = handler.get_model_cost(model)
|
||||
assert cost <= 0.001
|
||||
|
||||
print("✅ Cost threshold filtering works correctly")
|
||||
|
||||
def test_merge_with_defaults(self):
|
||||
"""Test merging custom models with defaults."""
|
||||
handler = RouterHandler(verbose=True)
|
||||
|
||||
custom_models = {
|
||||
'my-custom-model': {
|
||||
'provider': 'custom',
|
||||
'complexity': ['moderate'],
|
||||
'cost_per_1k': 0.002,
|
||||
'capabilities': ['text']
|
||||
}
|
||||
}
|
||||
|
||||
handler.load_models_from_config(custom_models, merge_with_defaults=True)
|
||||
|
||||
available = handler.get_available_models()
|
||||
print(f"Available models (merged): {available}")
|
||||
|
||||
# Should have both custom and default models
|
||||
assert 'my-custom-model' in available
|
||||
assert 'gpt-4o-mini' in available
|
||||
|
||||
print("✅ Merge with defaults works correctly")
|
||||
|
||||
|
||||
class TestRealAPIIntegration:
|
||||
"""Tests that make real API calls - requires API keys."""
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv('OPENAI_API_KEY'),
|
||||
reason="OPENAI_API_KEY not set"
|
||||
)
|
||||
def test_real_openai_call(self):
|
||||
"""Test real OpenAI API call with routed model."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
handler = RouterHandler(verbose=True)
|
||||
|
||||
# Select model for simple task
|
||||
model = handler.select_model(
|
||||
"What is 2+2?",
|
||||
preferred_provider='openai'
|
||||
)
|
||||
print(f"Selected model: {model}")
|
||||
|
||||
# Create agent with selected model
|
||||
agent = Agent(
|
||||
name="TestAgent",
|
||||
role="Calculator",
|
||||
goal="Answer simple math questions",
|
||||
llm=model,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Run simple task
|
||||
result = agent.start("What is 2+2? Answer with just the number.")
|
||||
print(f"Result: {result}")
|
||||
|
||||
assert '4' in str(result)
|
||||
print("✅ Real OpenAI API call successful")
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv('ANTHROPIC_API_KEY'),
|
||||
reason="ANTHROPIC_API_KEY not set"
|
||||
)
|
||||
def test_real_anthropic_call(self):
|
||||
"""Test real Anthropic API call with routed model."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
handler = RouterHandler(verbose=True)
|
||||
|
||||
# Select Anthropic model
|
||||
model = handler.select_model(
|
||||
"What is the capital of France?",
|
||||
preferred_provider='anthropic'
|
||||
)
|
||||
print(f"Selected model: {model}")
|
||||
|
||||
# Create agent with selected model
|
||||
agent = Agent(
|
||||
name="TestAgent",
|
||||
role="Geography Expert",
|
||||
goal="Answer geography questions",
|
||||
llm=model,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Run task
|
||||
result = agent.start("What is the capital of France? Answer with just the city name.")
|
||||
print(f"Result: {result}")
|
||||
|
||||
assert 'paris' in str(result).lower()
|
||||
print("✅ Real Anthropic API call successful")
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv('GEMINI_API_KEY'),
|
||||
reason="GEMINI_API_KEY not set"
|
||||
)
|
||||
def test_real_gemini_call(self):
|
||||
"""Test real Gemini API call with routed model."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
handler = RouterHandler(verbose=True)
|
||||
|
||||
# Select Google model
|
||||
model = handler.select_model(
|
||||
"What is 3 times 7?",
|
||||
preferred_provider='google'
|
||||
)
|
||||
print(f"Selected model: {model}")
|
||||
|
||||
# Create agent with selected model
|
||||
agent = Agent(
|
||||
name="TestAgent",
|
||||
role="Calculator",
|
||||
goal="Answer math questions",
|
||||
llm=f"gemini/{model}" if not model.startswith("gemini/") else model,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Run task
|
||||
result = agent.start("What is 3 times 7? Answer with just the number.")
|
||||
print(f"Result: {result}")
|
||||
|
||||
assert '21' in str(result)
|
||||
print("✅ Real Gemini API call successful")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Run tests
|
||||
pytest.main([__file__, '-v', '-s'])
|
||||
@@ -0,0 +1,590 @@
|
||||
"""
|
||||
Tests for Autonomy Mode System.
|
||||
|
||||
Test-Driven Development approach for autonomy/approval modes.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from praisonai.cli.features.autonomy_mode import (
|
||||
AutonomyMode,
|
||||
ActionType,
|
||||
ActionRequest,
|
||||
ApprovalResult,
|
||||
AutonomyPolicy,
|
||||
AutonomyManager,
|
||||
AutonomyModeHandler,
|
||||
create_file_write_action,
|
||||
create_shell_command_action,
|
||||
create_git_action,
|
||||
create_tool_call_action,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AutonomyMode Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestAutonomyMode:
|
||||
"""Tests for AutonomyMode enum."""
|
||||
|
||||
def test_mode_values(self):
|
||||
"""Test mode enum values."""
|
||||
assert AutonomyMode.SUGGEST.value == "suggest"
|
||||
assert AutonomyMode.AUTO_EDIT.value == "auto_edit"
|
||||
assert AutonomyMode.FULL_AUTO.value == "full_auto"
|
||||
|
||||
def test_from_string_valid(self):
|
||||
"""Test parsing valid mode strings."""
|
||||
assert AutonomyMode.from_string("suggest") == AutonomyMode.SUGGEST
|
||||
assert AutonomyMode.from_string("auto_edit") == AutonomyMode.AUTO_EDIT
|
||||
assert AutonomyMode.from_string("full_auto") == AutonomyMode.FULL_AUTO
|
||||
|
||||
def test_from_string_with_hyphen(self):
|
||||
"""Test parsing mode strings with hyphens."""
|
||||
assert AutonomyMode.from_string("auto-edit") == AutonomyMode.AUTO_EDIT
|
||||
assert AutonomyMode.from_string("full-auto") == AutonomyMode.FULL_AUTO
|
||||
|
||||
def test_from_string_case_insensitive(self):
|
||||
"""Test case insensitive parsing."""
|
||||
assert AutonomyMode.from_string("SUGGEST") == AutonomyMode.SUGGEST
|
||||
assert AutonomyMode.from_string("Auto_Edit") == AutonomyMode.AUTO_EDIT
|
||||
|
||||
def test_from_string_invalid(self):
|
||||
"""Test parsing invalid mode string."""
|
||||
with pytest.raises(ValueError, match="Unknown autonomy mode"):
|
||||
AutonomyMode.from_string("invalid_mode")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ActionType Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestActionType:
|
||||
"""Tests for ActionType enum."""
|
||||
|
||||
def test_action_types_exist(self):
|
||||
"""Test all action types exist."""
|
||||
assert ActionType.FILE_READ.value == "file_read"
|
||||
assert ActionType.FILE_WRITE.value == "file_write"
|
||||
assert ActionType.FILE_DELETE.value == "file_delete"
|
||||
assert ActionType.SHELL_COMMAND.value == "shell_command"
|
||||
assert ActionType.NETWORK_REQUEST.value == "network_request"
|
||||
assert ActionType.CODE_EXECUTION.value == "code_execution"
|
||||
assert ActionType.GIT_OPERATION.value == "git_operation"
|
||||
assert ActionType.TOOL_CALL.value == "tool_call"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ActionRequest Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestActionRequest:
|
||||
"""Tests for ActionRequest dataclass."""
|
||||
|
||||
def test_create_basic_action(self):
|
||||
"""Test creating a basic action request."""
|
||||
action = ActionRequest(
|
||||
action_type=ActionType.FILE_WRITE,
|
||||
description="Write to config.py"
|
||||
)
|
||||
assert action.action_type == ActionType.FILE_WRITE
|
||||
assert action.description == "Write to config.py"
|
||||
assert action.details == {}
|
||||
assert action.risk_level == "medium"
|
||||
assert action.reversible is True
|
||||
|
||||
def test_create_action_with_details(self):
|
||||
"""Test creating action with details."""
|
||||
action = ActionRequest(
|
||||
action_type=ActionType.SHELL_COMMAND,
|
||||
description="Run tests",
|
||||
details={"command": "pytest", "cwd": "/project"},
|
||||
risk_level="low",
|
||||
reversible=False
|
||||
)
|
||||
assert action.details["command"] == "pytest"
|
||||
assert action.risk_level == "low"
|
||||
assert action.reversible is False
|
||||
|
||||
def test_action_str(self):
|
||||
"""Test action string representation."""
|
||||
action = ActionRequest(
|
||||
action_type=ActionType.FILE_WRITE,
|
||||
description="Write to file.py"
|
||||
)
|
||||
assert "file_write" in str(action)
|
||||
assert "Write to file.py" in str(action)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ApprovalResult Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestApprovalResult:
|
||||
"""Tests for ApprovalResult dataclass."""
|
||||
|
||||
def test_approved_result(self):
|
||||
"""Test approved result."""
|
||||
result = ApprovalResult(approved=True)
|
||||
assert result.approved is True
|
||||
assert result.reason is None
|
||||
assert result.remember_choice is False
|
||||
|
||||
def test_denied_result_with_reason(self):
|
||||
"""Test denied result with reason."""
|
||||
result = ApprovalResult(
|
||||
approved=False,
|
||||
reason="User denied the action"
|
||||
)
|
||||
assert result.approved is False
|
||||
assert result.reason == "User denied the action"
|
||||
|
||||
def test_result_with_remember(self):
|
||||
"""Test result with remember choice."""
|
||||
result = ApprovalResult(
|
||||
approved=True,
|
||||
remember_choice=True
|
||||
)
|
||||
assert result.remember_choice is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AutonomyPolicy Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestAutonomyPolicy:
|
||||
"""Tests for AutonomyPolicy."""
|
||||
|
||||
def test_suggest_mode_policy(self):
|
||||
"""Test policy for suggest mode."""
|
||||
policy = AutonomyPolicy.for_mode(AutonomyMode.SUGGEST)
|
||||
|
||||
assert policy.mode == AutonomyMode.SUGGEST
|
||||
assert ActionType.FILE_READ in policy.auto_approve
|
||||
assert ActionType.FILE_WRITE in policy.require_approval
|
||||
assert ActionType.SHELL_COMMAND in policy.require_approval
|
||||
assert policy.show_preview is True
|
||||
|
||||
def test_auto_edit_mode_policy(self):
|
||||
"""Test policy for auto-edit mode."""
|
||||
policy = AutonomyPolicy.for_mode(AutonomyMode.AUTO_EDIT)
|
||||
|
||||
assert policy.mode == AutonomyMode.AUTO_EDIT
|
||||
assert ActionType.FILE_READ in policy.auto_approve
|
||||
assert ActionType.FILE_WRITE in policy.auto_approve
|
||||
assert ActionType.SHELL_COMMAND in policy.require_approval
|
||||
assert ActionType.FILE_DELETE in policy.require_approval
|
||||
|
||||
def test_full_auto_mode_policy(self):
|
||||
"""Test policy for full-auto mode."""
|
||||
policy = AutonomyPolicy.for_mode(AutonomyMode.FULL_AUTO)
|
||||
|
||||
assert policy.mode == AutonomyMode.FULL_AUTO
|
||||
assert ActionType.FILE_WRITE in policy.auto_approve
|
||||
assert ActionType.SHELL_COMMAND in policy.auto_approve
|
||||
assert ActionType.FILE_DELETE in policy.auto_approve
|
||||
assert len(policy.require_approval) == 0
|
||||
assert policy.max_auto_actions == 100
|
||||
|
||||
def test_custom_policy(self):
|
||||
"""Test creating custom policy."""
|
||||
policy = AutonomyPolicy(
|
||||
mode=AutonomyMode.SUGGEST,
|
||||
auto_approve={ActionType.FILE_READ, ActionType.TOOL_CALL},
|
||||
blocked={ActionType.FILE_DELETE},
|
||||
trusted_paths={"/safe/path"}
|
||||
)
|
||||
|
||||
assert ActionType.TOOL_CALL in policy.auto_approve
|
||||
assert ActionType.FILE_DELETE in policy.blocked
|
||||
assert "/safe/path" in policy.trusted_paths
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AutonomyManager Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestAutonomyManager:
|
||||
"""Tests for AutonomyManager."""
|
||||
|
||||
def test_create_manager_default(self):
|
||||
"""Test creating manager with defaults."""
|
||||
manager = AutonomyManager()
|
||||
|
||||
assert manager.mode == AutonomyMode.SUGGEST
|
||||
assert manager.policy is not None
|
||||
|
||||
def test_create_manager_with_mode(self):
|
||||
"""Test creating manager with specific mode."""
|
||||
manager = AutonomyManager(mode=AutonomyMode.FULL_AUTO)
|
||||
|
||||
assert manager.mode == AutonomyMode.FULL_AUTO
|
||||
|
||||
def test_set_mode(self):
|
||||
"""Test changing mode."""
|
||||
manager = AutonomyManager(mode=AutonomyMode.SUGGEST)
|
||||
manager.set_mode(AutonomyMode.AUTO_EDIT)
|
||||
|
||||
assert manager.mode == AutonomyMode.AUTO_EDIT
|
||||
|
||||
def test_auto_approve_file_read(self):
|
||||
"""Test auto-approval of file read in suggest mode."""
|
||||
manager = AutonomyManager(mode=AutonomyMode.SUGGEST)
|
||||
|
||||
action = ActionRequest(
|
||||
action_type=ActionType.FILE_READ,
|
||||
description="Read config.py"
|
||||
)
|
||||
|
||||
result = manager.request_approval(action)
|
||||
|
||||
assert result.approved is True
|
||||
assert "Auto-approved" in result.reason
|
||||
|
||||
def test_require_approval_file_write_suggest(self):
|
||||
"""Test file write requires approval in suggest mode."""
|
||||
# Mock the approval callback
|
||||
mock_callback = MagicMock(return_value=ApprovalResult(approved=True))
|
||||
manager = AutonomyManager(
|
||||
mode=AutonomyMode.SUGGEST,
|
||||
approval_callback=mock_callback
|
||||
)
|
||||
|
||||
action = ActionRequest(
|
||||
action_type=ActionType.FILE_WRITE,
|
||||
description="Write to config.py"
|
||||
)
|
||||
|
||||
result = manager.request_approval(action)
|
||||
|
||||
assert result.approved is True
|
||||
mock_callback.assert_called_once()
|
||||
|
||||
def test_auto_approve_file_write_auto_edit(self):
|
||||
"""Test file write is auto-approved in auto-edit mode."""
|
||||
manager = AutonomyManager(mode=AutonomyMode.AUTO_EDIT)
|
||||
|
||||
action = ActionRequest(
|
||||
action_type=ActionType.FILE_WRITE,
|
||||
description="Write to config.py"
|
||||
)
|
||||
|
||||
result = manager.request_approval(action)
|
||||
|
||||
assert result.approved is True
|
||||
assert "Auto-approved" in result.reason
|
||||
|
||||
def test_auto_approve_everything_full_auto(self):
|
||||
"""Test everything is auto-approved in full-auto mode."""
|
||||
manager = AutonomyManager(mode=AutonomyMode.FULL_AUTO)
|
||||
|
||||
actions = [
|
||||
ActionRequest(ActionType.FILE_WRITE, "Write file"),
|
||||
ActionRequest(ActionType.SHELL_COMMAND, "Run command"),
|
||||
ActionRequest(ActionType.FILE_DELETE, "Delete file"),
|
||||
]
|
||||
|
||||
for action in actions:
|
||||
result = manager.request_approval(action)
|
||||
assert result.approved is True
|
||||
|
||||
def test_blocked_action(self):
|
||||
"""Test blocked action is denied."""
|
||||
policy = AutonomyPolicy(
|
||||
mode=AutonomyMode.SUGGEST,
|
||||
blocked={ActionType.FILE_DELETE}
|
||||
)
|
||||
manager = AutonomyManager(mode=AutonomyMode.SUGGEST, policy=policy)
|
||||
|
||||
action = ActionRequest(
|
||||
action_type=ActionType.FILE_DELETE,
|
||||
description="Delete important file"
|
||||
)
|
||||
|
||||
result = manager.request_approval(action)
|
||||
|
||||
assert result.approved is False
|
||||
assert "blocked" in result.reason.lower()
|
||||
|
||||
def test_remember_approval(self):
|
||||
"""Test remembered approval decisions."""
|
||||
mock_callback = MagicMock(return_value=ApprovalResult(
|
||||
approved=True,
|
||||
remember_choice=True
|
||||
))
|
||||
manager = AutonomyManager(
|
||||
mode=AutonomyMode.SUGGEST,
|
||||
approval_callback=mock_callback
|
||||
)
|
||||
|
||||
action = ActionRequest(
|
||||
action_type=ActionType.FILE_WRITE,
|
||||
description="Write to config.py",
|
||||
details={"path": "/project/config.py"}
|
||||
)
|
||||
|
||||
# First request - should call callback
|
||||
result1 = manager.request_approval(action)
|
||||
assert result1.approved is True
|
||||
assert mock_callback.call_count == 1
|
||||
|
||||
# Second request - should use remembered choice
|
||||
result2 = manager.request_approval(action)
|
||||
assert result2.approved is True
|
||||
assert mock_callback.call_count == 1 # Not called again
|
||||
|
||||
def test_max_auto_actions(self):
|
||||
"""Test max auto actions limit."""
|
||||
mock_callback = MagicMock(return_value=ApprovalResult(approved=True))
|
||||
policy = AutonomyPolicy(
|
||||
mode=AutonomyMode.SUGGEST,
|
||||
auto_approve={ActionType.FILE_READ},
|
||||
max_auto_actions=3
|
||||
)
|
||||
manager = AutonomyManager(
|
||||
mode=AutonomyMode.SUGGEST,
|
||||
policy=policy,
|
||||
approval_callback=mock_callback
|
||||
)
|
||||
|
||||
action = ActionRequest(ActionType.FILE_READ, "Read file")
|
||||
|
||||
# First 3 should be auto-approved
|
||||
for _ in range(3):
|
||||
result = manager.request_approval(action)
|
||||
assert result.approved is True
|
||||
|
||||
# 4th should require approval (callback called)
|
||||
result = manager.request_approval(action)
|
||||
assert mock_callback.call_count == 1
|
||||
|
||||
def test_get_stats(self):
|
||||
"""Test getting approval statistics."""
|
||||
manager = AutonomyManager(mode=AutonomyMode.FULL_AUTO)
|
||||
|
||||
# Perform some actions
|
||||
manager.request_approval(ActionRequest(ActionType.FILE_READ, "Read"))
|
||||
manager.request_approval(ActionRequest(ActionType.FILE_WRITE, "Write"))
|
||||
|
||||
stats = manager.get_stats()
|
||||
|
||||
assert stats["total_actions"] == 2
|
||||
assert stats["auto_approved"] == 2
|
||||
assert stats["denied"] == 0
|
||||
|
||||
def test_reset_stats(self):
|
||||
"""Test resetting statistics."""
|
||||
manager = AutonomyManager(mode=AutonomyMode.FULL_AUTO)
|
||||
|
||||
manager.request_approval(ActionRequest(ActionType.FILE_READ, "Read"))
|
||||
manager.reset_stats()
|
||||
|
||||
stats = manager.get_stats()
|
||||
assert stats["total_actions"] == 0
|
||||
|
||||
def test_clear_remembered(self):
|
||||
"""Test clearing remembered approvals."""
|
||||
mock_callback = MagicMock(return_value=ApprovalResult(
|
||||
approved=True,
|
||||
remember_choice=True
|
||||
))
|
||||
manager = AutonomyManager(
|
||||
mode=AutonomyMode.SUGGEST,
|
||||
approval_callback=mock_callback
|
||||
)
|
||||
|
||||
action = ActionRequest(ActionType.FILE_WRITE, "Write", details={"path": "/a"})
|
||||
|
||||
# Remember approval
|
||||
manager.request_approval(action)
|
||||
assert mock_callback.call_count == 1
|
||||
|
||||
# Clear remembered
|
||||
manager.clear_remembered()
|
||||
|
||||
# Should call callback again
|
||||
manager.request_approval(action)
|
||||
assert mock_callback.call_count == 2
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AutonomyModeHandler Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestAutonomyModeHandler:
|
||||
"""Tests for AutonomyModeHandler."""
|
||||
|
||||
def test_handler_creation(self):
|
||||
"""Test handler creation."""
|
||||
handler = AutonomyModeHandler()
|
||||
assert handler.feature_name == "autonomy_mode"
|
||||
|
||||
def test_initialize_default(self):
|
||||
"""Test initializing with defaults."""
|
||||
handler = AutonomyModeHandler()
|
||||
manager = handler.initialize()
|
||||
|
||||
assert manager is not None
|
||||
assert manager.mode == AutonomyMode.SUGGEST
|
||||
|
||||
def test_initialize_with_mode(self):
|
||||
"""Test initializing with specific mode."""
|
||||
handler = AutonomyModeHandler()
|
||||
manager = handler.initialize(mode="full_auto")
|
||||
|
||||
assert manager.mode == AutonomyMode.FULL_AUTO
|
||||
|
||||
def test_initialize_invalid_mode(self):
|
||||
"""Test initializing with invalid mode falls back to suggest."""
|
||||
handler = AutonomyModeHandler()
|
||||
manager = handler.initialize(mode="invalid_mode")
|
||||
|
||||
assert manager.mode == AutonomyMode.SUGGEST
|
||||
|
||||
def test_get_manager(self):
|
||||
"""Test getting manager."""
|
||||
handler = AutonomyModeHandler()
|
||||
|
||||
assert handler.get_manager() is None
|
||||
|
||||
handler.initialize()
|
||||
assert handler.get_manager() is not None
|
||||
|
||||
def test_request_approval(self):
|
||||
"""Test requesting approval through handler."""
|
||||
handler = AutonomyModeHandler()
|
||||
handler.initialize(mode="full_auto")
|
||||
|
||||
action = ActionRequest(ActionType.FILE_WRITE, "Write file")
|
||||
result = handler.request_approval(action)
|
||||
|
||||
assert result.approved is True
|
||||
|
||||
def test_set_mode(self):
|
||||
"""Test setting mode through handler."""
|
||||
handler = AutonomyModeHandler()
|
||||
handler.initialize(mode="suggest")
|
||||
|
||||
handler.set_mode("auto_edit")
|
||||
|
||||
assert handler.get_mode() == "auto_edit"
|
||||
|
||||
def test_get_mode(self):
|
||||
"""Test getting mode through handler."""
|
||||
handler = AutonomyModeHandler()
|
||||
|
||||
# Before initialization
|
||||
assert handler.get_mode() == "suggest"
|
||||
|
||||
handler.initialize(mode="full_auto")
|
||||
assert handler.get_mode() == "full_auto"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Convenience Function Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestConvenienceFunctions:
|
||||
"""Tests for convenience functions."""
|
||||
|
||||
def test_create_file_write_action(self):
|
||||
"""Test creating file write action."""
|
||||
action = create_file_write_action("/path/to/file.py", "content preview")
|
||||
|
||||
assert action.action_type == ActionType.FILE_WRITE
|
||||
assert "/path/to/file.py" in action.description
|
||||
assert action.details["path"] == "/path/to/file.py"
|
||||
assert action.risk_level == "medium"
|
||||
assert action.reversible is True
|
||||
|
||||
def test_create_shell_command_action(self):
|
||||
"""Test creating shell command action."""
|
||||
action = create_shell_command_action("pytest tests/", "/project")
|
||||
|
||||
assert action.action_type == ActionType.SHELL_COMMAND
|
||||
assert action.details["command"] == "pytest tests/"
|
||||
assert action.details["cwd"] == "/project"
|
||||
assert action.risk_level == "medium"
|
||||
assert action.reversible is False
|
||||
|
||||
def test_create_shell_command_dangerous(self):
|
||||
"""Test dangerous shell command has high risk."""
|
||||
action = create_shell_command_action("rm -rf /")
|
||||
|
||||
assert action.risk_level == "high"
|
||||
|
||||
def test_create_git_action(self):
|
||||
"""Test creating git action."""
|
||||
action = create_git_action("commit", {"message": "Fix bug"})
|
||||
|
||||
assert action.action_type == ActionType.GIT_OPERATION
|
||||
assert "commit" in action.description
|
||||
assert action.details["message"] == "Fix bug"
|
||||
|
||||
def test_create_git_action_status_low_risk(self):
|
||||
"""Test git status has low risk."""
|
||||
action = create_git_action("status")
|
||||
|
||||
assert action.risk_level == "low"
|
||||
|
||||
def test_create_tool_call_action(self):
|
||||
"""Test creating tool call action."""
|
||||
action = create_tool_call_action("web_search", {"query": "python"})
|
||||
|
||||
assert action.action_type == ActionType.TOOL_CALL
|
||||
assert action.details["tool"] == "web_search"
|
||||
assert action.details["args"]["query"] == "python"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestAutonomyModeIntegration:
|
||||
"""Integration tests for autonomy mode."""
|
||||
|
||||
def test_full_workflow_suggest_mode(self):
|
||||
"""Test full workflow in suggest mode."""
|
||||
approvals = []
|
||||
|
||||
def track_approval(action):
|
||||
approvals.append(action)
|
||||
return ApprovalResult(approved=True)
|
||||
|
||||
handler = AutonomyModeHandler()
|
||||
handler.initialize(mode="suggest", approval_callback=track_approval)
|
||||
|
||||
# File read should be auto-approved
|
||||
read_action = ActionRequest(ActionType.FILE_READ, "Read file")
|
||||
result = handler.request_approval(read_action)
|
||||
assert result.approved is True
|
||||
assert len(approvals) == 0 # No callback for auto-approved
|
||||
|
||||
# File write should require approval
|
||||
write_action = ActionRequest(ActionType.FILE_WRITE, "Write file")
|
||||
result = handler.request_approval(write_action)
|
||||
assert result.approved is True
|
||||
assert len(approvals) == 1 # Callback was called
|
||||
|
||||
def test_mode_transition(self):
|
||||
"""Test transitioning between modes."""
|
||||
handler = AutonomyModeHandler()
|
||||
handler.initialize(mode="suggest")
|
||||
|
||||
# In suggest mode, file write requires approval
|
||||
mock_callback = MagicMock(return_value=ApprovalResult(approved=True))
|
||||
handler._manager.approval_callback = mock_callback
|
||||
|
||||
write_action = ActionRequest(ActionType.FILE_WRITE, "Write")
|
||||
handler.request_approval(write_action)
|
||||
assert mock_callback.call_count == 1
|
||||
|
||||
# Switch to auto-edit mode
|
||||
handler.set_mode("auto_edit")
|
||||
mock_callback.reset_mock()
|
||||
|
||||
# Now file write should be auto-approved
|
||||
handler.request_approval(write_action)
|
||||
assert mock_callback.call_count == 0 # Not called
|
||||
@@ -0,0 +1,620 @@
|
||||
"""
|
||||
Tests for Cost Tracking System.
|
||||
|
||||
Test-Driven Development approach for cost and token tracking.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
|
||||
from praisonai.cli.features.cost_tracker import (
|
||||
ModelPricing,
|
||||
TokenUsage,
|
||||
RequestStats,
|
||||
SessionStats,
|
||||
CostTracker,
|
||||
CostTrackerHandler,
|
||||
get_pricing,
|
||||
DEFAULT_PRICING,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ModelPricing Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestModelPricing:
|
||||
"""Tests for ModelPricing dataclass."""
|
||||
|
||||
def test_create_pricing(self):
|
||||
"""Test creating model pricing."""
|
||||
pricing = ModelPricing(
|
||||
model_name="gpt-4o",
|
||||
input_price_per_1m=2.50,
|
||||
output_price_per_1m=10.00
|
||||
)
|
||||
assert pricing.model_name == "gpt-4o"
|
||||
assert pricing.input_price_per_1m == 2.50
|
||||
assert pricing.output_price_per_1m == 10.00
|
||||
assert pricing.context_window == 128000
|
||||
|
||||
def test_calculate_cost(self):
|
||||
"""Test cost calculation."""
|
||||
pricing = ModelPricing(
|
||||
model_name="test",
|
||||
input_price_per_1m=1.00,
|
||||
output_price_per_1m=2.00
|
||||
)
|
||||
|
||||
# 1000 input tokens, 500 output tokens
|
||||
cost = pricing.calculate_cost(1000, 500)
|
||||
|
||||
# Expected: (1000/1M * 1.00) + (500/1M * 2.00) = 0.001 + 0.001 = 0.002
|
||||
assert cost == pytest.approx(0.002, rel=1e-6)
|
||||
|
||||
def test_calculate_cost_large_tokens(self):
|
||||
"""Test cost calculation with large token counts."""
|
||||
pricing = ModelPricing(
|
||||
model_name="test",
|
||||
input_price_per_1m=2.50,
|
||||
output_price_per_1m=10.00
|
||||
)
|
||||
|
||||
# 100K input, 50K output
|
||||
cost = pricing.calculate_cost(100_000, 50_000)
|
||||
|
||||
# Expected: (100K/1M * 2.50) + (50K/1M * 10.00) = 0.25 + 0.50 = 0.75
|
||||
assert cost == pytest.approx(0.75, rel=1e-6)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# get_pricing Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestGetPricing:
|
||||
"""Tests for get_pricing function."""
|
||||
|
||||
def test_get_exact_match(self):
|
||||
"""Test getting pricing with exact model name."""
|
||||
pricing = get_pricing("gpt-4o")
|
||||
assert pricing.model_name == "gpt-4o"
|
||||
|
||||
def test_get_partial_match(self):
|
||||
"""Test getting pricing with partial model name."""
|
||||
pricing = get_pricing("gpt-4o-2024-08-06")
|
||||
assert "gpt-4o" in pricing.model_name.lower() or pricing == DEFAULT_PRICING["default"]
|
||||
|
||||
def test_get_unknown_model(self):
|
||||
"""Test getting pricing for unknown model returns default."""
|
||||
pricing = get_pricing("unknown-model-xyz")
|
||||
assert pricing == DEFAULT_PRICING["default"]
|
||||
|
||||
def test_default_pricing_exists(self):
|
||||
"""Test that default pricing models exist."""
|
||||
assert "gpt-4o" in DEFAULT_PRICING
|
||||
assert "gpt-4o-mini" in DEFAULT_PRICING
|
||||
assert "claude-3-5-sonnet" in DEFAULT_PRICING
|
||||
assert "gemini-2.0-flash" in DEFAULT_PRICING
|
||||
assert "default" in DEFAULT_PRICING
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TokenUsage Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestTokenUsage:
|
||||
"""Tests for TokenUsage dataclass."""
|
||||
|
||||
def test_create_usage(self):
|
||||
"""Test creating token usage."""
|
||||
usage = TokenUsage(
|
||||
input_tokens=100,
|
||||
output_tokens=50
|
||||
)
|
||||
assert usage.input_tokens == 100
|
||||
assert usage.output_tokens == 50
|
||||
assert usage.total_tokens == 150
|
||||
assert usage.cached_tokens == 0
|
||||
|
||||
def test_usage_with_cached(self):
|
||||
"""Test usage with cached tokens."""
|
||||
usage = TokenUsage(
|
||||
input_tokens=100,
|
||||
output_tokens=50,
|
||||
cached_tokens=30
|
||||
)
|
||||
assert usage.cached_tokens == 30
|
||||
|
||||
def test_total_tokens_auto_calculated(self):
|
||||
"""Test total tokens is auto-calculated."""
|
||||
usage = TokenUsage(input_tokens=200, output_tokens=100)
|
||||
assert usage.total_tokens == 300
|
||||
|
||||
def test_total_tokens_explicit(self):
|
||||
"""Test explicit total tokens is preserved."""
|
||||
usage = TokenUsage(
|
||||
input_tokens=100,
|
||||
output_tokens=50,
|
||||
total_tokens=200 # Explicit value
|
||||
)
|
||||
assert usage.total_tokens == 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RequestStats Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestRequestStats:
|
||||
"""Tests for RequestStats dataclass."""
|
||||
|
||||
def test_create_request_stats(self):
|
||||
"""Test creating request stats."""
|
||||
usage = TokenUsage(input_tokens=100, output_tokens=50)
|
||||
stats = RequestStats(
|
||||
timestamp=datetime.now(),
|
||||
model="gpt-4o",
|
||||
usage=usage,
|
||||
cost=0.001
|
||||
)
|
||||
assert stats.model == "gpt-4o"
|
||||
assert stats.usage.total_tokens == 150
|
||||
assert stats.cost == 0.001
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test converting to dictionary."""
|
||||
usage = TokenUsage(input_tokens=100, output_tokens=50)
|
||||
stats = RequestStats(
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
model="gpt-4o",
|
||||
usage=usage,
|
||||
cost=0.001,
|
||||
duration_ms=500.0
|
||||
)
|
||||
|
||||
data = stats.to_dict()
|
||||
|
||||
assert data["model"] == "gpt-4o"
|
||||
assert data["input_tokens"] == 100
|
||||
assert data["output_tokens"] == 50
|
||||
assert data["cost"] == 0.001
|
||||
assert data["duration_ms"] == 500.0
|
||||
assert "timestamp" in data
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SessionStats Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestSessionStats:
|
||||
"""Tests for SessionStats dataclass."""
|
||||
|
||||
def test_create_session_stats(self):
|
||||
"""Test creating session stats."""
|
||||
stats = SessionStats(
|
||||
session_id="test-123",
|
||||
start_time=datetime.now()
|
||||
)
|
||||
assert stats.session_id == "test-123"
|
||||
assert stats.total_requests == 0
|
||||
assert stats.total_cost == 0.0
|
||||
|
||||
def test_add_request(self):
|
||||
"""Test adding request to session."""
|
||||
session = SessionStats(
|
||||
session_id="test",
|
||||
start_time=datetime.now()
|
||||
)
|
||||
|
||||
usage = TokenUsage(input_tokens=100, output_tokens=50)
|
||||
request = RequestStats(
|
||||
timestamp=datetime.now(),
|
||||
model="gpt-4o",
|
||||
usage=usage,
|
||||
cost=0.001
|
||||
)
|
||||
|
||||
session.add_request(request)
|
||||
|
||||
assert session.total_requests == 1
|
||||
assert session.total_input_tokens == 100
|
||||
assert session.total_output_tokens == 50
|
||||
assert session.total_tokens == 150
|
||||
assert session.total_cost == 0.001
|
||||
assert "gpt-4o" in session.models_used
|
||||
|
||||
def test_add_multiple_requests(self):
|
||||
"""Test adding multiple requests."""
|
||||
session = SessionStats(
|
||||
session_id="test",
|
||||
start_time=datetime.now()
|
||||
)
|
||||
|
||||
for i in range(3):
|
||||
usage = TokenUsage(input_tokens=100, output_tokens=50)
|
||||
request = RequestStats(
|
||||
timestamp=datetime.now(),
|
||||
model="gpt-4o",
|
||||
usage=usage,
|
||||
cost=0.001
|
||||
)
|
||||
session.add_request(request)
|
||||
|
||||
assert session.total_requests == 3
|
||||
assert session.total_tokens == 450
|
||||
assert session.total_cost == pytest.approx(0.003)
|
||||
|
||||
def test_avg_tokens_per_request(self):
|
||||
"""Test average tokens per request."""
|
||||
session = SessionStats(
|
||||
session_id="test",
|
||||
start_time=datetime.now()
|
||||
)
|
||||
|
||||
for tokens in [100, 200, 300]:
|
||||
usage = TokenUsage(input_tokens=tokens, output_tokens=0)
|
||||
request = RequestStats(
|
||||
timestamp=datetime.now(),
|
||||
model="gpt-4o",
|
||||
usage=usage,
|
||||
cost=0.0
|
||||
)
|
||||
session.add_request(request)
|
||||
|
||||
assert session.avg_tokens_per_request == 200.0
|
||||
|
||||
def test_avg_cost_per_request(self):
|
||||
"""Test average cost per request."""
|
||||
session = SessionStats(
|
||||
session_id="test",
|
||||
start_time=datetime.now()
|
||||
)
|
||||
|
||||
for cost in [0.001, 0.002, 0.003]:
|
||||
usage = TokenUsage(input_tokens=100, output_tokens=50)
|
||||
request = RequestStats(
|
||||
timestamp=datetime.now(),
|
||||
model="gpt-4o",
|
||||
usage=usage,
|
||||
cost=cost
|
||||
)
|
||||
session.add_request(request)
|
||||
|
||||
assert session.avg_cost_per_request == pytest.approx(0.002)
|
||||
|
||||
def test_duration_seconds(self):
|
||||
"""Test session duration calculation."""
|
||||
start = datetime.now() - timedelta(seconds=60)
|
||||
session = SessionStats(
|
||||
session_id="test",
|
||||
start_time=start
|
||||
)
|
||||
|
||||
assert session.duration_seconds >= 60
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test converting to dictionary."""
|
||||
session = SessionStats(
|
||||
session_id="test-123",
|
||||
start_time=datetime(2024, 1, 1, 12, 0, 0)
|
||||
)
|
||||
|
||||
data = session.to_dict()
|
||||
|
||||
assert data["session_id"] == "test-123"
|
||||
assert data["total_requests"] == 0
|
||||
assert "start_time" in data
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CostTracker Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestCostTracker:
|
||||
"""Tests for CostTracker."""
|
||||
|
||||
def test_create_tracker(self):
|
||||
"""Test creating cost tracker."""
|
||||
tracker = CostTracker()
|
||||
|
||||
assert tracker.session_id is not None
|
||||
assert len(tracker.session_id) == 8
|
||||
assert tracker.session_stats is not None
|
||||
|
||||
def test_create_tracker_with_session_id(self):
|
||||
"""Test creating tracker with custom session ID."""
|
||||
tracker = CostTracker(session_id="custom-id")
|
||||
|
||||
assert tracker.session_id == "custom-id"
|
||||
|
||||
def test_track_request(self):
|
||||
"""Test tracking a request."""
|
||||
tracker = CostTracker()
|
||||
|
||||
stats = tracker.track_request(
|
||||
model="gpt-4o",
|
||||
input_tokens=1000,
|
||||
output_tokens=500
|
||||
)
|
||||
|
||||
assert stats is not None
|
||||
assert stats.model == "gpt-4o"
|
||||
assert stats.usage.input_tokens == 1000
|
||||
assert stats.usage.output_tokens == 500
|
||||
assert stats.cost > 0
|
||||
|
||||
def test_track_multiple_requests(self):
|
||||
"""Test tracking multiple requests."""
|
||||
tracker = CostTracker()
|
||||
|
||||
tracker.track_request("gpt-4o", 1000, 500)
|
||||
tracker.track_request("gpt-4o-mini", 2000, 1000)
|
||||
tracker.track_request("gpt-4o", 500, 250)
|
||||
|
||||
assert tracker.get_request_count() == 3
|
||||
assert tracker.get_total_tokens() == 5250
|
||||
assert tracker.get_total_cost() > 0
|
||||
|
||||
def test_track_with_cached_tokens(self):
|
||||
"""Test tracking with cached tokens."""
|
||||
tracker = CostTracker()
|
||||
|
||||
stats = tracker.track_request(
|
||||
model="gpt-4o",
|
||||
input_tokens=1000,
|
||||
output_tokens=500,
|
||||
cached_tokens=200
|
||||
)
|
||||
|
||||
assert stats.usage.cached_tokens == 200
|
||||
assert tracker.session_stats.total_cached_tokens == 200
|
||||
|
||||
def test_track_with_duration(self):
|
||||
"""Test tracking with duration."""
|
||||
tracker = CostTracker()
|
||||
|
||||
stats = tracker.track_request(
|
||||
model="gpt-4o",
|
||||
input_tokens=1000,
|
||||
output_tokens=500,
|
||||
duration_ms=1500.0
|
||||
)
|
||||
|
||||
assert stats.duration_ms == 1500.0
|
||||
|
||||
def test_get_current_stats(self):
|
||||
"""Test getting current stats."""
|
||||
tracker = CostTracker()
|
||||
tracker.track_request("gpt-4o", 1000, 500)
|
||||
|
||||
stats = tracker.get_current_stats()
|
||||
|
||||
assert stats.total_requests == 1
|
||||
assert stats.total_tokens == 1500
|
||||
|
||||
def test_format_summary(self):
|
||||
"""Test formatting summary."""
|
||||
tracker = CostTracker(session_id="test-123")
|
||||
tracker.track_request("gpt-4o", 1000, 500)
|
||||
|
||||
summary = tracker.format_summary()
|
||||
|
||||
assert "test-123" in summary
|
||||
assert "Tokens:" in summary
|
||||
assert "Cost:" in summary
|
||||
|
||||
def test_export_json(self):
|
||||
"""Test exporting to JSON."""
|
||||
tracker = CostTracker(session_id="test-123")
|
||||
tracker.track_request("gpt-4o", 1000, 500)
|
||||
|
||||
json_str = tracker.export_json()
|
||||
data = json.loads(json_str)
|
||||
|
||||
assert "session" in data
|
||||
assert "requests" in data
|
||||
assert data["session"]["session_id"] == "test-123"
|
||||
assert len(data["requests"]) == 1
|
||||
|
||||
def test_end_session(self):
|
||||
"""Test ending session."""
|
||||
tracker = CostTracker()
|
||||
tracker.track_request("gpt-4o", 1000, 500)
|
||||
|
||||
stats = tracker.end_session()
|
||||
|
||||
assert stats.end_time is not None
|
||||
|
||||
def test_models_used_tracking(self):
|
||||
"""Test tracking of models used."""
|
||||
tracker = CostTracker()
|
||||
|
||||
tracker.track_request("gpt-4o", 1000, 500)
|
||||
tracker.track_request("gpt-4o", 1000, 500)
|
||||
tracker.track_request("gpt-4o-mini", 1000, 500)
|
||||
|
||||
stats = tracker.get_current_stats()
|
||||
|
||||
assert stats.models_used["gpt-4o"] == 2
|
||||
assert stats.models_used["gpt-4o-mini"] == 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CostTracker Response Tracking Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestCostTrackerResponseTracking:
|
||||
"""Tests for tracking from LLM responses."""
|
||||
|
||||
def test_track_from_openai_response(self):
|
||||
"""Test tracking from OpenAI-style response."""
|
||||
tracker = CostTracker()
|
||||
|
||||
# Mock OpenAI response
|
||||
mock_response = MagicMock()
|
||||
mock_response.usage = MagicMock()
|
||||
mock_response.usage.prompt_tokens = 100
|
||||
mock_response.usage.completion_tokens = 50
|
||||
mock_response.usage.cached_tokens = 0
|
||||
|
||||
stats = tracker.track_from_response("gpt-4o", mock_response)
|
||||
|
||||
assert stats is not None
|
||||
assert stats.usage.input_tokens == 100
|
||||
assert stats.usage.output_tokens == 50
|
||||
|
||||
def test_track_from_dict_response(self):
|
||||
"""Test tracking from dict response."""
|
||||
tracker = CostTracker()
|
||||
|
||||
response = {
|
||||
"usage": {
|
||||
"prompt_tokens": 200,
|
||||
"completion_tokens": 100
|
||||
}
|
||||
}
|
||||
|
||||
stats = tracker.track_from_response("gpt-4o", response)
|
||||
|
||||
assert stats is not None
|
||||
assert stats.usage.input_tokens == 200
|
||||
assert stats.usage.output_tokens == 100
|
||||
|
||||
def test_track_from_invalid_response(self):
|
||||
"""Test tracking from invalid response returns None."""
|
||||
tracker = CostTracker()
|
||||
|
||||
stats = tracker.track_from_response("gpt-4o", "invalid response")
|
||||
|
||||
assert stats is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CostTrackerHandler Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestCostTrackerHandler:
|
||||
"""Tests for CostTrackerHandler."""
|
||||
|
||||
def test_handler_creation(self):
|
||||
"""Test handler creation."""
|
||||
handler = CostTrackerHandler()
|
||||
assert handler.feature_name == "cost_tracker"
|
||||
|
||||
def test_initialize(self):
|
||||
"""Test initializing tracker."""
|
||||
handler = CostTrackerHandler()
|
||||
tracker = handler.initialize()
|
||||
|
||||
assert tracker is not None
|
||||
assert handler.get_tracker() is tracker
|
||||
|
||||
def test_initialize_with_session_id(self):
|
||||
"""Test initializing with session ID."""
|
||||
handler = CostTrackerHandler()
|
||||
tracker = handler.initialize(session_id="custom-123")
|
||||
|
||||
assert tracker.session_id == "custom-123"
|
||||
|
||||
def test_track_request(self):
|
||||
"""Test tracking request through handler."""
|
||||
handler = CostTrackerHandler()
|
||||
|
||||
stats = handler.track_request(
|
||||
model="gpt-4o",
|
||||
input_tokens=1000,
|
||||
output_tokens=500
|
||||
)
|
||||
|
||||
assert stats is not None
|
||||
assert handler.get_cost() > 0
|
||||
assert handler.get_tokens() == 1500
|
||||
|
||||
def test_get_summary(self):
|
||||
"""Test getting summary."""
|
||||
handler = CostTrackerHandler()
|
||||
handler.track_request("gpt-4o", 1000, 500)
|
||||
|
||||
summary = handler.get_summary()
|
||||
|
||||
assert "total_requests" in summary
|
||||
assert summary["total_requests"] == 1
|
||||
|
||||
def test_get_cost_before_init(self):
|
||||
"""Test getting cost before initialization."""
|
||||
handler = CostTrackerHandler()
|
||||
|
||||
assert handler.get_cost() == 0.0
|
||||
|
||||
def test_get_tokens_before_init(self):
|
||||
"""Test getting tokens before initialization."""
|
||||
handler = CostTrackerHandler()
|
||||
|
||||
assert handler.get_tokens() == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestCostTrackerIntegration:
|
||||
"""Integration tests for cost tracking."""
|
||||
|
||||
def test_full_session_workflow(self):
|
||||
"""Test full session workflow."""
|
||||
handler = CostTrackerHandler(verbose=False)
|
||||
handler.initialize(session_id="integration-test")
|
||||
|
||||
# Simulate multiple requests
|
||||
models = ["gpt-4o", "gpt-4o-mini", "gpt-4o"]
|
||||
for i, model in enumerate(models):
|
||||
handler.track_request(
|
||||
model=model,
|
||||
input_tokens=1000 * (i + 1),
|
||||
output_tokens=500 * (i + 1),
|
||||
duration_ms=100.0 * (i + 1)
|
||||
)
|
||||
|
||||
# Verify totals
|
||||
summary = handler.get_summary()
|
||||
|
||||
assert summary["total_requests"] == 3
|
||||
assert summary["total_input_tokens"] == 6000 # 1000 + 2000 + 3000
|
||||
assert summary["total_output_tokens"] == 3000 # 500 + 1000 + 1500
|
||||
assert summary["total_tokens"] == 9000
|
||||
assert handler.get_cost() > 0
|
||||
|
||||
def test_cost_calculation_accuracy(self):
|
||||
"""Test cost calculation accuracy."""
|
||||
tracker = CostTracker()
|
||||
|
||||
# Use known pricing
|
||||
# gpt-4o: $2.50/1M input, $10.00/1M output
|
||||
stats = tracker.track_request(
|
||||
model="gpt-4o",
|
||||
input_tokens=1_000_000, # 1M tokens
|
||||
output_tokens=1_000_000 # 1M tokens
|
||||
)
|
||||
|
||||
# Expected: $2.50 + $10.00 = $12.50
|
||||
assert stats.cost == pytest.approx(12.50, rel=0.01)
|
||||
|
||||
def test_session_persistence(self):
|
||||
"""Test session data can be exported and contains all info."""
|
||||
tracker = CostTracker(session_id="persist-test")
|
||||
|
||||
tracker.track_request("gpt-4o", 1000, 500)
|
||||
tracker.track_request("gpt-4o-mini", 2000, 1000)
|
||||
|
||||
json_str = tracker.export_json()
|
||||
data = json.loads(json_str)
|
||||
|
||||
# Verify structure
|
||||
assert data["session"]["session_id"] == "persist-test"
|
||||
assert len(data["requests"]) == 2
|
||||
assert data["session"]["total_requests"] == 2
|
||||
|
||||
# Verify request details preserved
|
||||
assert data["requests"][0]["model"] == "gpt-4o"
|
||||
assert data["requests"][1]["model"] == "gpt-4o-mini"
|
||||
@@ -0,0 +1,368 @@
|
||||
"""
|
||||
Tests for Git Integration System.
|
||||
|
||||
Test-Driven Development approach for Git operations.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import subprocess
|
||||
|
||||
from praisonai.cli.features.git_integration import (
|
||||
GitStatus,
|
||||
GitCommit,
|
||||
GitManager,
|
||||
CommitMessageGenerator,
|
||||
DiffViewer,
|
||||
GitIntegrationHandler,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GitStatus Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestGitStatus:
|
||||
"""Tests for GitStatus dataclass."""
|
||||
|
||||
def test_create_status(self):
|
||||
"""Test creating git status."""
|
||||
status = GitStatus()
|
||||
assert status.branch == ""
|
||||
assert status.is_clean is True
|
||||
assert status.staged_files == []
|
||||
|
||||
def test_has_changes(self):
|
||||
"""Test has_changes property."""
|
||||
status = GitStatus()
|
||||
assert status.has_changes is False
|
||||
|
||||
status.modified_files = ["file.py"]
|
||||
assert status.has_changes is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GitCommit Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestGitCommit:
|
||||
"""Tests for GitCommit dataclass."""
|
||||
|
||||
def test_create_commit(self):
|
||||
"""Test creating git commit."""
|
||||
commit = GitCommit(
|
||||
hash="abc123def456",
|
||||
short_hash="abc123d",
|
||||
message="Test commit",
|
||||
author="Test User",
|
||||
date="2024-01-01"
|
||||
)
|
||||
assert commit.hash == "abc123def456"
|
||||
assert commit.message == "Test commit"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GitManager Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestGitManager:
|
||||
"""Tests for GitManager."""
|
||||
|
||||
def test_create_manager(self):
|
||||
"""Test creating git manager."""
|
||||
manager = GitManager()
|
||||
assert manager is not None
|
||||
|
||||
def test_is_repo_false_for_non_repo(self):
|
||||
"""Test is_repo returns False for non-repo."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manager = GitManager(repo_path=tmpdir)
|
||||
assert manager.is_repo is False
|
||||
|
||||
def test_is_repo_true_for_repo(self):
|
||||
"""Test is_repo returns True for git repo."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Initialize git repo
|
||||
subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True)
|
||||
|
||||
manager = GitManager(repo_path=tmpdir)
|
||||
assert manager.is_repo is True
|
||||
|
||||
def test_get_status_empty_repo(self):
|
||||
"""Test getting status of empty repo."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True)
|
||||
|
||||
manager = GitManager(repo_path=tmpdir)
|
||||
status = manager.get_status()
|
||||
|
||||
assert status.is_clean is True
|
||||
|
||||
def test_get_status_with_changes(self):
|
||||
"""Test getting status with changes."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True)
|
||||
|
||||
# Create a file
|
||||
(Path(tmpdir) / "test.py").write_text("# test")
|
||||
|
||||
manager = GitManager(repo_path=tmpdir)
|
||||
status = manager.get_status()
|
||||
|
||||
assert "test.py" in status.untracked_files
|
||||
|
||||
def test_stage_files(self):
|
||||
"""Test staging files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True)
|
||||
(Path(tmpdir) / "test.py").write_text("# test")
|
||||
|
||||
manager = GitManager(repo_path=tmpdir)
|
||||
result = manager.stage_files(["test.py"])
|
||||
|
||||
assert result is True
|
||||
|
||||
status = manager.get_status()
|
||||
assert "test.py" in status.staged_files
|
||||
|
||||
def test_commit(self):
|
||||
"""Test creating a commit."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "[email protected]"],
|
||||
cwd=tmpdir, capture_output=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test"],
|
||||
cwd=tmpdir, capture_output=True
|
||||
)
|
||||
|
||||
(Path(tmpdir) / "test.py").write_text("# test")
|
||||
|
||||
manager = GitManager(repo_path=tmpdir)
|
||||
manager.stage_files()
|
||||
|
||||
commit = manager.commit("Test commit")
|
||||
|
||||
assert commit is not None
|
||||
assert commit.message == "Test commit"
|
||||
|
||||
def test_get_log(self):
|
||||
"""Test getting commit log."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "[email protected]"],
|
||||
cwd=tmpdir, capture_output=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test"],
|
||||
cwd=tmpdir, capture_output=True
|
||||
)
|
||||
|
||||
(Path(tmpdir) / "test.py").write_text("# test")
|
||||
|
||||
manager = GitManager(repo_path=tmpdir)
|
||||
manager.stage_files()
|
||||
manager.commit("First commit")
|
||||
|
||||
commits = manager.get_log()
|
||||
|
||||
assert len(commits) >= 1
|
||||
assert commits[0].message == "First commit"
|
||||
|
||||
def test_undo_last_commit(self):
|
||||
"""Test undoing last commit."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "[email protected]"],
|
||||
cwd=tmpdir, capture_output=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test"],
|
||||
cwd=tmpdir, capture_output=True
|
||||
)
|
||||
|
||||
# Need at least 2 commits to undo one
|
||||
(Path(tmpdir) / "test.py").write_text("# test")
|
||||
|
||||
manager = GitManager(repo_path=tmpdir)
|
||||
manager.stage_files()
|
||||
manager.commit("First commit")
|
||||
|
||||
(Path(tmpdir) / "test2.py").write_text("# test2")
|
||||
manager.stage_files()
|
||||
manager.commit("Second commit")
|
||||
|
||||
result = manager.undo_last_commit(soft=True)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_get_branches(self):
|
||||
"""Test getting branches."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "[email protected]"],
|
||||
cwd=tmpdir, capture_output=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test"],
|
||||
cwd=tmpdir, capture_output=True
|
||||
)
|
||||
|
||||
(Path(tmpdir) / "test.py").write_text("# test")
|
||||
|
||||
manager = GitManager(repo_path=tmpdir)
|
||||
manager.stage_files()
|
||||
manager.commit("Initial commit")
|
||||
|
||||
branches = manager.get_branches()
|
||||
|
||||
assert len(branches) >= 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CommitMessageGenerator Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestCommitMessageGenerator:
|
||||
"""Tests for CommitMessageGenerator."""
|
||||
|
||||
def test_create_generator(self):
|
||||
"""Test creating generator."""
|
||||
generator = CommitMessageGenerator()
|
||||
assert generator is not None
|
||||
|
||||
def test_generate_empty_diff(self):
|
||||
"""Test generating message for empty diff."""
|
||||
generator = CommitMessageGenerator(use_ai=False)
|
||||
message = generator.generate("")
|
||||
|
||||
assert message == "Empty commit"
|
||||
|
||||
def test_generate_from_diff(self):
|
||||
"""Test generating message from diff."""
|
||||
generator = CommitMessageGenerator(use_ai=False)
|
||||
|
||||
diff = """
|
||||
diff --git a/test.py b/test.py
|
||||
--- a/test.py
|
||||
+++ b/test.py
|
||||
@@ -1 +1,5 @@
|
||||
+# New content
|
||||
+def hello():
|
||||
+ pass
|
||||
"""
|
||||
|
||||
message = generator.generate(diff)
|
||||
|
||||
assert len(message) > 0
|
||||
assert "test" in message.lower() or "feat" in message.lower()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DiffViewer Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestDiffViewer:
|
||||
"""Tests for DiffViewer."""
|
||||
|
||||
def test_create_viewer(self):
|
||||
"""Test creating viewer."""
|
||||
viewer = DiffViewer()
|
||||
assert viewer is not None
|
||||
|
||||
def test_display_diff_no_console(self):
|
||||
"""Test displaying diff without console."""
|
||||
viewer = DiffViewer()
|
||||
viewer._console = None
|
||||
|
||||
# Should not raise
|
||||
viewer.display_diff("test diff")
|
||||
|
||||
def test_display_status_no_console(self):
|
||||
"""Test displaying status without console."""
|
||||
viewer = DiffViewer()
|
||||
viewer._console = None
|
||||
|
||||
status = GitStatus(branch="main")
|
||||
viewer.display_status(status)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GitIntegrationHandler Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestGitIntegrationHandler:
|
||||
"""Tests for GitIntegrationHandler."""
|
||||
|
||||
def test_handler_creation(self):
|
||||
"""Test handler creation."""
|
||||
handler = GitIntegrationHandler()
|
||||
assert handler.feature_name == "git_integration"
|
||||
|
||||
def test_initialize(self):
|
||||
"""Test initializing handler."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True)
|
||||
|
||||
handler = GitIntegrationHandler()
|
||||
git = handler.initialize(repo_path=tmpdir)
|
||||
|
||||
assert git is not None
|
||||
assert git.is_repo is True
|
||||
|
||||
def test_show_status(self):
|
||||
"""Test showing status."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True)
|
||||
|
||||
handler = GitIntegrationHandler()
|
||||
handler.initialize(repo_path=tmpdir)
|
||||
|
||||
status = handler.show_status()
|
||||
|
||||
assert status is not None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestGitIntegrationIntegration:
|
||||
"""Integration tests for Git integration."""
|
||||
|
||||
def test_full_workflow(self):
|
||||
"""Test full git workflow."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Initialize repo
|
||||
subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "[email protected]"],
|
||||
cwd=tmpdir, capture_output=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test"],
|
||||
cwd=tmpdir, capture_output=True
|
||||
)
|
||||
|
||||
handler = GitIntegrationHandler()
|
||||
handler.initialize(repo_path=tmpdir)
|
||||
|
||||
# Create file
|
||||
(Path(tmpdir) / "main.py").write_text("# Main file\n")
|
||||
|
||||
# Check status
|
||||
status = handler.show_status()
|
||||
assert "main.py" in status.untracked_files
|
||||
|
||||
# Commit
|
||||
commit = handler.commit(message="Add main.py")
|
||||
assert commit is not None
|
||||
|
||||
# Check log
|
||||
commits = handler.show_log()
|
||||
assert len(commits) >= 1
|
||||
@@ -0,0 +1,460 @@
|
||||
"""
|
||||
Tests for Interactive TUI System.
|
||||
|
||||
Test-Driven Development approach for interactive terminal interface.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from praisonai.cli.features.interactive_tui import (
|
||||
InteractiveConfig,
|
||||
CommandCompleter,
|
||||
HistoryManager,
|
||||
StatusDisplay,
|
||||
InteractiveSession,
|
||||
InteractiveTUIHandler,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# InteractiveConfig Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestInteractiveConfig:
|
||||
"""Tests for InteractiveConfig."""
|
||||
|
||||
def test_default_config(self):
|
||||
"""Test default configuration."""
|
||||
config = InteractiveConfig()
|
||||
|
||||
assert config.prompt == ">>> "
|
||||
assert config.multiline is True
|
||||
assert config.enable_completions is True
|
||||
assert config.vi_mode is False
|
||||
|
||||
def test_custom_config(self):
|
||||
"""Test custom configuration."""
|
||||
config = InteractiveConfig(
|
||||
prompt="$ ",
|
||||
multiline=False,
|
||||
vi_mode=True
|
||||
)
|
||||
|
||||
assert config.prompt == "$ "
|
||||
assert config.multiline is False
|
||||
assert config.vi_mode is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CommandCompleter Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestCommandCompleter:
|
||||
"""Tests for CommandCompleter."""
|
||||
|
||||
def test_create_completer(self):
|
||||
"""Test creating completer."""
|
||||
completer = CommandCompleter()
|
||||
assert completer is not None
|
||||
|
||||
def test_add_commands(self):
|
||||
"""Test adding commands."""
|
||||
completer = CommandCompleter()
|
||||
completer.add_commands(["help", "exit", "cost"])
|
||||
|
||||
assert "help" in completer.commands
|
||||
assert "exit" in completer.commands
|
||||
|
||||
def test_complete_slash_command(self):
|
||||
"""Test completing slash commands."""
|
||||
completer = CommandCompleter(commands=["help", "history", "exit"])
|
||||
|
||||
completions = completer.get_completions("/he", 3)
|
||||
|
||||
assert "/help" in completions
|
||||
# /history should also match but may be filtered
|
||||
assert "/exit" not in completions
|
||||
|
||||
def test_complete_file_mention(self):
|
||||
"""Test completing file mentions."""
|
||||
completer = CommandCompleter()
|
||||
completer._file_cache = ["main.py", "config.py", "utils.py"]
|
||||
|
||||
completions = completer.get_completions("@main", 5)
|
||||
|
||||
assert "@main.py" in completions
|
||||
|
||||
def test_add_symbols(self):
|
||||
"""Test adding symbols."""
|
||||
completer = CommandCompleter()
|
||||
completer.add_symbols(["MyClass", "my_function"])
|
||||
|
||||
assert "MyClass" in completer._symbol_cache
|
||||
|
||||
def test_refresh_files(self):
|
||||
"""Test refreshing file cache."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "test.py").write_text("# test")
|
||||
(Path(tmpdir) / "main.py").write_text("# main")
|
||||
|
||||
completer = CommandCompleter()
|
||||
completer.refresh_files(Path(tmpdir))
|
||||
|
||||
assert len(completer._file_cache) >= 2
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HistoryManager Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestHistoryManager:
|
||||
"""Tests for HistoryManager."""
|
||||
|
||||
def test_create_manager(self):
|
||||
"""Test creating history manager."""
|
||||
manager = HistoryManager()
|
||||
assert manager is not None
|
||||
|
||||
def test_add_entry(self):
|
||||
"""Test adding history entry."""
|
||||
manager = HistoryManager()
|
||||
manager.add("first command")
|
||||
manager.add("second command")
|
||||
|
||||
assert len(manager.get_all()) == 2
|
||||
|
||||
def test_no_duplicate_consecutive(self):
|
||||
"""Test no duplicate consecutive entries."""
|
||||
manager = HistoryManager()
|
||||
manager.add("same command")
|
||||
manager.add("same command")
|
||||
|
||||
assert len(manager.get_all()) == 1
|
||||
|
||||
def test_get_previous(self):
|
||||
"""Test getting previous entry."""
|
||||
manager = HistoryManager()
|
||||
manager.add("first")
|
||||
manager.add("second")
|
||||
manager.add("third")
|
||||
|
||||
assert manager.get_previous() == "third"
|
||||
assert manager.get_previous() == "second"
|
||||
assert manager.get_previous() == "first"
|
||||
|
||||
def test_get_next(self):
|
||||
"""Test getting next entry."""
|
||||
manager = HistoryManager()
|
||||
manager.add("first")
|
||||
manager.add("second")
|
||||
|
||||
manager.get_previous() # second
|
||||
manager.get_previous() # first
|
||||
|
||||
assert manager.get_next() == "second"
|
||||
|
||||
def test_search(self):
|
||||
"""Test searching history."""
|
||||
manager = HistoryManager()
|
||||
manager.add("/help")
|
||||
manager.add("/exit")
|
||||
manager.add("/help model")
|
||||
|
||||
results = manager.search("/help")
|
||||
|
||||
assert len(results) == 2
|
||||
assert "/help" in results
|
||||
assert "/help model" in results
|
||||
|
||||
def test_clear(self):
|
||||
"""Test clearing history."""
|
||||
manager = HistoryManager()
|
||||
manager.add("command")
|
||||
manager.clear()
|
||||
|
||||
assert len(manager.get_all()) == 0
|
||||
|
||||
def test_persistent_history(self):
|
||||
"""Test persistent history file."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
history_file = Path(tmpdir) / "history.txt"
|
||||
|
||||
# Create and add entries
|
||||
manager1 = HistoryManager(history_file=str(history_file))
|
||||
manager1.add("command1")
|
||||
manager1.add("command2")
|
||||
|
||||
# Create new manager with same file
|
||||
manager2 = HistoryManager(history_file=str(history_file))
|
||||
|
||||
assert len(manager2.get_all()) == 2
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# StatusDisplay Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestStatusDisplay:
|
||||
"""Tests for StatusDisplay."""
|
||||
|
||||
def test_create_display(self):
|
||||
"""Test creating status display."""
|
||||
display = StatusDisplay()
|
||||
assert display is not None
|
||||
|
||||
def test_set_status(self):
|
||||
"""Test setting status."""
|
||||
display = StatusDisplay()
|
||||
display.set_status("model", "gpt-4o")
|
||||
|
||||
assert display._status_items["model"] == "gpt-4o"
|
||||
|
||||
def test_clear_status(self):
|
||||
"""Test clearing status."""
|
||||
display = StatusDisplay()
|
||||
display.set_status("key", "value")
|
||||
display.clear_status("key")
|
||||
|
||||
assert "key" not in display._status_items
|
||||
|
||||
def test_print_welcome(self):
|
||||
"""Test printing welcome message."""
|
||||
display = StatusDisplay()
|
||||
display._console = None # Force no console
|
||||
|
||||
# Should not raise without console
|
||||
display.print_welcome()
|
||||
|
||||
def test_print_response(self):
|
||||
"""Test printing response."""
|
||||
display = StatusDisplay()
|
||||
display._console = None # Force no console
|
||||
|
||||
display.print_response("Test response")
|
||||
|
||||
def test_print_error(self):
|
||||
"""Test printing error."""
|
||||
display = StatusDisplay()
|
||||
display._console = None # Force no console
|
||||
|
||||
display.print_error("Test error")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# InteractiveSession Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestInteractiveSession:
|
||||
"""Tests for InteractiveSession."""
|
||||
|
||||
def test_create_session(self):
|
||||
"""Test creating session."""
|
||||
session = InteractiveSession()
|
||||
assert session is not None
|
||||
|
||||
def test_create_session_with_config(self):
|
||||
"""Test creating session with config."""
|
||||
config = InteractiveConfig(prompt="$ ")
|
||||
session = InteractiveSession(config=config)
|
||||
|
||||
assert session.config.prompt == "$ "
|
||||
|
||||
def test_add_commands(self):
|
||||
"""Test adding commands."""
|
||||
session = InteractiveSession()
|
||||
session.add_commands(["help", "exit"])
|
||||
|
||||
assert "help" in session.completer.commands
|
||||
|
||||
def test_add_symbols(self):
|
||||
"""Test adding symbols."""
|
||||
session = InteractiveSession()
|
||||
session.add_symbols(["MyClass"])
|
||||
|
||||
assert "MyClass" in session.completer._symbol_cache
|
||||
|
||||
def test_process_empty_input(self):
|
||||
"""Test processing empty input."""
|
||||
session = InteractiveSession()
|
||||
result = session.process_input("")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_process_slash_command(self):
|
||||
"""Test processing slash command."""
|
||||
command_called = []
|
||||
|
||||
def on_command(cmd):
|
||||
command_called.append(cmd)
|
||||
return {"type": "help"}
|
||||
|
||||
session = InteractiveSession(on_command=on_command)
|
||||
session.process_input("/help")
|
||||
|
||||
assert len(command_called) == 1
|
||||
assert command_called[0] == "/help"
|
||||
|
||||
def test_process_regular_input(self):
|
||||
"""Test processing regular input."""
|
||||
input_received = []
|
||||
|
||||
def on_input(text):
|
||||
input_received.append(text)
|
||||
return "Response"
|
||||
|
||||
session = InteractiveSession(on_input=on_input)
|
||||
result = session.process_input("Hello world")
|
||||
|
||||
assert len(input_received) == 1
|
||||
assert result == "Response"
|
||||
|
||||
def test_exit_command_stops_session(self):
|
||||
"""Test exit command stops session."""
|
||||
def on_command(cmd):
|
||||
return {"type": "exit"}
|
||||
|
||||
session = InteractiveSession(on_command=on_command)
|
||||
session._running = True
|
||||
|
||||
session.process_input("/exit")
|
||||
|
||||
assert session._running is False
|
||||
|
||||
def test_history_tracking(self):
|
||||
"""Test that input is added to history."""
|
||||
session = InteractiveSession()
|
||||
session.process_input("test command")
|
||||
|
||||
assert "test command" in session.history.get_all()
|
||||
|
||||
def test_stop_session(self):
|
||||
"""Test stopping session."""
|
||||
session = InteractiveSession()
|
||||
session._running = True
|
||||
|
||||
session.stop()
|
||||
|
||||
assert session._running is False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# InteractiveTUIHandler Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestInteractiveTUIHandler:
|
||||
"""Tests for InteractiveTUIHandler."""
|
||||
|
||||
def test_handler_creation(self):
|
||||
"""Test handler creation."""
|
||||
handler = InteractiveTUIHandler()
|
||||
assert handler.feature_name == "interactive_tui"
|
||||
|
||||
def test_initialize(self):
|
||||
"""Test initializing handler."""
|
||||
handler = InteractiveTUIHandler()
|
||||
session = handler.initialize()
|
||||
|
||||
assert session is not None
|
||||
assert handler.get_session() is session
|
||||
|
||||
def test_initialize_with_config(self):
|
||||
"""Test initializing with config."""
|
||||
handler = InteractiveTUIHandler()
|
||||
config = InteractiveConfig(prompt="$ ")
|
||||
|
||||
session = handler.initialize(config=config)
|
||||
|
||||
assert session.config.prompt == "$ "
|
||||
|
||||
def test_initialize_with_callbacks(self):
|
||||
"""Test initializing with callbacks."""
|
||||
handler = InteractiveTUIHandler()
|
||||
|
||||
def on_input(text):
|
||||
return "response"
|
||||
|
||||
def on_command(cmd):
|
||||
return {"type": "help"}
|
||||
|
||||
session = handler.initialize(on_input=on_input, on_command=on_command)
|
||||
|
||||
assert session.on_input is on_input
|
||||
assert session.on_command is on_command
|
||||
|
||||
def test_stop(self):
|
||||
"""Test stopping handler."""
|
||||
handler = InteractiveTUIHandler()
|
||||
handler.initialize()
|
||||
handler._session._running = True
|
||||
|
||||
handler.stop()
|
||||
|
||||
assert handler._session._running is False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestInteractiveTUIIntegration:
|
||||
"""Integration tests for Interactive TUI."""
|
||||
|
||||
def test_full_session_workflow(self):
|
||||
"""Test full session workflow."""
|
||||
|
||||
def on_input(text):
|
||||
return f"Echo: {text}"
|
||||
|
||||
def on_command(cmd):
|
||||
if cmd == "/exit":
|
||||
return {"type": "exit"}
|
||||
return {"type": "command", "message": f"Executed: {cmd}"}
|
||||
|
||||
handler = InteractiveTUIHandler()
|
||||
session = handler.initialize(on_input=on_input, on_command=on_command)
|
||||
|
||||
# Process various inputs
|
||||
result1 = session.process_input("Hello")
|
||||
assert result1 == "Echo: Hello"
|
||||
|
||||
result2 = session.process_input("/help")
|
||||
assert result2 == "Executed: /help"
|
||||
|
||||
# Exit
|
||||
session.process_input("/exit")
|
||||
assert session._running is False
|
||||
|
||||
def test_completion_integration(self):
|
||||
"""Test completion integration."""
|
||||
handler = InteractiveTUIHandler()
|
||||
session = handler.initialize()
|
||||
|
||||
# Add commands and symbols
|
||||
session.add_commands(["help", "exit", "cost"])
|
||||
session.add_symbols(["MyClass", "my_function"])
|
||||
|
||||
# Test completions
|
||||
completions = session.completer.get_completions("/he", 3)
|
||||
assert "/help" in completions
|
||||
|
||||
completions = session.completer.get_completions("My", 2)
|
||||
assert "MyClass" in completions
|
||||
|
||||
def test_history_integration(self):
|
||||
"""Test history integration."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
history_file = Path(tmpdir) / "history.txt"
|
||||
|
||||
config = InteractiveConfig(history_file=str(history_file))
|
||||
handler = InteractiveTUIHandler()
|
||||
session = handler.initialize(config=config)
|
||||
|
||||
# Add some history
|
||||
session.process_input("command 1")
|
||||
session.process_input("command 2")
|
||||
|
||||
# Check history
|
||||
history = session.history.get_all()
|
||||
assert "command 1" in history
|
||||
assert "command 2" in history
|
||||
@@ -0,0 +1,611 @@
|
||||
"""
|
||||
Tests for Repository Map System.
|
||||
|
||||
Test-Driven Development approach for codebase mapping.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from praisonai.cli.features.repo_map import (
|
||||
Symbol,
|
||||
FileMap,
|
||||
RepoMapConfig,
|
||||
SymbolExtractor,
|
||||
SymbolRanker,
|
||||
RepoMap,
|
||||
RepoMapHandler,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Symbol Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestSymbol:
|
||||
"""Tests for Symbol dataclass."""
|
||||
|
||||
def test_create_symbol(self):
|
||||
"""Test creating a symbol."""
|
||||
symbol = Symbol(
|
||||
name="MyClass",
|
||||
kind="class",
|
||||
file_path="test.py",
|
||||
line_number=10
|
||||
)
|
||||
assert symbol.name == "MyClass"
|
||||
assert symbol.kind == "class"
|
||||
assert symbol.file_path == "test.py"
|
||||
assert symbol.line_number == 10
|
||||
|
||||
def test_symbol_with_signature(self):
|
||||
"""Test symbol with signature."""
|
||||
symbol = Symbol(
|
||||
name="my_function",
|
||||
kind="function",
|
||||
file_path="test.py",
|
||||
line_number=5,
|
||||
signature="def my_function(x, y):"
|
||||
)
|
||||
assert symbol.signature == "def my_function(x, y):"
|
||||
|
||||
def test_symbol_with_parent(self):
|
||||
"""Test method symbol with parent class."""
|
||||
symbol = Symbol(
|
||||
name="method",
|
||||
kind="method",
|
||||
file_path="test.py",
|
||||
line_number=15,
|
||||
parent="MyClass"
|
||||
)
|
||||
assert symbol.parent == "MyClass"
|
||||
assert symbol.qualified_name == "MyClass.method"
|
||||
|
||||
def test_symbol_qualified_name_no_parent(self):
|
||||
"""Test qualified name without parent."""
|
||||
symbol = Symbol(
|
||||
name="function",
|
||||
kind="function",
|
||||
file_path="test.py",
|
||||
line_number=1
|
||||
)
|
||||
assert symbol.qualified_name == "function"
|
||||
|
||||
def test_symbol_hash(self):
|
||||
"""Test symbol hashing for sets."""
|
||||
s1 = Symbol("test", "function", "file.py", 1)
|
||||
s2 = Symbol("test", "function", "file.py", 1)
|
||||
s3 = Symbol("test", "function", "file.py", 2)
|
||||
|
||||
assert hash(s1) == hash(s2)
|
||||
assert hash(s1) != hash(s3)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FileMap Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestFileMap:
|
||||
"""Tests for FileMap dataclass."""
|
||||
|
||||
def test_create_file_map(self):
|
||||
"""Test creating a file map."""
|
||||
file_map = FileMap(file_path="test.py")
|
||||
assert file_map.file_path == "test.py"
|
||||
assert file_map.symbols == []
|
||||
|
||||
def test_add_symbol(self):
|
||||
"""Test adding symbols to file map."""
|
||||
file_map = FileMap(file_path="test.py")
|
||||
symbol = Symbol("test", "function", "test.py", 1)
|
||||
|
||||
file_map.add_symbol(symbol)
|
||||
|
||||
assert len(file_map.symbols) == 1
|
||||
assert file_map.symbols[0].name == "test"
|
||||
|
||||
def test_get_summary(self):
|
||||
"""Test getting file summary."""
|
||||
file_map = FileMap(file_path="test.py")
|
||||
file_map.add_symbol(Symbol(
|
||||
"MyClass", "class", "test.py", 1,
|
||||
signature="class MyClass:"
|
||||
))
|
||||
file_map.add_symbol(Symbol(
|
||||
"my_func", "function", "test.py", 10,
|
||||
signature="def my_func(x):"
|
||||
))
|
||||
|
||||
summary = file_map.get_summary()
|
||||
|
||||
assert "test.py:" in summary
|
||||
assert "class MyClass:" in summary or "MyClass" in summary
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RepoMapConfig Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestRepoMapConfig:
|
||||
"""Tests for RepoMapConfig."""
|
||||
|
||||
def test_default_config(self):
|
||||
"""Test default configuration."""
|
||||
config = RepoMapConfig()
|
||||
|
||||
assert config.max_tokens == 1024
|
||||
assert config.max_files == 50
|
||||
assert ".py" in config.file_extensions
|
||||
assert "__pycache__" in config.exclude_patterns
|
||||
|
||||
def test_custom_config(self):
|
||||
"""Test custom configuration."""
|
||||
config = RepoMapConfig(
|
||||
max_tokens=2048,
|
||||
max_files=100,
|
||||
file_extensions={".py", ".js"}
|
||||
)
|
||||
|
||||
assert config.max_tokens == 2048
|
||||
assert config.max_files == 100
|
||||
assert len(config.file_extensions) == 2
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SymbolExtractor Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestSymbolExtractor:
|
||||
"""Tests for SymbolExtractor."""
|
||||
|
||||
def test_create_extractor(self):
|
||||
"""Test creating extractor."""
|
||||
extractor = SymbolExtractor(use_tree_sitter=False)
|
||||
assert extractor is not None
|
||||
|
||||
def test_extract_python_class(self):
|
||||
"""Test extracting Python class."""
|
||||
extractor = SymbolExtractor(use_tree_sitter=False)
|
||||
|
||||
content = """
|
||||
class MyClass:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def method(self):
|
||||
pass
|
||||
"""
|
||||
|
||||
symbols = extractor.extract_symbols("test.py", content)
|
||||
|
||||
class_symbols = [s for s in symbols if s.kind == "class"]
|
||||
assert len(class_symbols) >= 1
|
||||
assert class_symbols[0].name == "MyClass"
|
||||
|
||||
def test_extract_python_function(self):
|
||||
"""Test extracting Python function."""
|
||||
extractor = SymbolExtractor(use_tree_sitter=False)
|
||||
|
||||
content = """
|
||||
def my_function(x, y):
|
||||
return x + y
|
||||
|
||||
def another_function():
|
||||
pass
|
||||
"""
|
||||
|
||||
symbols = extractor.extract_symbols("test.py", content)
|
||||
|
||||
func_symbols = [s for s in symbols if s.kind == "function"]
|
||||
assert len(func_symbols) >= 2
|
||||
names = [s.name for s in func_symbols]
|
||||
assert "my_function" in names
|
||||
assert "another_function" in names
|
||||
|
||||
def test_extract_python_method(self):
|
||||
"""Test extracting Python method."""
|
||||
extractor = SymbolExtractor(use_tree_sitter=False)
|
||||
|
||||
content = """
|
||||
class MyClass:
|
||||
def method_one(self):
|
||||
pass
|
||||
|
||||
def method_two(self, x):
|
||||
return x
|
||||
"""
|
||||
|
||||
symbols = extractor.extract_symbols("test.py", content)
|
||||
|
||||
method_symbols = [s for s in symbols if s.kind == "method"]
|
||||
assert len(method_symbols) >= 2
|
||||
|
||||
# Check parent is set
|
||||
for method in method_symbols:
|
||||
assert method.parent == "MyClass"
|
||||
|
||||
def test_extract_javascript_class(self):
|
||||
"""Test extracting JavaScript class."""
|
||||
extractor = SymbolExtractor(use_tree_sitter=False)
|
||||
|
||||
content = """
|
||||
class MyComponent {
|
||||
constructor() {}
|
||||
}
|
||||
|
||||
export class AnotherClass {}
|
||||
"""
|
||||
|
||||
symbols = extractor.extract_symbols("test.js", content)
|
||||
|
||||
class_symbols = [s for s in symbols if s.kind == "class"]
|
||||
assert len(class_symbols) >= 1
|
||||
|
||||
def test_extract_javascript_function(self):
|
||||
"""Test extracting JavaScript function."""
|
||||
extractor = SymbolExtractor(use_tree_sitter=False)
|
||||
|
||||
content = """
|
||||
function myFunction() {}
|
||||
|
||||
const arrowFunc = () => {};
|
||||
|
||||
async function asyncFunc() {}
|
||||
"""
|
||||
|
||||
symbols = extractor.extract_symbols("test.js", content)
|
||||
|
||||
func_symbols = [s for s in symbols if s.kind == "function"]
|
||||
assert len(func_symbols) >= 1
|
||||
|
||||
def test_extract_go_symbols(self):
|
||||
"""Test extracting Go symbols."""
|
||||
extractor = SymbolExtractor(use_tree_sitter=False)
|
||||
|
||||
content = """
|
||||
type MyStruct struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func MyFunction() {}
|
||||
|
||||
func (m *MyStruct) Method() {}
|
||||
"""
|
||||
|
||||
symbols = extractor.extract_symbols("test.go", content)
|
||||
|
||||
assert len(symbols) >= 2
|
||||
|
||||
def test_extract_rust_symbols(self):
|
||||
"""Test extracting Rust symbols."""
|
||||
extractor = SymbolExtractor(use_tree_sitter=False)
|
||||
|
||||
content = """
|
||||
pub struct MyStruct {
|
||||
field: i32,
|
||||
}
|
||||
|
||||
fn my_function() {}
|
||||
|
||||
pub async fn async_func() {}
|
||||
"""
|
||||
|
||||
symbols = extractor.extract_symbols("test.rs", content)
|
||||
|
||||
assert len(symbols) >= 2
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SymbolRanker Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestSymbolRanker:
|
||||
"""Tests for SymbolRanker."""
|
||||
|
||||
def test_create_ranker(self):
|
||||
"""Test creating ranker."""
|
||||
ranker = SymbolRanker()
|
||||
assert ranker is not None
|
||||
|
||||
def test_analyze_references(self):
|
||||
"""Test analyzing references."""
|
||||
ranker = SymbolRanker()
|
||||
|
||||
file_maps = {
|
||||
"a.py": FileMap(
|
||||
file_path="a.py",
|
||||
symbols=[Symbol("MyClass", "class", "a.py", 1)]
|
||||
),
|
||||
"b.py": FileMap(
|
||||
file_path="b.py",
|
||||
symbols=[Symbol("use_class", "function", "b.py", 1)]
|
||||
)
|
||||
}
|
||||
|
||||
all_content = {
|
||||
"a.py": "class MyClass:\n pass",
|
||||
"b.py": "from a import MyClass\nobj = MyClass()"
|
||||
}
|
||||
|
||||
ranker.analyze_references(file_maps, all_content)
|
||||
|
||||
# MyClass should have references
|
||||
my_class = file_maps["a.py"].symbols[0]
|
||||
assert my_class.references > 0
|
||||
|
||||
def test_get_top_symbols(self):
|
||||
"""Test getting top symbols."""
|
||||
ranker = SymbolRanker()
|
||||
|
||||
file_maps = {
|
||||
"test.py": FileMap(
|
||||
file_path="test.py",
|
||||
symbols=[
|
||||
Symbol("HighRef", "class", "test.py", 1, references=10),
|
||||
Symbol("LowRef", "function", "test.py", 10, references=1),
|
||||
Symbol("MedRef", "function", "test.py", 20, references=5),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
# Manually set references
|
||||
file_maps["test.py"].symbols[0].references = 10
|
||||
file_maps["test.py"].symbols[1].references = 1
|
||||
file_maps["test.py"].symbols[2].references = 5
|
||||
|
||||
top = ranker.get_top_symbols(file_maps, max_symbols=2)
|
||||
|
||||
assert len(top) == 2
|
||||
assert top[0].name == "HighRef"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RepoMap Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestRepoMap:
|
||||
"""Tests for RepoMap."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_repo(self):
|
||||
"""Create a temporary repository."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create some test files
|
||||
(Path(tmpdir) / "main.py").write_text("""
|
||||
class Application:
|
||||
def __init__(self):
|
||||
self.config = Config()
|
||||
|
||||
def run(self):
|
||||
pass
|
||||
|
||||
def main():
|
||||
app = Application()
|
||||
app.run()
|
||||
""")
|
||||
|
||||
(Path(tmpdir) / "config.py").write_text("""
|
||||
class Config:
|
||||
def __init__(self):
|
||||
self.debug = False
|
||||
|
||||
def load(self):
|
||||
pass
|
||||
""")
|
||||
|
||||
(Path(tmpdir) / "utils.py").write_text("""
|
||||
def helper_function():
|
||||
pass
|
||||
|
||||
def format_output(data):
|
||||
return str(data)
|
||||
""")
|
||||
|
||||
yield tmpdir
|
||||
|
||||
def test_create_repo_map(self, temp_repo):
|
||||
"""Test creating a repo map."""
|
||||
repo_map = RepoMap(root=temp_repo)
|
||||
assert repo_map.root == Path(temp_repo)
|
||||
|
||||
def test_scan_repository(self, temp_repo):
|
||||
"""Test scanning a repository."""
|
||||
repo_map = RepoMap(root=temp_repo)
|
||||
repo_map.scan()
|
||||
|
||||
assert len(repo_map._file_maps) > 0
|
||||
|
||||
def test_get_map(self, temp_repo):
|
||||
"""Test getting the repository map."""
|
||||
repo_map = RepoMap(root=temp_repo)
|
||||
map_str = repo_map.get_map()
|
||||
|
||||
assert len(map_str) > 0
|
||||
# Should contain file names
|
||||
assert "main.py" in map_str or "config.py" in map_str
|
||||
|
||||
def test_get_map_with_focus(self, temp_repo):
|
||||
"""Test getting map with focus files."""
|
||||
repo_map = RepoMap(root=temp_repo)
|
||||
repo_map.scan()
|
||||
|
||||
map_str = repo_map.get_map(focus_files=["main.py"])
|
||||
|
||||
assert "main.py" in map_str
|
||||
|
||||
def test_get_file_symbols(self, temp_repo):
|
||||
"""Test getting symbols for a specific file."""
|
||||
repo_map = RepoMap(root=temp_repo)
|
||||
repo_map.scan()
|
||||
|
||||
symbols = repo_map.get_file_symbols("main.py")
|
||||
|
||||
assert len(symbols) > 0
|
||||
|
||||
def test_get_symbol_context(self, temp_repo):
|
||||
"""Test getting context for a symbol."""
|
||||
repo_map = RepoMap(root=temp_repo)
|
||||
repo_map.scan()
|
||||
|
||||
context = repo_map.get_symbol_context("Application")
|
||||
|
||||
if context: # May not find if symbol extraction differs
|
||||
assert "Application" in context
|
||||
|
||||
def test_refresh(self, temp_repo):
|
||||
"""Test refreshing the map."""
|
||||
repo_map = RepoMap(root=temp_repo)
|
||||
repo_map.scan()
|
||||
|
||||
initial_count = len(repo_map._file_maps)
|
||||
|
||||
repo_map.refresh()
|
||||
|
||||
assert len(repo_map._file_maps) == initial_count
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RepoMapHandler Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestRepoMapHandler:
|
||||
"""Tests for RepoMapHandler."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_repo(self):
|
||||
"""Create a temporary repository."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "test.py").write_text("""
|
||||
class TestClass:
|
||||
def test_method(self):
|
||||
pass
|
||||
""")
|
||||
yield tmpdir
|
||||
|
||||
def test_handler_creation(self):
|
||||
"""Test handler creation."""
|
||||
handler = RepoMapHandler()
|
||||
assert handler.feature_name == "repo_map"
|
||||
|
||||
def test_initialize(self, temp_repo):
|
||||
"""Test initializing handler."""
|
||||
handler = RepoMapHandler()
|
||||
repo_map = handler.initialize(root=temp_repo)
|
||||
|
||||
assert repo_map is not None
|
||||
|
||||
def test_get_map(self, temp_repo):
|
||||
"""Test getting map through handler."""
|
||||
handler = RepoMapHandler()
|
||||
handler.initialize(root=temp_repo)
|
||||
|
||||
map_str = handler.get_map()
|
||||
|
||||
assert len(map_str) > 0
|
||||
|
||||
def test_refresh(self, temp_repo):
|
||||
"""Test refreshing through handler."""
|
||||
handler = RepoMapHandler()
|
||||
handler.initialize(root=temp_repo)
|
||||
handler.get_map()
|
||||
|
||||
handler.refresh()
|
||||
|
||||
# Should not raise
|
||||
map_str = handler.get_map()
|
||||
assert len(map_str) > 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestRepoMapIntegration:
|
||||
"""Integration tests for RepoMap."""
|
||||
|
||||
@pytest.fixture
|
||||
def complex_repo(self):
|
||||
"""Create a more complex repository."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create directory structure
|
||||
src_dir = Path(tmpdir) / "src"
|
||||
src_dir.mkdir()
|
||||
|
||||
(src_dir / "app.py").write_text("""
|
||||
from .models import User
|
||||
from .utils import format_name
|
||||
|
||||
class Application:
|
||||
def __init__(self):
|
||||
self.users = []
|
||||
|
||||
def add_user(self, name):
|
||||
user = User(format_name(name))
|
||||
self.users.append(user)
|
||||
return user
|
||||
|
||||
def get_users(self):
|
||||
return self.users
|
||||
""")
|
||||
|
||||
(src_dir / "models.py").write_text("""
|
||||
class User:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __repr__(self):
|
||||
return f"User({self.name})"
|
||||
|
||||
class Admin(User):
|
||||
def __init__(self, name, role):
|
||||
super().__init__(name)
|
||||
self.role = role
|
||||
""")
|
||||
|
||||
(src_dir / "utils.py").write_text("""
|
||||
def format_name(name):
|
||||
return name.strip().title()
|
||||
|
||||
def validate_email(email):
|
||||
return '@' in email
|
||||
""")
|
||||
|
||||
yield tmpdir
|
||||
|
||||
def test_full_scan_and_map(self, complex_repo):
|
||||
"""Test full scan and map generation."""
|
||||
repo_map = RepoMap(root=complex_repo)
|
||||
repo_map.scan()
|
||||
|
||||
map_str = repo_map.get_map()
|
||||
|
||||
# Should contain key symbols
|
||||
assert len(map_str) > 0
|
||||
|
||||
def test_symbol_ranking(self, complex_repo):
|
||||
"""Test that frequently referenced symbols rank higher."""
|
||||
repo_map = RepoMap(root=complex_repo)
|
||||
repo_map.scan()
|
||||
|
||||
# User is referenced in multiple files
|
||||
top_symbols = repo_map.ranker.get_top_symbols(repo_map._file_maps)
|
||||
|
||||
# Should have some symbols
|
||||
assert len(top_symbols) > 0
|
||||
|
||||
def test_handler_workflow(self, complex_repo):
|
||||
"""Test complete handler workflow."""
|
||||
handler = RepoMapHandler(verbose=False)
|
||||
handler.initialize(root=complex_repo)
|
||||
|
||||
# Get initial map
|
||||
map1 = handler.get_map()
|
||||
assert len(map1) > 0
|
||||
|
||||
# Refresh
|
||||
handler.refresh()
|
||||
|
||||
# Get map again
|
||||
map2 = handler.get_map()
|
||||
assert len(map2) > 0
|
||||
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Tests for Sandbox Executor System.
|
||||
|
||||
Test-Driven Development approach for sandboxed execution.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
|
||||
from praisonai.cli.features.sandbox_executor import (
|
||||
SandboxMode,
|
||||
SandboxPolicy,
|
||||
ExecutionResult,
|
||||
CommandValidator,
|
||||
SubprocessSandbox,
|
||||
SandboxExecutorHandler,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SandboxMode Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestSandboxMode:
|
||||
"""Tests for SandboxMode enum."""
|
||||
|
||||
def test_mode_values(self):
|
||||
"""Test mode enum values."""
|
||||
assert SandboxMode.DISABLED.value == "disabled"
|
||||
assert SandboxMode.BASIC.value == "basic"
|
||||
assert SandboxMode.STRICT.value == "strict"
|
||||
assert SandboxMode.NETWORK_ISOLATED.value == "network_isolated"
|
||||
|
||||
def test_from_string_valid(self):
|
||||
"""Test parsing valid mode strings."""
|
||||
assert SandboxMode.from_string("disabled") == SandboxMode.DISABLED
|
||||
assert SandboxMode.from_string("basic") == SandboxMode.BASIC
|
||||
assert SandboxMode.from_string("strict") == SandboxMode.STRICT
|
||||
|
||||
def test_from_string_with_hyphen(self):
|
||||
"""Test parsing mode strings with hyphens."""
|
||||
assert SandboxMode.from_string("network-isolated") == SandboxMode.NETWORK_ISOLATED
|
||||
|
||||
def test_from_string_case_insensitive(self):
|
||||
"""Test case insensitive parsing."""
|
||||
assert SandboxMode.from_string("BASIC") == SandboxMode.BASIC
|
||||
assert SandboxMode.from_string("Strict") == SandboxMode.STRICT
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SandboxPolicy Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestSandboxPolicy:
|
||||
"""Tests for SandboxPolicy."""
|
||||
|
||||
def test_default_policy(self):
|
||||
"""Test default policy."""
|
||||
policy = SandboxPolicy()
|
||||
|
||||
assert policy.mode == SandboxMode.DISABLED
|
||||
assert policy.max_memory_mb == 512
|
||||
assert policy.allow_network is True
|
||||
|
||||
def test_for_mode_disabled(self):
|
||||
"""Test policy for disabled mode."""
|
||||
policy = SandboxPolicy.for_mode(SandboxMode.DISABLED)
|
||||
|
||||
assert policy.mode == SandboxMode.DISABLED
|
||||
|
||||
def test_for_mode_basic(self):
|
||||
"""Test policy for basic mode."""
|
||||
policy = SandboxPolicy.for_mode(SandboxMode.BASIC)
|
||||
|
||||
assert policy.mode == SandboxMode.BASIC
|
||||
assert policy.allow_network is True
|
||||
|
||||
def test_for_mode_strict(self):
|
||||
"""Test policy for strict mode."""
|
||||
policy = SandboxPolicy.for_mode(SandboxMode.STRICT)
|
||||
|
||||
assert policy.mode == SandboxMode.STRICT
|
||||
assert policy.max_memory_mb == 256
|
||||
assert "curl" in policy.blocked_commands
|
||||
|
||||
def test_for_mode_network_isolated(self):
|
||||
"""Test policy for network isolated mode."""
|
||||
policy = SandboxPolicy.for_mode(SandboxMode.NETWORK_ISOLATED)
|
||||
|
||||
assert policy.mode == SandboxMode.NETWORK_ISOLATED
|
||||
assert policy.allow_network is False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ExecutionResult Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestExecutionResult:
|
||||
"""Tests for ExecutionResult."""
|
||||
|
||||
def test_create_result(self):
|
||||
"""Test creating execution result."""
|
||||
result = ExecutionResult(
|
||||
success=True,
|
||||
exit_code=0,
|
||||
stdout="output",
|
||||
stderr="",
|
||||
duration_ms=100.0,
|
||||
was_sandboxed=True
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout == "output"
|
||||
|
||||
def test_output_property(self):
|
||||
"""Test output property."""
|
||||
result = ExecutionResult(
|
||||
success=True,
|
||||
exit_code=0,
|
||||
stdout="out",
|
||||
stderr="err",
|
||||
duration_ms=100.0,
|
||||
was_sandboxed=True
|
||||
)
|
||||
|
||||
assert result.output == "outerr"
|
||||
|
||||
def test_policy_violations(self):
|
||||
"""Test policy violations."""
|
||||
result = ExecutionResult(
|
||||
success=False,
|
||||
exit_code=-1,
|
||||
stdout="",
|
||||
stderr="blocked",
|
||||
duration_ms=0.0,
|
||||
was_sandboxed=True,
|
||||
policy_violations=["Command blocked"]
|
||||
)
|
||||
|
||||
assert len(result.policy_violations) == 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CommandValidator Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestCommandValidator:
|
||||
"""Tests for CommandValidator."""
|
||||
|
||||
def test_validate_disabled_mode(self):
|
||||
"""Test validation in disabled mode."""
|
||||
policy = SandboxPolicy(mode=SandboxMode.DISABLED)
|
||||
validator = CommandValidator(policy)
|
||||
|
||||
violations = validator.validate("rm -rf /")
|
||||
|
||||
assert len(violations) == 0 # No validation in disabled mode
|
||||
|
||||
def test_validate_blocked_command(self):
|
||||
"""Test validation of blocked command."""
|
||||
policy = SandboxPolicy(
|
||||
mode=SandboxMode.BASIC,
|
||||
blocked_commands={"rm", "sudo"}
|
||||
)
|
||||
validator = CommandValidator(policy)
|
||||
|
||||
violations = validator.validate("rm file.txt")
|
||||
|
||||
assert len(violations) > 0
|
||||
assert "rm" in violations[0]
|
||||
|
||||
def test_validate_dangerous_pattern(self):
|
||||
"""Test validation of dangerous patterns."""
|
||||
policy = SandboxPolicy(mode=SandboxMode.BASIC)
|
||||
validator = CommandValidator(policy)
|
||||
|
||||
violations = validator.validate("echo test | sh")
|
||||
|
||||
assert len(violations) > 0
|
||||
|
||||
def test_validate_blocked_path(self):
|
||||
"""Test validation of blocked paths."""
|
||||
policy = SandboxPolicy(
|
||||
mode=SandboxMode.BASIC,
|
||||
blocked_paths={"/etc", "/var"}
|
||||
)
|
||||
validator = CommandValidator(policy)
|
||||
|
||||
violations = validator.validate("cat /etc/passwd")
|
||||
|
||||
assert len(violations) > 0
|
||||
|
||||
def test_is_allowed(self):
|
||||
"""Test is_allowed method."""
|
||||
policy = SandboxPolicy(
|
||||
mode=SandboxMode.BASIC,
|
||||
blocked_commands={"rm"}
|
||||
)
|
||||
validator = CommandValidator(policy)
|
||||
|
||||
assert validator.is_allowed("ls -la") is True
|
||||
assert validator.is_allowed("rm file.txt") is False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SubprocessSandbox Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestSubprocessSandbox:
|
||||
"""Tests for SubprocessSandbox."""
|
||||
|
||||
def test_create_sandbox(self):
|
||||
"""Test creating sandbox."""
|
||||
sandbox = SubprocessSandbox()
|
||||
assert sandbox is not None
|
||||
|
||||
def test_execute_disabled_mode(self):
|
||||
"""Test execution in disabled mode."""
|
||||
policy = SandboxPolicy(mode=SandboxMode.DISABLED)
|
||||
sandbox = SubprocessSandbox(policy=policy)
|
||||
|
||||
result = sandbox.execute("echo hello")
|
||||
|
||||
assert result.success is True
|
||||
assert "hello" in result.stdout
|
||||
assert result.was_sandboxed is False
|
||||
|
||||
def test_execute_basic_mode(self):
|
||||
"""Test execution in basic mode."""
|
||||
policy = SandboxPolicy.for_mode(SandboxMode.BASIC)
|
||||
sandbox = SubprocessSandbox(policy=policy)
|
||||
|
||||
result = sandbox.execute("echo hello")
|
||||
|
||||
assert result.success is True
|
||||
assert "hello" in result.stdout
|
||||
assert result.was_sandboxed is True
|
||||
|
||||
def test_execute_blocked_command(self):
|
||||
"""Test execution of blocked command."""
|
||||
policy = SandboxPolicy(
|
||||
mode=SandboxMode.BASIC,
|
||||
blocked_commands={"rm"}
|
||||
)
|
||||
sandbox = SubprocessSandbox(policy=policy)
|
||||
|
||||
result = sandbox.execute("rm test.txt")
|
||||
|
||||
assert result.success is False
|
||||
assert len(result.policy_violations) > 0
|
||||
|
||||
def test_execute_timeout(self):
|
||||
"""Test execution timeout."""
|
||||
policy = SandboxPolicy(mode=SandboxMode.BASIC)
|
||||
sandbox = SubprocessSandbox(policy=policy)
|
||||
|
||||
result = sandbox.execute("sleep 10", timeout=0.1)
|
||||
|
||||
assert result.success is False
|
||||
assert "timed out" in result.stderr.lower()
|
||||
|
||||
def test_execute_with_working_dir(self):
|
||||
"""Test execution with working directory."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
policy = SandboxPolicy(mode=SandboxMode.BASIC)
|
||||
sandbox = SubprocessSandbox(policy=policy, working_dir=tmpdir)
|
||||
|
||||
result = sandbox.execute("pwd")
|
||||
|
||||
assert result.success is True
|
||||
assert tmpdir in result.stdout
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SandboxExecutorHandler Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestSandboxExecutorHandler:
|
||||
"""Tests for SandboxExecutorHandler."""
|
||||
|
||||
def test_handler_creation(self):
|
||||
"""Test handler creation."""
|
||||
handler = SandboxExecutorHandler()
|
||||
assert handler.feature_name == "sandbox_executor"
|
||||
assert handler.is_enabled is False
|
||||
|
||||
def test_initialize_disabled(self):
|
||||
"""Test initializing with disabled mode."""
|
||||
handler = SandboxExecutorHandler()
|
||||
sandbox = handler.initialize(mode="disabled")
|
||||
|
||||
assert sandbox is not None
|
||||
assert handler.is_enabled is False
|
||||
|
||||
def test_initialize_basic(self):
|
||||
"""Test initializing with basic mode."""
|
||||
handler = SandboxExecutorHandler()
|
||||
sandbox = handler.initialize(mode="basic")
|
||||
|
||||
assert sandbox is not None
|
||||
assert handler.is_enabled is True
|
||||
|
||||
def test_execute(self):
|
||||
"""Test executing command."""
|
||||
handler = SandboxExecutorHandler()
|
||||
handler.initialize(mode="basic")
|
||||
|
||||
result = handler.execute("echo test")
|
||||
|
||||
assert result.success is True
|
||||
assert "test" in result.stdout
|
||||
|
||||
def test_validate_command(self):
|
||||
"""Test validating command."""
|
||||
handler = SandboxExecutorHandler()
|
||||
handler.initialize(mode="basic")
|
||||
|
||||
violations = handler.validate_command("echo hello")
|
||||
assert len(violations) == 0
|
||||
|
||||
def test_get_mode(self):
|
||||
"""Test getting mode."""
|
||||
handler = SandboxExecutorHandler()
|
||||
|
||||
assert handler.get_mode() == "disabled"
|
||||
|
||||
handler.initialize(mode="strict")
|
||||
assert handler.get_mode() == "strict"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestSandboxIntegration:
|
||||
"""Integration tests for Sandbox."""
|
||||
|
||||
def test_full_workflow_disabled(self):
|
||||
"""Test full workflow with sandbox disabled."""
|
||||
handler = SandboxExecutorHandler()
|
||||
handler.initialize(mode="disabled")
|
||||
|
||||
# Should execute without sandboxing
|
||||
result = handler.execute("echo hello")
|
||||
|
||||
assert result.success is True
|
||||
assert result.was_sandboxed is False
|
||||
|
||||
def test_full_workflow_basic(self):
|
||||
"""Test full workflow with basic sandbox."""
|
||||
handler = SandboxExecutorHandler()
|
||||
handler.initialize(mode="basic")
|
||||
|
||||
# Safe command should work
|
||||
result = handler.execute("echo hello")
|
||||
assert result.success is True
|
||||
assert result.was_sandboxed is True
|
||||
|
||||
# Blocked command should fail
|
||||
result = handler.execute("rm -rf /")
|
||||
assert result.success is False
|
||||
assert len(result.policy_violations) > 0
|
||||
|
||||
def test_strict_mode_isolation(self):
|
||||
"""Test strict mode isolation."""
|
||||
handler = SandboxExecutorHandler()
|
||||
handler.initialize(mode="strict")
|
||||
|
||||
# Should work in isolated environment
|
||||
result = handler.execute("echo $HOME")
|
||||
|
||||
assert result.success is True
|
||||
assert result.was_sandboxed is True
|
||||
@@ -0,0 +1,608 @@
|
||||
"""
|
||||
Tests for Slash Commands System.
|
||||
|
||||
Test-Driven Development approach:
|
||||
1. Write tests first
|
||||
2. Implement features to pass tests
|
||||
3. Refactor while keeping tests green
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
import time
|
||||
|
||||
from praisonai.cli.features.slash_commands import (
|
||||
SlashCommand,
|
||||
CommandContext,
|
||||
CommandKind,
|
||||
SlashCommandRegistry,
|
||||
SlashCommandParser,
|
||||
SlashCommandHandler,
|
||||
create_default_registry,
|
||||
cmd_help,
|
||||
cmd_cost,
|
||||
cmd_clear,
|
||||
cmd_model,
|
||||
cmd_tokens,
|
||||
cmd_plan,
|
||||
cmd_exit,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SlashCommand Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestSlashCommand:
|
||||
"""Tests for SlashCommand dataclass."""
|
||||
|
||||
def test_create_basic_command(self):
|
||||
"""Test creating a basic slash command."""
|
||||
cmd = SlashCommand(
|
||||
name="test",
|
||||
description="A test command"
|
||||
)
|
||||
assert cmd.name == "test"
|
||||
assert cmd.description == "A test command"
|
||||
assert cmd.action is None
|
||||
assert cmd.alt_names == []
|
||||
assert cmd.sub_commands == []
|
||||
assert cmd.kind == CommandKind.BUILT_IN
|
||||
assert cmd.auto_execute is True
|
||||
|
||||
def test_create_command_with_action(self):
|
||||
"""Test creating a command with an action."""
|
||||
def my_action(ctx, args):
|
||||
return {"result": "success"}
|
||||
|
||||
cmd = SlashCommand(
|
||||
name="mycommand",
|
||||
description="My command",
|
||||
action=my_action
|
||||
)
|
||||
assert cmd.action is not None
|
||||
result = cmd.action(None, "")
|
||||
assert result == {"result": "success"}
|
||||
|
||||
def test_create_command_with_aliases(self):
|
||||
"""Test creating a command with aliases."""
|
||||
cmd = SlashCommand(
|
||||
name="help",
|
||||
description="Show help",
|
||||
alt_names=["h", "?"]
|
||||
)
|
||||
assert "h" in cmd.alt_names
|
||||
assert "?" in cmd.alt_names
|
||||
|
||||
def test_create_command_with_subcommands(self):
|
||||
"""Test creating a command with sub-commands."""
|
||||
sub1 = SlashCommand(name="sub1", description="Sub 1")
|
||||
sub2 = SlashCommand(name="sub2", description="Sub 2")
|
||||
|
||||
cmd = SlashCommand(
|
||||
name="parent",
|
||||
description="Parent command",
|
||||
sub_commands=[sub1, sub2]
|
||||
)
|
||||
assert len(cmd.sub_commands) == 2
|
||||
assert cmd.sub_commands[0].name == "sub1"
|
||||
|
||||
def test_empty_name_raises_error(self):
|
||||
"""Test that empty name raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Command name cannot be empty"):
|
||||
SlashCommand(name="", description="Empty name")
|
||||
|
||||
def test_command_kinds(self):
|
||||
"""Test different command kinds."""
|
||||
builtin = SlashCommand(name="builtin", description="Built-in", kind=CommandKind.BUILT_IN)
|
||||
custom = SlashCommand(name="custom", description="Custom", kind=CommandKind.CUSTOM)
|
||||
mcp = SlashCommand(name="mcp", description="MCP", kind=CommandKind.MCP)
|
||||
|
||||
assert builtin.kind == CommandKind.BUILT_IN
|
||||
assert custom.kind == CommandKind.CUSTOM
|
||||
assert mcp.kind == CommandKind.MCP
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CommandContext Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestCommandContext:
|
||||
"""Tests for CommandContext dataclass."""
|
||||
|
||||
def test_create_default_context(self):
|
||||
"""Test creating a default context."""
|
||||
ctx = CommandContext()
|
||||
assert ctx.agent is None
|
||||
assert ctx.config == {}
|
||||
assert ctx.session_id is None
|
||||
assert ctx.total_tokens == 0
|
||||
assert ctx.total_cost == 0.0
|
||||
assert ctx.prompt_count == 0
|
||||
|
||||
def test_context_with_metrics(self):
|
||||
"""Test context with metrics."""
|
||||
ctx = CommandContext(
|
||||
total_tokens=1500,
|
||||
total_cost=0.0045,
|
||||
prompt_count=3
|
||||
)
|
||||
assert ctx.total_tokens == 1500
|
||||
assert ctx.total_cost == 0.0045
|
||||
assert ctx.prompt_count == 3
|
||||
|
||||
def test_context_with_session(self):
|
||||
"""Test context with session info."""
|
||||
start_time = time.time()
|
||||
ctx = CommandContext(
|
||||
session_id="test-session-123",
|
||||
session_start_time=start_time
|
||||
)
|
||||
assert ctx.session_id == "test-session-123"
|
||||
assert ctx.session_start_time == start_time
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SlashCommandRegistry Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestSlashCommandRegistry:
|
||||
"""Tests for SlashCommandRegistry."""
|
||||
|
||||
def test_register_command(self):
|
||||
"""Test registering a command."""
|
||||
registry = SlashCommandRegistry()
|
||||
cmd = SlashCommand(name="test", description="Test")
|
||||
|
||||
registry.register(cmd)
|
||||
|
||||
assert registry.get("test") is not None
|
||||
assert registry.get("test").name == "test"
|
||||
|
||||
def test_register_command_with_aliases(self):
|
||||
"""Test registering a command with aliases."""
|
||||
registry = SlashCommandRegistry()
|
||||
cmd = SlashCommand(
|
||||
name="help",
|
||||
description="Help",
|
||||
alt_names=["h", "?"]
|
||||
)
|
||||
|
||||
registry.register(cmd)
|
||||
|
||||
# Should be accessible by name and aliases
|
||||
assert registry.get("help") is not None
|
||||
assert registry.get("h") is not None
|
||||
assert registry.get("?") is not None
|
||||
|
||||
# All should return the same command
|
||||
assert registry.get("help") == registry.get("h")
|
||||
assert registry.get("h") == registry.get("?")
|
||||
|
||||
def test_get_nonexistent_command(self):
|
||||
"""Test getting a non-existent command."""
|
||||
registry = SlashCommandRegistry()
|
||||
assert registry.get("nonexistent") is None
|
||||
|
||||
def test_unregister_command(self):
|
||||
"""Test unregistering a command."""
|
||||
registry = SlashCommandRegistry()
|
||||
cmd = SlashCommand(name="test", description="Test", alt_names=["t"])
|
||||
|
||||
registry.register(cmd)
|
||||
assert registry.get("test") is not None
|
||||
|
||||
result = registry.unregister("test")
|
||||
assert result is True
|
||||
assert registry.get("test") is None
|
||||
assert registry.get("t") is None # Alias should also be removed
|
||||
|
||||
def test_unregister_nonexistent(self):
|
||||
"""Test unregistering a non-existent command."""
|
||||
registry = SlashCommandRegistry()
|
||||
result = registry.unregister("nonexistent")
|
||||
assert result is False
|
||||
|
||||
def test_get_all_commands(self):
|
||||
"""Test getting all commands."""
|
||||
registry = SlashCommandRegistry()
|
||||
registry.register(SlashCommand(name="cmd1", description="Cmd 1"))
|
||||
registry.register(SlashCommand(name="cmd2", description="Cmd 2"))
|
||||
registry.register(SlashCommand(name="cmd3", description="Cmd 3"))
|
||||
|
||||
all_cmds = registry.get_all()
|
||||
assert len(all_cmds) == 3
|
||||
names = [cmd.name for cmd in all_cmds]
|
||||
assert "cmd1" in names
|
||||
assert "cmd2" in names
|
||||
assert "cmd3" in names
|
||||
|
||||
def test_get_names(self):
|
||||
"""Test getting all command names including aliases."""
|
||||
registry = SlashCommandRegistry()
|
||||
registry.register(SlashCommand(name="help", description="Help", alt_names=["h"]))
|
||||
registry.register(SlashCommand(name="exit", description="Exit", alt_names=["q", "quit"]))
|
||||
|
||||
names = registry.get_names()
|
||||
assert "help" in names
|
||||
assert "h" in names
|
||||
assert "exit" in names
|
||||
assert "q" in names
|
||||
assert "quit" in names
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SlashCommandParser Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestSlashCommandParser:
|
||||
"""Tests for SlashCommandParser."""
|
||||
|
||||
@pytest.fixture
|
||||
def parser(self):
|
||||
"""Create a parser with test commands."""
|
||||
registry = SlashCommandRegistry()
|
||||
registry.register(SlashCommand(name="help", description="Help", alt_names=["h"]))
|
||||
registry.register(SlashCommand(name="cost", description="Cost"))
|
||||
registry.register(SlashCommand(
|
||||
name="memory",
|
||||
description="Memory",
|
||||
sub_commands=[
|
||||
SlashCommand(name="add", description="Add memory"),
|
||||
SlashCommand(name="list", description="List memories"),
|
||||
]
|
||||
))
|
||||
return SlashCommandParser(registry)
|
||||
|
||||
def test_is_slash_command(self, parser):
|
||||
"""Test slash command detection."""
|
||||
assert parser.is_slash_command("/help") is True
|
||||
assert parser.is_slash_command("/cost") is True
|
||||
assert parser.is_slash_command("help") is False
|
||||
assert parser.is_slash_command("") is False
|
||||
|
||||
def test_is_not_code_comment(self, parser):
|
||||
"""Test that code comments are not detected as commands."""
|
||||
assert parser.is_slash_command("// This is a comment") is False
|
||||
assert parser.is_slash_command("/* Block comment */") is False
|
||||
|
||||
def test_parse_simple_command(self, parser):
|
||||
"""Test parsing a simple command."""
|
||||
result = parser.parse("/help")
|
||||
|
||||
assert result.command is not None
|
||||
assert result.command.name == "help"
|
||||
assert result.args == ""
|
||||
assert result.canonical_path == ["help"]
|
||||
assert result.error is None
|
||||
|
||||
def test_parse_command_with_args(self, parser):
|
||||
"""Test parsing a command with arguments."""
|
||||
result = parser.parse("/help model")
|
||||
|
||||
assert result.command is not None
|
||||
assert result.command.name == "help"
|
||||
assert result.args == "model"
|
||||
|
||||
def test_parse_command_by_alias(self, parser):
|
||||
"""Test parsing a command by alias."""
|
||||
result = parser.parse("/h")
|
||||
|
||||
assert result.command is not None
|
||||
assert result.command.name == "help"
|
||||
|
||||
def test_parse_subcommand(self, parser):
|
||||
"""Test parsing a sub-command."""
|
||||
result = parser.parse("/memory add some data")
|
||||
|
||||
assert result.command is not None
|
||||
assert result.command.name == "add"
|
||||
assert result.args == "some data"
|
||||
assert result.canonical_path == ["memory", "add"]
|
||||
|
||||
def test_parse_unknown_command(self, parser):
|
||||
"""Test parsing an unknown command."""
|
||||
result = parser.parse("/unknown")
|
||||
|
||||
assert result.command is None
|
||||
assert result.canonical_path == []
|
||||
|
||||
def test_parse_empty_command(self, parser):
|
||||
"""Test parsing empty command."""
|
||||
result = parser.parse("/")
|
||||
|
||||
assert result.error == "Empty command"
|
||||
|
||||
def test_parse_not_slash_command(self, parser):
|
||||
"""Test parsing non-slash command."""
|
||||
result = parser.parse("regular text")
|
||||
|
||||
assert result.error == "Not a slash command"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Built-in Command Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestBuiltinCommands:
|
||||
"""Tests for built-in commands."""
|
||||
|
||||
@pytest.fixture
|
||||
def context(self):
|
||||
"""Create a test context."""
|
||||
ctx = CommandContext(
|
||||
total_tokens=1000,
|
||||
total_cost=0.003,
|
||||
prompt_count=5,
|
||||
session_start_time=time.time() - 120 # 2 minutes ago
|
||||
)
|
||||
ctx.config["command_registry"] = create_default_registry()
|
||||
return ctx
|
||||
|
||||
def test_cmd_help_general(self, context):
|
||||
"""Test general help command."""
|
||||
with patch('rich.console.Console.print'):
|
||||
result = cmd_help(context, "")
|
||||
|
||||
assert result["type"] == "help"
|
||||
|
||||
def test_cmd_help_specific(self, context):
|
||||
"""Test help for specific command."""
|
||||
with patch('rich.console.Console.print'):
|
||||
result = cmd_help(context, "cost")
|
||||
|
||||
assert result["type"] == "help"
|
||||
assert result["command"] == "cost"
|
||||
|
||||
def test_cmd_help_unknown(self, context):
|
||||
"""Test help for unknown command."""
|
||||
with patch('rich.console.Console.print'):
|
||||
result = cmd_help(context, "unknown_cmd")
|
||||
|
||||
assert result["type"] == "error"
|
||||
|
||||
def test_cmd_cost(self, context):
|
||||
"""Test cost command."""
|
||||
with patch('rich.console.Console.print'):
|
||||
result = cmd_cost(context, "")
|
||||
|
||||
assert result["type"] == "stats"
|
||||
assert result["tokens"] == 1000
|
||||
assert result["cost"] == 0.003
|
||||
assert result["prompts"] == 5
|
||||
|
||||
def test_cmd_clear(self, context):
|
||||
"""Test clear command."""
|
||||
mock_agent = MagicMock()
|
||||
context.agent = mock_agent
|
||||
|
||||
with patch('rich.console.Console.print'):
|
||||
result = cmd_clear(context, "")
|
||||
|
||||
assert result["type"] == "clear"
|
||||
mock_agent.clear_history.assert_called_once()
|
||||
|
||||
def test_cmd_model_show(self, context):
|
||||
"""Test model command without args (show current)."""
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.llm = MagicMock(model="gpt-4o")
|
||||
context.agent = mock_agent
|
||||
|
||||
with patch('rich.console.Console.print'):
|
||||
result = cmd_model(context, "")
|
||||
|
||||
assert result["type"] == "model_info"
|
||||
assert result["model"] == "gpt-4o"
|
||||
|
||||
def test_cmd_model_change(self, context):
|
||||
"""Test model command with args (change model)."""
|
||||
mock_agent = MagicMock()
|
||||
context.agent = mock_agent
|
||||
|
||||
with patch('rich.console.Console.print'):
|
||||
result = cmd_model(context, "gpt-4o-mini")
|
||||
|
||||
assert result["type"] == "model_change"
|
||||
assert result["model"] == "gpt-4o-mini"
|
||||
|
||||
def test_cmd_tokens(self, context):
|
||||
"""Test tokens command."""
|
||||
with patch('rich.console.Console.print'):
|
||||
result = cmd_tokens(context, "")
|
||||
|
||||
assert result["type"] == "tokens"
|
||||
assert result["total"] == 1000
|
||||
|
||||
def test_cmd_plan_with_args(self, context):
|
||||
"""Test plan command with task."""
|
||||
with patch('rich.console.Console.print'):
|
||||
result = cmd_plan(context, "build a REST API")
|
||||
|
||||
assert result["type"] == "submit_prompt"
|
||||
assert "build a REST API" in result["content"]
|
||||
|
||||
def test_cmd_plan_without_args(self, context):
|
||||
"""Test plan command without task."""
|
||||
with patch('rich.console.Console.print'):
|
||||
result = cmd_plan(context, "")
|
||||
|
||||
assert result["type"] == "help"
|
||||
|
||||
def test_cmd_exit(self, context):
|
||||
"""Test exit command."""
|
||||
with patch('rich.console.Console.print'):
|
||||
result = cmd_exit(context, "")
|
||||
|
||||
assert result["type"] == "exit"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SlashCommandHandler Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestSlashCommandHandler:
|
||||
"""Tests for SlashCommandHandler."""
|
||||
|
||||
def test_handler_creation(self):
|
||||
"""Test handler creation."""
|
||||
handler = SlashCommandHandler()
|
||||
assert handler.registry is not None
|
||||
assert handler.parser is not None
|
||||
|
||||
def test_is_command(self):
|
||||
"""Test command detection."""
|
||||
handler = SlashCommandHandler()
|
||||
|
||||
assert handler.is_command("/help") is True
|
||||
assert handler.is_command("/cost") is True
|
||||
assert handler.is_command("regular text") is False
|
||||
|
||||
def test_execute_help(self):
|
||||
"""Test executing help command."""
|
||||
handler = SlashCommandHandler()
|
||||
|
||||
with patch('rich.console.Console.print'):
|
||||
result = handler.execute("/help")
|
||||
|
||||
assert result is not None
|
||||
assert result["type"] == "help"
|
||||
|
||||
def test_execute_unknown_command(self):
|
||||
"""Test executing unknown command."""
|
||||
handler = SlashCommandHandler()
|
||||
|
||||
result = handler.execute("/unknown_command_xyz")
|
||||
|
||||
assert result is not None
|
||||
assert result["type"] == "error"
|
||||
|
||||
def test_execute_non_command(self):
|
||||
"""Test executing non-command text."""
|
||||
handler = SlashCommandHandler()
|
||||
|
||||
result = handler.execute("regular text")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_register_custom_command(self):
|
||||
"""Test registering a custom command."""
|
||||
handler = SlashCommandHandler()
|
||||
|
||||
def custom_action(ctx, args):
|
||||
return {"type": "custom", "args": args}
|
||||
|
||||
handler.register_command(SlashCommand(
|
||||
name="custom",
|
||||
description="Custom command",
|
||||
action=custom_action
|
||||
))
|
||||
|
||||
result = handler.execute("/custom test args")
|
||||
|
||||
assert result is not None
|
||||
assert result["type"] == "custom"
|
||||
assert result["args"] == "test args"
|
||||
|
||||
def test_get_completions(self):
|
||||
"""Test command completions."""
|
||||
handler = SlashCommandHandler()
|
||||
|
||||
completions = handler.get_completions("/he")
|
||||
assert "/help" in completions
|
||||
|
||||
completions = handler.get_completions("/co")
|
||||
assert "/cost" in completions
|
||||
assert "/commit" in completions
|
||||
|
||||
completions = handler.get_completions("regular")
|
||||
assert completions == []
|
||||
|
||||
def test_set_context(self):
|
||||
"""Test setting context."""
|
||||
handler = SlashCommandHandler()
|
||||
ctx = CommandContext(total_tokens=500)
|
||||
|
||||
handler.set_context(ctx)
|
||||
|
||||
assert handler._context is ctx
|
||||
assert "command_registry" in ctx.config
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Default Registry Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestDefaultRegistry:
|
||||
"""Tests for default command registry."""
|
||||
|
||||
def test_default_registry_has_core_commands(self):
|
||||
"""Test that default registry has core commands."""
|
||||
registry = create_default_registry()
|
||||
|
||||
# Core commands should exist
|
||||
assert registry.get("help") is not None
|
||||
assert registry.get("cost") is not None
|
||||
assert registry.get("clear") is not None
|
||||
assert registry.get("model") is not None
|
||||
assert registry.get("tokens") is not None
|
||||
assert registry.get("plan") is not None
|
||||
assert registry.get("exit") is not None
|
||||
|
||||
def test_default_registry_aliases(self):
|
||||
"""Test that default registry has aliases."""
|
||||
registry = create_default_registry()
|
||||
|
||||
# Help aliases
|
||||
assert registry.get("h") is not None
|
||||
assert registry.get("?") is not None
|
||||
|
||||
# Exit aliases
|
||||
assert registry.get("q") is not None
|
||||
assert registry.get("quit") is not None
|
||||
|
||||
# Cost aliases
|
||||
assert registry.get("usage") is not None
|
||||
assert registry.get("stats") is not None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration Tests (require real execution)
|
||||
# ============================================================================
|
||||
|
||||
class TestSlashCommandsIntegration:
|
||||
"""Integration tests for slash commands."""
|
||||
|
||||
def test_full_command_flow(self):
|
||||
"""Test full command execution flow."""
|
||||
handler = SlashCommandHandler(verbose=True)
|
||||
|
||||
# Set up context
|
||||
ctx = CommandContext(
|
||||
total_tokens=2500,
|
||||
total_cost=0.0075,
|
||||
prompt_count=10,
|
||||
session_start_time=time.time() - 300
|
||||
)
|
||||
handler.set_context(ctx)
|
||||
|
||||
# Execute commands
|
||||
with patch('rich.console.Console.print'):
|
||||
help_result = handler.execute("/help")
|
||||
cost_result = handler.execute("/cost")
|
||||
tokens_result = handler.execute("/tokens")
|
||||
|
||||
assert help_result["type"] == "help"
|
||||
assert cost_result["type"] == "stats"
|
||||
assert cost_result["tokens"] == 2500
|
||||
assert tokens_result["type"] == "tokens"
|
||||
|
||||
def test_command_with_special_characters(self):
|
||||
"""Test commands with special characters in args."""
|
||||
handler = SlashCommandHandler()
|
||||
|
||||
with patch('rich.console.Console.print'):
|
||||
result = handler.execute("/plan Build a REST API with /users endpoint")
|
||||
|
||||
assert result is not None
|
||||
assert result["type"] == "submit_prompt"
|
||||
assert "/users" in result["content"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for CLI integrations."""
|
||||
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
Tests for BaseCLIIntegration class.
|
||||
|
||||
TDD approach: Write tests first, then implement.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the praisonai package to the path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||
|
||||
|
||||
class TestBaseCLIIntegration:
|
||||
"""Tests for the BaseCLIIntegration abstract base class."""
|
||||
|
||||
def test_import_base_integration(self):
|
||||
"""Test that BaseCLIIntegration can be imported."""
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
assert BaseCLIIntegration is not None
|
||||
|
||||
def test_base_integration_is_abstract(self):
|
||||
"""Test that BaseCLIIntegration cannot be instantiated directly."""
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
with pytest.raises(TypeError):
|
||||
BaseCLIIntegration()
|
||||
|
||||
def test_concrete_implementation_requires_cli_command(self):
|
||||
"""Test that concrete implementations must define cli_command."""
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
class IncompleteIntegration(BaseCLIIntegration):
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
IncompleteIntegration()
|
||||
|
||||
def test_is_available_returns_false_for_missing_cli(self):
|
||||
"""Test is_available returns False when CLI is not installed."""
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
class TestIntegration(BaseCLIIntegration):
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
return "nonexistent_cli_tool_12345"
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
return ""
|
||||
|
||||
async def stream(self, prompt: str, **options):
|
||||
yield {}
|
||||
|
||||
integration = TestIntegration()
|
||||
assert integration.is_available is False
|
||||
|
||||
def test_is_available_caches_result(self):
|
||||
"""Test that is_available caches the availability check."""
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
class TestIntegration(BaseCLIIntegration):
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
return "nonexistent_cli_12345"
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
return ""
|
||||
|
||||
async def stream(self, prompt: str, **options):
|
||||
yield {}
|
||||
|
||||
integration = TestIntegration()
|
||||
# First call
|
||||
result1 = integration.is_available
|
||||
# Second call should use cache
|
||||
result2 = integration.is_available
|
||||
assert result1 == result2
|
||||
|
||||
def test_workspace_default_is_current_dir(self):
|
||||
"""Test that default workspace is current directory."""
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
class TestIntegration(BaseCLIIntegration):
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
return "test"
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
return ""
|
||||
|
||||
async def stream(self, prompt: str, **options):
|
||||
yield {}
|
||||
|
||||
integration = TestIntegration()
|
||||
assert integration.workspace == "."
|
||||
|
||||
def test_workspace_can_be_set(self):
|
||||
"""Test that workspace can be set during initialization."""
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
class TestIntegration(BaseCLIIntegration):
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
return "test"
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
return ""
|
||||
|
||||
async def stream(self, prompt: str, **options):
|
||||
yield {}
|
||||
|
||||
integration = TestIntegration(workspace="/custom/path")
|
||||
assert integration.workspace == "/custom/path"
|
||||
|
||||
def test_timeout_default_is_300(self):
|
||||
"""Test that default timeout is 300 seconds."""
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
class TestIntegration(BaseCLIIntegration):
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
return "test"
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
return ""
|
||||
|
||||
async def stream(self, prompt: str, **options):
|
||||
yield {}
|
||||
|
||||
integration = TestIntegration()
|
||||
assert integration.timeout == 300
|
||||
|
||||
def test_as_tool_returns_callable(self):
|
||||
"""Test that as_tool returns a callable function."""
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
class TestIntegration(BaseCLIIntegration):
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
return "test"
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
return "test result"
|
||||
|
||||
async def stream(self, prompt: str, **options):
|
||||
yield {}
|
||||
|
||||
integration = TestIntegration()
|
||||
tool = integration.as_tool()
|
||||
assert callable(tool)
|
||||
|
||||
def test_as_tool_has_correct_name(self):
|
||||
"""Test that the tool function has the correct name."""
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
class TestIntegration(BaseCLIIntegration):
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
return "my_cli"
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
return ""
|
||||
|
||||
async def stream(self, prompt: str, **options):
|
||||
yield {}
|
||||
|
||||
integration = TestIntegration()
|
||||
tool = integration.as_tool()
|
||||
assert tool.__name__ == "my_cli_tool"
|
||||
|
||||
def test_as_tool_has_docstring(self):
|
||||
"""Test that the tool function has a docstring."""
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
class TestIntegration(BaseCLIIntegration):
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
return "test"
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
return ""
|
||||
|
||||
async def stream(self, prompt: str, **options):
|
||||
yield {}
|
||||
|
||||
integration = TestIntegration()
|
||||
tool = integration.as_tool()
|
||||
assert tool.__doc__ is not None
|
||||
assert "test" in tool.__doc__
|
||||
|
||||
|
||||
class TestBaseCLIIntegrationAsync:
|
||||
"""Async tests for BaseCLIIntegration."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_async_runs_command(self):
|
||||
"""Test that execute_async runs a command."""
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
class TestIntegration(BaseCLIIntegration):
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
return "echo"
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
return await self.execute_async(["echo", prompt])
|
||||
|
||||
async def stream(self, prompt: str, **options):
|
||||
yield {}
|
||||
|
||||
integration = TestIntegration()
|
||||
result = await integration.execute("hello")
|
||||
assert "hello" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_async_handles_timeout(self):
|
||||
"""Test that execute_async handles timeout."""
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
class TestIntegration(BaseCLIIntegration):
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
return "sleep"
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
return await self.execute_async(["sleep", "10"], timeout=0.1)
|
||||
|
||||
async def stream(self, prompt: str, **options):
|
||||
yield {}
|
||||
|
||||
integration = TestIntegration()
|
||||
with pytest.raises(TimeoutError):
|
||||
await integration.execute("test")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_async_captures_stderr(self):
|
||||
"""Test that execute_async captures stderr."""
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
class TestIntegration(BaseCLIIntegration):
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
return "bash"
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
stdout, stderr = await self.execute_async_with_stderr(
|
||||
["bash", "-c", "echo error >&2"]
|
||||
)
|
||||
return stderr
|
||||
|
||||
async def stream(self, prompt: str, **options):
|
||||
yield {}
|
||||
|
||||
integration = TestIntegration()
|
||||
result = await integration.execute("test")
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestAvailabilityCache:
|
||||
"""Tests for the availability caching mechanism."""
|
||||
|
||||
def test_availability_cache_is_class_level(self):
|
||||
"""Test that availability cache is shared across instances."""
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
class TestIntegration(BaseCLIIntegration):
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
return "shared_test_cli"
|
||||
|
||||
async def execute(self, prompt: str, **options) -> str:
|
||||
return ""
|
||||
|
||||
async def stream(self, prompt: str, **options):
|
||||
yield {}
|
||||
|
||||
integration1 = TestIntegration()
|
||||
integration2 = TestIntegration()
|
||||
|
||||
# Access is_available on first instance
|
||||
_ = integration1.is_available
|
||||
|
||||
# Second instance should use cached value
|
||||
assert "shared_test_cli" in BaseCLIIntegration._availability_cache
|
||||
|
||||
def test_clear_availability_cache(self):
|
||||
"""Test that availability cache can be cleared."""
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
# Clear cache
|
||||
BaseCLIIntegration._availability_cache.clear()
|
||||
assert len(BaseCLIIntegration._availability_cache) == 0
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Tests for ClaudeCodeIntegration.
|
||||
|
||||
TDD approach: Write tests first, then implement.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||
|
||||
|
||||
class TestClaudeCodeIntegration:
|
||||
"""Tests for ClaudeCodeIntegration class."""
|
||||
|
||||
def test_import_claude_code_integration(self):
|
||||
"""Test that ClaudeCodeIntegration can be imported."""
|
||||
from praisonai.integrations.claude_code import ClaudeCodeIntegration
|
||||
assert ClaudeCodeIntegration is not None
|
||||
|
||||
def test_cli_command_is_claude(self):
|
||||
"""Test that cli_command returns 'claude'."""
|
||||
from praisonai.integrations.claude_code import ClaudeCodeIntegration
|
||||
integration = ClaudeCodeIntegration()
|
||||
assert integration.cli_command == "claude"
|
||||
|
||||
def test_inherits_from_base(self):
|
||||
"""Test that ClaudeCodeIntegration inherits from BaseCLIIntegration."""
|
||||
from praisonai.integrations.claude_code import ClaudeCodeIntegration
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
integration = ClaudeCodeIntegration()
|
||||
assert isinstance(integration, BaseCLIIntegration)
|
||||
|
||||
def test_default_options(self):
|
||||
"""Test default options are set correctly."""
|
||||
from praisonai.integrations.claude_code import ClaudeCodeIntegration
|
||||
|
||||
integration = ClaudeCodeIntegration()
|
||||
assert integration.output_format == "json"
|
||||
assert integration.skip_permissions is True
|
||||
|
||||
def test_custom_options(self):
|
||||
"""Test custom options can be set."""
|
||||
from praisonai.integrations.claude_code import ClaudeCodeIntegration
|
||||
|
||||
integration = ClaudeCodeIntegration(
|
||||
output_format="text",
|
||||
skip_permissions=False,
|
||||
system_prompt="You are a Python expert"
|
||||
)
|
||||
assert integration.output_format == "text"
|
||||
assert integration.skip_permissions is False
|
||||
assert integration.system_prompt == "You are a Python expert"
|
||||
|
||||
def test_build_command_basic(self):
|
||||
"""Test building basic command."""
|
||||
from praisonai.integrations.claude_code import ClaudeCodeIntegration
|
||||
|
||||
integration = ClaudeCodeIntegration()
|
||||
cmd = integration._build_command("Hello")
|
||||
|
||||
assert cmd[0] == "claude"
|
||||
assert "-p" in cmd
|
||||
assert "Hello" in cmd
|
||||
assert "--output-format" in cmd
|
||||
assert "json" in cmd
|
||||
|
||||
def test_build_command_with_continue(self):
|
||||
"""Test building command with continue flag."""
|
||||
from praisonai.integrations.claude_code import ClaudeCodeIntegration
|
||||
|
||||
integration = ClaudeCodeIntegration()
|
||||
cmd = integration._build_command("Hello", continue_session=True)
|
||||
|
||||
assert "--continue" in cmd
|
||||
|
||||
def test_build_command_with_system_prompt(self):
|
||||
"""Test building command with system prompt."""
|
||||
from praisonai.integrations.claude_code import ClaudeCodeIntegration
|
||||
|
||||
integration = ClaudeCodeIntegration(system_prompt="Be helpful")
|
||||
cmd = integration._build_command("Hello")
|
||||
|
||||
assert "--append-system-prompt" in cmd
|
||||
assert "Be helpful" in cmd
|
||||
|
||||
def test_build_command_with_allowed_tools(self):
|
||||
"""Test building command with allowed tools."""
|
||||
from praisonai.integrations.claude_code import ClaudeCodeIntegration
|
||||
|
||||
integration = ClaudeCodeIntegration(allowed_tools=["Read", "Write"])
|
||||
cmd = integration._build_command("Hello")
|
||||
|
||||
assert "--allowedTools" in cmd
|
||||
|
||||
|
||||
class TestClaudeCodeIntegrationAsync:
|
||||
"""Async tests for ClaudeCodeIntegration."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_parses_json_output(self):
|
||||
"""Test that execute parses JSON output correctly."""
|
||||
from praisonai.integrations.claude_code import ClaudeCodeIntegration
|
||||
|
||||
integration = ClaudeCodeIntegration()
|
||||
|
||||
mock_output = json.dumps({
|
||||
"result": "Hello, world!",
|
||||
"cost_usd": 0.001
|
||||
})
|
||||
|
||||
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = mock_output
|
||||
result = await integration.execute("Say hello")
|
||||
|
||||
assert "Hello, world!" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_handles_text_output(self):
|
||||
"""Test that execute handles text output format."""
|
||||
from praisonai.integrations.claude_code import ClaudeCodeIntegration
|
||||
|
||||
integration = ClaudeCodeIntegration(output_format="text")
|
||||
|
||||
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = "Hello, world!"
|
||||
result = await integration.execute("Say hello")
|
||||
|
||||
assert result == "Hello, world!"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_yields_events(self):
|
||||
"""Test that stream yields parsed events."""
|
||||
from praisonai.integrations.claude_code import ClaudeCodeIntegration
|
||||
|
||||
integration = ClaudeCodeIntegration()
|
||||
|
||||
async def mock_stream(*args, **kwargs):
|
||||
yield '{"type": "assistant", "content": "Hello"}'
|
||||
yield '{"type": "result", "content": "Done"}'
|
||||
|
||||
with patch.object(integration, 'stream_async', side_effect=mock_stream):
|
||||
events = []
|
||||
async for event in integration.stream("Say hello"):
|
||||
events.append(event)
|
||||
|
||||
assert len(events) >= 0 # May be empty if stream_async not called correctly
|
||||
|
||||
|
||||
class TestClaudeCodeSDKIntegration:
|
||||
"""Tests for Claude Code SDK integration (when available)."""
|
||||
|
||||
def test_sdk_available_property(self):
|
||||
"""Test sdk_available property."""
|
||||
from praisonai.integrations.claude_code import ClaudeCodeIntegration
|
||||
|
||||
integration = ClaudeCodeIntegration()
|
||||
# Should be a boolean
|
||||
assert isinstance(integration.sdk_available, bool)
|
||||
|
||||
def test_use_sdk_option(self):
|
||||
"""Test use_sdk option."""
|
||||
from praisonai.integrations.claude_code import ClaudeCodeIntegration
|
||||
|
||||
integration = ClaudeCodeIntegration(use_sdk=True)
|
||||
assert integration.use_sdk is True
|
||||
|
||||
integration2 = ClaudeCodeIntegration(use_sdk=False)
|
||||
assert integration2.use_sdk is False
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Tests for CodexCLIIntegration.
|
||||
|
||||
TDD approach: Write tests first, then implement.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import patch, AsyncMock
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||
|
||||
|
||||
class TestCodexCLIIntegration:
|
||||
"""Tests for CodexCLIIntegration class."""
|
||||
|
||||
def test_import_codex_cli_integration(self):
|
||||
"""Test that CodexCLIIntegration can be imported."""
|
||||
from praisonai.integrations.codex_cli import CodexCLIIntegration
|
||||
assert CodexCLIIntegration is not None
|
||||
|
||||
def test_cli_command_is_codex(self):
|
||||
"""Test that cli_command returns 'codex'."""
|
||||
from praisonai.integrations.codex_cli import CodexCLIIntegration
|
||||
integration = CodexCLIIntegration()
|
||||
assert integration.cli_command == "codex"
|
||||
|
||||
def test_inherits_from_base(self):
|
||||
"""Test that CodexCLIIntegration inherits from BaseCLIIntegration."""
|
||||
from praisonai.integrations.codex_cli import CodexCLIIntegration
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
integration = CodexCLIIntegration()
|
||||
assert isinstance(integration, BaseCLIIntegration)
|
||||
|
||||
def test_default_options(self):
|
||||
"""Test default options are set correctly."""
|
||||
from praisonai.integrations.codex_cli import CodexCLIIntegration
|
||||
|
||||
integration = CodexCLIIntegration()
|
||||
assert integration.full_auto is False
|
||||
assert integration.sandbox == "default"
|
||||
|
||||
def test_full_auto_option(self):
|
||||
"""Test full_auto option can be set."""
|
||||
from praisonai.integrations.codex_cli import CodexCLIIntegration
|
||||
|
||||
integration = CodexCLIIntegration(full_auto=True)
|
||||
assert integration.full_auto is True
|
||||
|
||||
def test_sandbox_option(self):
|
||||
"""Test sandbox option can be set."""
|
||||
from praisonai.integrations.codex_cli import CodexCLIIntegration
|
||||
|
||||
integration = CodexCLIIntegration(sandbox="danger-full-access")
|
||||
assert integration.sandbox == "danger-full-access"
|
||||
|
||||
def test_build_command_basic(self):
|
||||
"""Test building basic command."""
|
||||
from praisonai.integrations.codex_cli import CodexCLIIntegration
|
||||
|
||||
integration = CodexCLIIntegration()
|
||||
cmd = integration._build_command("Fix the bug")
|
||||
|
||||
assert cmd[0] == "codex"
|
||||
assert "exec" in cmd
|
||||
assert "Fix the bug" in cmd
|
||||
|
||||
def test_build_command_with_full_auto(self):
|
||||
"""Test building command with full_auto."""
|
||||
from praisonai.integrations.codex_cli import CodexCLIIntegration
|
||||
|
||||
integration = CodexCLIIntegration(full_auto=True)
|
||||
cmd = integration._build_command("Fix the bug")
|
||||
|
||||
assert "--full-auto" in cmd
|
||||
|
||||
def test_build_command_with_json(self):
|
||||
"""Test building command with JSON output."""
|
||||
from praisonai.integrations.codex_cli import CodexCLIIntegration
|
||||
|
||||
integration = CodexCLIIntegration(json_output=True)
|
||||
cmd = integration._build_command("Fix the bug")
|
||||
|
||||
assert "--json" in cmd
|
||||
|
||||
def test_build_command_with_sandbox(self):
|
||||
"""Test building command with sandbox."""
|
||||
from praisonai.integrations.codex_cli import CodexCLIIntegration
|
||||
|
||||
integration = CodexCLIIntegration(sandbox="danger-full-access")
|
||||
cmd = integration._build_command("Fix the bug")
|
||||
|
||||
assert "--sandbox" in cmd
|
||||
assert "danger-full-access" in cmd
|
||||
|
||||
|
||||
class TestCodexCLIIntegrationAsync:
|
||||
"""Async tests for CodexCLIIntegration."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_returns_result(self):
|
||||
"""Test that execute returns the result."""
|
||||
from praisonai.integrations.codex_cli import CodexCLIIntegration
|
||||
|
||||
integration = CodexCLIIntegration()
|
||||
|
||||
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = "Task completed successfully"
|
||||
result = await integration.execute("Fix the bug")
|
||||
|
||||
assert "Task completed" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_parses_json_events(self):
|
||||
"""Test that execute parses JSON events when json_output is True."""
|
||||
from praisonai.integrations.codex_cli import CodexCLIIntegration
|
||||
|
||||
integration = CodexCLIIntegration(json_output=True)
|
||||
|
||||
mock_output = '\n'.join([
|
||||
'{"type":"thread.started","thread_id":"123"}',
|
||||
'{"type":"item.completed","item":{"type":"agent_message","text":"Done"}}',
|
||||
'{"type":"turn.completed"}'
|
||||
])
|
||||
|
||||
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = mock_output
|
||||
result = await integration.execute("Fix the bug")
|
||||
|
||||
assert "Done" in result
|
||||
|
||||
|
||||
class TestCodexCLIStructuredOutput:
|
||||
"""Tests for Codex CLI structured output."""
|
||||
|
||||
def test_output_schema_option(self):
|
||||
"""Test output_schema option."""
|
||||
from praisonai.integrations.codex_cli import CodexCLIIntegration
|
||||
|
||||
integration = CodexCLIIntegration(output_schema="/path/to/schema.json")
|
||||
assert integration.output_schema == "/path/to/schema.json"
|
||||
|
||||
def test_build_command_with_output_schema(self):
|
||||
"""Test building command with output schema."""
|
||||
from praisonai.integrations.codex_cli import CodexCLIIntegration
|
||||
|
||||
integration = CodexCLIIntegration(output_schema="/path/to/schema.json")
|
||||
cmd = integration._build_command("Extract metadata")
|
||||
|
||||
assert "--output-schema" in cmd
|
||||
assert "/path/to/schema.json" in cmd
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Tests for CursorCLIIntegration.
|
||||
|
||||
TDD approach: Write tests first, then implement.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import patch, AsyncMock
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||
|
||||
|
||||
class TestCursorCLIIntegration:
|
||||
"""Tests for CursorCLIIntegration class."""
|
||||
|
||||
def test_import_cursor_cli_integration(self):
|
||||
"""Test that CursorCLIIntegration can be imported."""
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
assert CursorCLIIntegration is not None
|
||||
|
||||
def test_cli_command_is_cursor_agent(self):
|
||||
"""Test that cli_command returns 'cursor-agent'."""
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
integration = CursorCLIIntegration()
|
||||
assert integration.cli_command == "cursor-agent"
|
||||
|
||||
def test_inherits_from_base(self):
|
||||
"""Test that CursorCLIIntegration inherits from BaseCLIIntegration."""
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
integration = CursorCLIIntegration()
|
||||
assert isinstance(integration, BaseCLIIntegration)
|
||||
|
||||
def test_default_options(self):
|
||||
"""Test default options are set correctly."""
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
|
||||
integration = CursorCLIIntegration()
|
||||
assert integration.output_format == "json"
|
||||
assert integration.force is False
|
||||
|
||||
def test_force_option(self):
|
||||
"""Test force option can be set."""
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
|
||||
integration = CursorCLIIntegration(force=True)
|
||||
assert integration.force is True
|
||||
|
||||
def test_model_option(self):
|
||||
"""Test model option can be set."""
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
|
||||
integration = CursorCLIIntegration(model="gpt-5")
|
||||
assert integration.model == "gpt-5"
|
||||
|
||||
def test_build_command_basic(self):
|
||||
"""Test building basic command."""
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
|
||||
integration = CursorCLIIntegration()
|
||||
cmd = integration._build_command("Fix the bug")
|
||||
|
||||
assert cmd[0] == "cursor-agent"
|
||||
assert "-p" in cmd
|
||||
assert "Fix the bug" in cmd
|
||||
assert "--output-format" in cmd
|
||||
assert "json" in cmd
|
||||
|
||||
def test_build_command_with_force(self):
|
||||
"""Test building command with force flag."""
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
|
||||
integration = CursorCLIIntegration(force=True)
|
||||
cmd = integration._build_command("Fix the bug")
|
||||
|
||||
assert "--force" in cmd
|
||||
|
||||
def test_build_command_with_model(self):
|
||||
"""Test building command with model."""
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
|
||||
integration = CursorCLIIntegration(model="gpt-5")
|
||||
cmd = integration._build_command("Fix the bug")
|
||||
|
||||
assert "-m" in cmd
|
||||
assert "gpt-5" in cmd
|
||||
|
||||
def test_build_command_with_stream_partial(self):
|
||||
"""Test building command with stream partial output."""
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
|
||||
integration = CursorCLIIntegration(stream_partial=True)
|
||||
cmd = integration._build_command("Fix the bug")
|
||||
|
||||
assert "--stream-partial-output" in cmd
|
||||
|
||||
|
||||
class TestCursorCLIIntegrationAsync:
|
||||
"""Async tests for CursorCLIIntegration."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_parses_json_output(self):
|
||||
"""Test that execute parses JSON output correctly."""
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
|
||||
integration = CursorCLIIntegration()
|
||||
|
||||
mock_output = json.dumps({
|
||||
"result": "Bug fixed successfully"
|
||||
})
|
||||
|
||||
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = mock_output
|
||||
result = await integration.execute("Fix the bug")
|
||||
|
||||
assert "Bug fixed" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_handles_text_output(self):
|
||||
"""Test that execute handles text output format."""
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
|
||||
integration = CursorCLIIntegration(output_format="text")
|
||||
|
||||
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = "Bug fixed successfully"
|
||||
result = await integration.execute("Fix the bug")
|
||||
|
||||
assert result == "Bug fixed successfully"
|
||||
|
||||
|
||||
class TestCursorCLIEnvironment:
|
||||
"""Tests for Cursor CLI environment handling."""
|
||||
|
||||
def test_get_env_includes_cursor_api_key(self):
|
||||
"""Test that get_env includes Cursor API key."""
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
|
||||
integration = CursorCLIIntegration()
|
||||
|
||||
with patch.dict(os.environ, {"CURSOR_API_KEY": "test-key"}):
|
||||
env = integration.get_env()
|
||||
assert "CURSOR_API_KEY" in env
|
||||
|
||||
def test_api_key_required_for_headless(self):
|
||||
"""Test that API key is required for headless mode."""
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
|
||||
integration = CursorCLIIntegration()
|
||||
# Should not raise, just return env without key if not set
|
||||
env = integration.get_env()
|
||||
assert isinstance(env, dict)
|
||||
|
||||
|
||||
class TestCursorCLISession:
|
||||
"""Tests for Cursor CLI session management."""
|
||||
|
||||
def test_resume_option(self):
|
||||
"""Test resume option."""
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
|
||||
integration = CursorCLIIntegration(resume_session="session-123")
|
||||
assert integration.resume_session == "session-123"
|
||||
|
||||
def test_build_command_with_resume(self):
|
||||
"""Test building command with resume."""
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
|
||||
integration = CursorCLIIntegration(resume_session="session-123")
|
||||
cmd = integration._build_command("Continue the task")
|
||||
|
||||
assert "--resume" in cmd
|
||||
assert "session-123" in cmd
|
||||
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Tests for ExternalAgentsHandler.
|
||||
|
||||
TDD approach: Write tests first, then implement.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||
|
||||
|
||||
class TestExternalAgentsHandler:
|
||||
"""Tests for ExternalAgentsHandler class."""
|
||||
|
||||
def test_import_external_agents_handler(self):
|
||||
"""Test that ExternalAgentsHandler can be imported."""
|
||||
from praisonai.cli.features.external_agents import ExternalAgentsHandler
|
||||
assert ExternalAgentsHandler is not None
|
||||
|
||||
def test_feature_name(self):
|
||||
"""Test that feature_name is correct."""
|
||||
from praisonai.cli.features.external_agents import ExternalAgentsHandler
|
||||
handler = ExternalAgentsHandler()
|
||||
assert handler.feature_name == "external_agents"
|
||||
|
||||
def test_flag_name(self):
|
||||
"""Test that flag_name is correct."""
|
||||
from praisonai.cli.features.external_agents import ExternalAgentsHandler
|
||||
handler = ExternalAgentsHandler()
|
||||
assert handler.flag_name == "external-agent"
|
||||
|
||||
def test_supported_integrations(self):
|
||||
"""Test that all integrations are supported."""
|
||||
from praisonai.cli.features.external_agents import ExternalAgentsHandler
|
||||
handler = ExternalAgentsHandler()
|
||||
|
||||
assert "claude" in handler.INTEGRATIONS
|
||||
assert "gemini" in handler.INTEGRATIONS
|
||||
assert "codex" in handler.INTEGRATIONS
|
||||
assert "cursor" in handler.INTEGRATIONS
|
||||
|
||||
def test_get_integration_claude(self):
|
||||
"""Test getting Claude integration."""
|
||||
from praisonai.cli.features.external_agents import ExternalAgentsHandler
|
||||
from praisonai.integrations.claude_code import ClaudeCodeIntegration
|
||||
|
||||
handler = ExternalAgentsHandler()
|
||||
integration = handler.get_integration("claude")
|
||||
|
||||
assert isinstance(integration, ClaudeCodeIntegration)
|
||||
|
||||
def test_get_integration_gemini(self):
|
||||
"""Test getting Gemini integration."""
|
||||
from praisonai.cli.features.external_agents import ExternalAgentsHandler
|
||||
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
|
||||
|
||||
handler = ExternalAgentsHandler()
|
||||
integration = handler.get_integration("gemini")
|
||||
|
||||
assert isinstance(integration, GeminiCLIIntegration)
|
||||
|
||||
def test_get_integration_codex(self):
|
||||
"""Test getting Codex integration."""
|
||||
from praisonai.cli.features.external_agents import ExternalAgentsHandler
|
||||
from praisonai.integrations.codex_cli import CodexCLIIntegration
|
||||
|
||||
handler = ExternalAgentsHandler()
|
||||
integration = handler.get_integration("codex")
|
||||
|
||||
assert isinstance(integration, CodexCLIIntegration)
|
||||
|
||||
def test_get_integration_cursor(self):
|
||||
"""Test getting Cursor integration."""
|
||||
from praisonai.cli.features.external_agents import ExternalAgentsHandler
|
||||
from praisonai.integrations.cursor_cli import CursorCLIIntegration
|
||||
|
||||
handler = ExternalAgentsHandler()
|
||||
integration = handler.get_integration("cursor")
|
||||
|
||||
assert isinstance(integration, CursorCLIIntegration)
|
||||
|
||||
def test_get_integration_invalid(self):
|
||||
"""Test getting invalid integration raises error."""
|
||||
from praisonai.cli.features.external_agents import ExternalAgentsHandler
|
||||
|
||||
handler = ExternalAgentsHandler()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
handler.get_integration("invalid")
|
||||
|
||||
def test_list_integrations(self):
|
||||
"""Test listing all integrations."""
|
||||
from praisonai.cli.features.external_agents import ExternalAgentsHandler
|
||||
|
||||
handler = ExternalAgentsHandler()
|
||||
integrations = handler.list_integrations()
|
||||
|
||||
assert len(integrations) == 4
|
||||
assert all(name in integrations for name in ["claude", "gemini", "codex", "cursor"])
|
||||
|
||||
def test_check_availability(self):
|
||||
"""Test checking integration availability."""
|
||||
from praisonai.cli.features.external_agents import ExternalAgentsHandler
|
||||
|
||||
handler = ExternalAgentsHandler()
|
||||
availability = handler.check_availability()
|
||||
|
||||
assert isinstance(availability, dict)
|
||||
assert "claude" in availability
|
||||
assert isinstance(availability["claude"], bool)
|
||||
|
||||
|
||||
class TestExternalAgentsHandlerConfig:
|
||||
"""Tests for ExternalAgentsHandler configuration."""
|
||||
|
||||
def test_apply_to_agent_config_adds_tool(self):
|
||||
"""Test that apply_to_agent_config adds tool to config."""
|
||||
from praisonai.cli.features.external_agents import ExternalAgentsHandler
|
||||
|
||||
handler = ExternalAgentsHandler()
|
||||
config = {"tools": []}
|
||||
|
||||
# Mock the integration to be available
|
||||
with patch.object(handler, 'get_integration') as mock_get:
|
||||
mock_integration = MagicMock()
|
||||
mock_integration.is_available = True
|
||||
mock_integration.as_tool.return_value = lambda x: x
|
||||
mock_get.return_value = mock_integration
|
||||
|
||||
result = handler.apply_to_agent_config(config, "claude")
|
||||
|
||||
assert len(result["tools"]) == 1
|
||||
|
||||
def test_apply_to_agent_config_skips_unavailable(self):
|
||||
"""Test that apply_to_agent_config skips unavailable integrations."""
|
||||
from praisonai.cli.features.external_agents import ExternalAgentsHandler
|
||||
|
||||
handler = ExternalAgentsHandler()
|
||||
config = {"tools": []}
|
||||
|
||||
with patch.object(handler, 'get_integration') as mock_get:
|
||||
mock_integration = MagicMock()
|
||||
mock_integration.is_available = False
|
||||
mock_get.return_value = mock_integration
|
||||
|
||||
result = handler.apply_to_agent_config(config, "claude")
|
||||
|
||||
assert len(result["tools"]) == 0
|
||||
|
||||
|
||||
class TestExternalAgentsHandlerExecution:
|
||||
"""Tests for ExternalAgentsHandler execution."""
|
||||
|
||||
def test_execute_returns_integration(self):
|
||||
"""Test that execute returns the integration."""
|
||||
from praisonai.cli.features.external_agents import ExternalAgentsHandler
|
||||
|
||||
handler = ExternalAgentsHandler()
|
||||
|
||||
with patch.object(handler, 'get_integration') as mock_get:
|
||||
mock_integration = MagicMock()
|
||||
mock_integration.is_available = True
|
||||
mock_get.return_value = mock_integration
|
||||
|
||||
result = handler.execute(integration_name="claude")
|
||||
|
||||
assert result == mock_integration
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Tests for GeminiCLIIntegration.
|
||||
|
||||
TDD approach: Write tests first, then implement.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import patch, AsyncMock
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||
|
||||
|
||||
class TestGeminiCLIIntegration:
|
||||
"""Tests for GeminiCLIIntegration class."""
|
||||
|
||||
def test_import_gemini_cli_integration(self):
|
||||
"""Test that GeminiCLIIntegration can be imported."""
|
||||
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
|
||||
assert GeminiCLIIntegration is not None
|
||||
|
||||
def test_cli_command_is_gemini(self):
|
||||
"""Test that cli_command returns 'gemini'."""
|
||||
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
|
||||
integration = GeminiCLIIntegration()
|
||||
assert integration.cli_command == "gemini"
|
||||
|
||||
def test_inherits_from_base(self):
|
||||
"""Test that GeminiCLIIntegration inherits from BaseCLIIntegration."""
|
||||
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
|
||||
from praisonai.integrations.base import BaseCLIIntegration
|
||||
|
||||
integration = GeminiCLIIntegration()
|
||||
assert isinstance(integration, BaseCLIIntegration)
|
||||
|
||||
def test_default_options(self):
|
||||
"""Test default options are set correctly."""
|
||||
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
|
||||
|
||||
integration = GeminiCLIIntegration()
|
||||
assert integration.output_format == "json"
|
||||
assert integration.model == "gemini-2.5-pro"
|
||||
|
||||
def test_custom_model(self):
|
||||
"""Test custom model can be set."""
|
||||
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
|
||||
|
||||
integration = GeminiCLIIntegration(model="gemini-2.5-flash")
|
||||
assert integration.model == "gemini-2.5-flash"
|
||||
|
||||
def test_build_command_basic(self):
|
||||
"""Test building basic command."""
|
||||
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
|
||||
|
||||
integration = GeminiCLIIntegration()
|
||||
cmd = integration._build_command("Hello")
|
||||
|
||||
assert cmd[0] == "gemini"
|
||||
assert "-p" in cmd
|
||||
assert "Hello" in cmd
|
||||
assert "--output-format" in cmd
|
||||
assert "json" in cmd
|
||||
|
||||
def test_build_command_with_model(self):
|
||||
"""Test building command with model."""
|
||||
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
|
||||
|
||||
integration = GeminiCLIIntegration(model="gemini-2.5-flash")
|
||||
cmd = integration._build_command("Hello")
|
||||
|
||||
assert "-m" in cmd
|
||||
assert "gemini-2.5-flash" in cmd
|
||||
|
||||
def test_build_command_with_include_directories(self):
|
||||
"""Test building command with include directories."""
|
||||
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
|
||||
|
||||
integration = GeminiCLIIntegration(include_directories=["../lib", "../docs"])
|
||||
cmd = integration._build_command("Hello")
|
||||
|
||||
assert "--include-directories" in cmd
|
||||
|
||||
|
||||
class TestGeminiCLIIntegrationAsync:
|
||||
"""Async tests for GeminiCLIIntegration."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_parses_json_output(self):
|
||||
"""Test that execute parses JSON output correctly."""
|
||||
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
|
||||
|
||||
integration = GeminiCLIIntegration()
|
||||
|
||||
mock_output = json.dumps({
|
||||
"response": "Hello, world!",
|
||||
"stats": {"models": {}}
|
||||
})
|
||||
|
||||
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = mock_output
|
||||
result = await integration.execute("Say hello")
|
||||
|
||||
assert "Hello, world!" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_handles_text_output(self):
|
||||
"""Test that execute handles text output format."""
|
||||
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
|
||||
|
||||
integration = GeminiCLIIntegration(output_format="text")
|
||||
|
||||
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = "Hello, world!"
|
||||
result = await integration.execute("Say hello")
|
||||
|
||||
assert result == "Hello, world!"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_stats_returns_usage_info(self):
|
||||
"""Test that get_stats returns usage information."""
|
||||
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
|
||||
|
||||
integration = GeminiCLIIntegration()
|
||||
|
||||
mock_output = json.dumps({
|
||||
"response": "Hello",
|
||||
"stats": {
|
||||
"models": {
|
||||
"gemini-2.5-pro": {
|
||||
"tokens": {"prompt": 100, "candidates": 50}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = mock_output
|
||||
result, stats = await integration.execute_with_stats("Say hello")
|
||||
|
||||
assert stats is not None
|
||||
assert "models" in stats
|
||||
|
||||
|
||||
class TestGeminiCLIEnvironment:
|
||||
"""Tests for Gemini CLI environment handling."""
|
||||
|
||||
def test_get_env_includes_google_api_key(self):
|
||||
"""Test that get_env includes Google API key."""
|
||||
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
|
||||
|
||||
integration = GeminiCLIIntegration()
|
||||
|
||||
with patch.dict(os.environ, {"GOOGLE_API_KEY": "test-key"}):
|
||||
env = integration.get_env()
|
||||
assert "GOOGLE_API_KEY" in env
|
||||
|
||||
def test_get_env_includes_gemini_api_key(self):
|
||||
"""Test that get_env includes Gemini API key."""
|
||||
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
|
||||
|
||||
integration = GeminiCLIIntegration()
|
||||
|
||||
with patch.dict(os.environ, {"GEMINI_API_KEY": "test-key"}):
|
||||
env = integration.get_env()
|
||||
# Should map GEMINI_API_KEY to GOOGLE_API_KEY
|
||||
assert "GOOGLE_API_KEY" in env or "GEMINI_API_KEY" in env
|
||||
@@ -0,0 +1,743 @@
|
||||
"""
|
||||
Unit tests for AutoGenerator and WorkflowAutoGenerator classes.
|
||||
|
||||
Tests cover:
|
||||
- Default model value (gpt-4o-mini)
|
||||
- Tools preservation (not replaced with [''])
|
||||
- LiteLLM fallback to OpenAI
|
||||
- Lazy loading of client
|
||||
- Timeout and max_retries configuration
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
# Add the source path for imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
try:
|
||||
from praisonai.auto import AutoGenerator, WorkflowAutoGenerator, LITELLM_AVAILABLE
|
||||
except ImportError as e:
|
||||
pytest.skip(f"Could not import required modules: {e}", allow_module_level=True)
|
||||
|
||||
|
||||
class TestAutoGeneratorDefaultModel:
|
||||
"""Test suite for default model configuration."""
|
||||
|
||||
def test_default_model_is_gpt4o_mini(self):
|
||||
"""Test that default model is gpt-4o-mini, not gpt-5-nano."""
|
||||
# Clear environment variables to test defaults
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
# Set required API key
|
||||
os.environ['OPENAI_API_KEY'] = 'test-key'
|
||||
|
||||
generator = AutoGenerator(
|
||||
topic="Test topic",
|
||||
framework="praisonai"
|
||||
)
|
||||
|
||||
# Check that default model is gpt-4o-mini
|
||||
assert generator.config_list[0]['model'] == 'gpt-4o-mini'
|
||||
|
||||
def test_model_from_environment_variable(self):
|
||||
"""Test that MODEL_NAME environment variable is respected."""
|
||||
with patch.dict(os.environ, {'MODEL_NAME': 'custom-model', 'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = AutoGenerator(
|
||||
topic="Test topic",
|
||||
framework="praisonai"
|
||||
)
|
||||
|
||||
assert generator.config_list[0]['model'] == 'custom-model'
|
||||
|
||||
def test_openai_model_name_fallback(self):
|
||||
"""Test that OPENAI_MODEL_NAME is used as fallback."""
|
||||
with patch.dict(os.environ, {'OPENAI_MODEL_NAME': 'gpt-4', 'OPENAI_API_KEY': 'test-key'}, clear=True):
|
||||
generator = AutoGenerator(
|
||||
topic="Test topic",
|
||||
framework="praisonai"
|
||||
)
|
||||
|
||||
assert generator.config_list[0]['model'] == 'gpt-4'
|
||||
|
||||
|
||||
class TestAutoGeneratorToolsPreservation:
|
||||
"""Test suite for tools preservation in generated YAML."""
|
||||
|
||||
def test_tools_are_preserved_in_convert_and_save(self):
|
||||
"""Test that generated tools are preserved, not replaced with ['']."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = AutoGenerator(
|
||||
topic="Test topic",
|
||||
framework="praisonai"
|
||||
)
|
||||
|
||||
# Mock JSON data with tools
|
||||
json_data = {
|
||||
'roles': {
|
||||
'researcher': {
|
||||
'role': 'Researcher',
|
||||
'goal': 'Research topics',
|
||||
'backstory': 'Expert researcher',
|
||||
'tools': ['ScrapeWebsiteTool', 'WebsiteSearchTool'],
|
||||
'tasks': {
|
||||
'research_task': {
|
||||
'description': 'Research the topic',
|
||||
'expected_output': 'Research report'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Use a temporary file
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
||||
generator.agent_file = f.name
|
||||
|
||||
try:
|
||||
generator.convert_and_save(json_data, merge=False)
|
||||
|
||||
# Read the saved file
|
||||
import yaml
|
||||
with open(generator.agent_file, 'r') as f:
|
||||
saved_data = yaml.safe_load(f)
|
||||
|
||||
# Check that tools are preserved
|
||||
assert saved_data['roles']['researcher']['tools'] == ['ScrapeWebsiteTool', 'WebsiteSearchTool']
|
||||
finally:
|
||||
os.unlink(generator.agent_file)
|
||||
|
||||
def test_tools_preserved_in_merge(self):
|
||||
"""Test that tools are preserved when merging with existing file."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = AutoGenerator(
|
||||
topic="Test topic",
|
||||
framework="praisonai"
|
||||
)
|
||||
|
||||
# Create existing file
|
||||
existing_data = {
|
||||
'framework': 'praisonai',
|
||||
'topic': 'Existing topic',
|
||||
'roles': {},
|
||||
'dependencies': []
|
||||
}
|
||||
|
||||
# New JSON data with tools
|
||||
new_json_data = {
|
||||
'roles': {
|
||||
'writer': {
|
||||
'role': 'Writer',
|
||||
'goal': 'Write content',
|
||||
'backstory': 'Expert writer',
|
||||
'tools': ['FileReadTool', 'TXTSearchTool'],
|
||||
'tasks': {
|
||||
'write_task': {
|
||||
'description': 'Write the content',
|
||||
'expected_output': 'Written content'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Use a temporary file
|
||||
import yaml
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
||||
yaml.dump(existing_data, f)
|
||||
generator.agent_file = f.name
|
||||
|
||||
try:
|
||||
generator.convert_and_save(new_json_data, merge=True)
|
||||
|
||||
# Read the saved file
|
||||
with open(generator.agent_file, 'r') as f:
|
||||
saved_data = yaml.safe_load(f)
|
||||
|
||||
# Check that tools are preserved
|
||||
assert saved_data['roles']['writer']['tools'] == ['FileReadTool', 'TXTSearchTool']
|
||||
finally:
|
||||
os.unlink(generator.agent_file)
|
||||
|
||||
|
||||
class TestAutoGeneratorLazyLoading:
|
||||
"""Test suite for lazy loading of client."""
|
||||
|
||||
def test_client_is_not_created_on_init(self):
|
||||
"""Test that client is not created during __init__."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = AutoGenerator(
|
||||
topic="Test topic",
|
||||
framework="praisonai"
|
||||
)
|
||||
|
||||
# Check that _client is None (lazy loading)
|
||||
assert generator._client is None
|
||||
|
||||
def test_client_is_created_on_first_access(self):
|
||||
"""Test that client is created on first access."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = AutoGenerator(
|
||||
topic="Test topic",
|
||||
framework="praisonai"
|
||||
)
|
||||
|
||||
# Access client property
|
||||
with patch('praisonai.auto.instructor') as mock_instructor:
|
||||
mock_instructor.from_litellm.return_value = Mock()
|
||||
mock_instructor.patch.return_value = Mock()
|
||||
|
||||
client = generator.client
|
||||
|
||||
# Check that client is now set
|
||||
assert generator._client is not None
|
||||
|
||||
|
||||
class TestAutoGeneratorLiteLLMFallback:
|
||||
"""Test suite for LiteLLM fallback to OpenAI."""
|
||||
|
||||
def test_uses_litellm_when_available(self):
|
||||
"""Test that LiteLLM is used when available via from_provider."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
with patch('praisonai.auto.LITELLM_AVAILABLE', True):
|
||||
with patch('praisonai.auto.instructor') as mock_instructor:
|
||||
mock_instructor.from_provider.return_value = Mock()
|
||||
|
||||
generator = AutoGenerator(
|
||||
topic="Test topic",
|
||||
framework="praisonai"
|
||||
)
|
||||
|
||||
# Access client to trigger creation
|
||||
_ = generator.client
|
||||
|
||||
# Check that from_provider was called with litellm prefix
|
||||
mock_instructor.from_provider.assert_called_once()
|
||||
call_args = mock_instructor.from_provider.call_args[0][0]
|
||||
assert call_args.startswith('litellm/')
|
||||
|
||||
def test_falls_back_to_openai_when_litellm_unavailable(self):
|
||||
"""Test that OpenAI SDK is used when LiteLLM is not available."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
with patch('praisonai.auto.LITELLM_AVAILABLE', False):
|
||||
with patch('praisonai.auto.instructor') as mock_instructor:
|
||||
with patch('praisonai.auto.OpenAI') as mock_openai:
|
||||
mock_instructor.patch.return_value = Mock()
|
||||
mock_openai.return_value = Mock()
|
||||
|
||||
generator = AutoGenerator(
|
||||
topic="Test topic",
|
||||
framework="praisonai"
|
||||
)
|
||||
|
||||
# Access client to trigger creation
|
||||
_ = generator.client
|
||||
|
||||
# Check that patch was called (OpenAI fallback)
|
||||
mock_instructor.patch.assert_called_once()
|
||||
|
||||
|
||||
class TestWorkflowAutoGenerator:
|
||||
"""Test suite for WorkflowAutoGenerator."""
|
||||
|
||||
def test_default_model_is_gpt4o_mini(self):
|
||||
"""Test that default model is gpt-4o-mini."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
os.environ['OPENAI_API_KEY'] = 'test-key'
|
||||
|
||||
generator = WorkflowAutoGenerator(
|
||||
topic="Test workflow"
|
||||
)
|
||||
|
||||
assert generator.config_list[0]['model'] == 'gpt-4o-mini'
|
||||
|
||||
def test_lazy_loading(self):
|
||||
"""Test that client uses lazy loading."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(
|
||||
topic="Test workflow"
|
||||
)
|
||||
|
||||
assert generator._client is None
|
||||
|
||||
def test_litellm_fallback(self):
|
||||
"""Test LiteLLM fallback to OpenAI."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
with patch('praisonai.auto.LITELLM_AVAILABLE', False):
|
||||
with patch('praisonai.auto.instructor') as mock_instructor:
|
||||
with patch('praisonai.auto.OpenAI') as mock_openai:
|
||||
mock_instructor.patch.return_value = Mock()
|
||||
mock_openai.return_value = Mock()
|
||||
|
||||
generator = WorkflowAutoGenerator(
|
||||
topic="Test workflow"
|
||||
)
|
||||
|
||||
# Access client to trigger creation
|
||||
_ = generator.client
|
||||
|
||||
# Check that patch was called (OpenAI fallback)
|
||||
mock_instructor.patch.assert_called_once()
|
||||
|
||||
|
||||
class TestAutoGeneratorJSONMode:
|
||||
"""Test suite for JSON mode (critical fix for nested Dict structures)."""
|
||||
|
||||
def test_uses_json_mode_not_tools_mode(self):
|
||||
"""Test that JSON mode is used instead of TOOLS mode for OpenAI fallback.
|
||||
|
||||
TOOLS mode doesn't handle nested Dict[str, ...] structures properly,
|
||||
causing validation errors. JSON mode works correctly.
|
||||
"""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
with patch('praisonai.auto.LITELLM_AVAILABLE', False):
|
||||
with patch('praisonai.auto.instructor') as mock_instructor:
|
||||
with patch('praisonai.auto.OpenAI') as mock_openai:
|
||||
mock_instructor.patch.return_value = Mock()
|
||||
mock_instructor.Mode.JSON = 'JSON_MODE'
|
||||
mock_openai.return_value = Mock()
|
||||
|
||||
generator = AutoGenerator(
|
||||
topic="Test topic",
|
||||
framework="praisonai"
|
||||
)
|
||||
|
||||
# Access client to trigger creation
|
||||
_ = generator.client
|
||||
|
||||
# Verify JSON mode was used
|
||||
call_kwargs = mock_instructor.patch.call_args[1]
|
||||
assert call_kwargs['mode'] == 'JSON_MODE'
|
||||
|
||||
def test_workflow_generator_uses_json_mode(self):
|
||||
"""Test that WorkflowAutoGenerator also uses JSON mode."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
with patch('praisonai.auto.LITELLM_AVAILABLE', False):
|
||||
with patch('praisonai.auto.instructor') as mock_instructor:
|
||||
with patch('praisonai.auto.OpenAI') as mock_openai:
|
||||
mock_instructor.patch.return_value = Mock()
|
||||
mock_instructor.Mode.JSON = 'JSON_MODE'
|
||||
mock_openai.return_value = Mock()
|
||||
|
||||
generator = WorkflowAutoGenerator(
|
||||
topic="Test workflow"
|
||||
)
|
||||
|
||||
# Access client to trigger creation
|
||||
_ = generator.client
|
||||
|
||||
# Verify JSON mode was used
|
||||
call_kwargs = mock_instructor.patch.call_args[1]
|
||||
assert call_kwargs['mode'] == 'JSON_MODE'
|
||||
|
||||
|
||||
class TestAutoGeneratorImprovedPrompt:
|
||||
"""Test suite for improved task analysis prompt."""
|
||||
|
||||
def test_prompt_includes_complexity_analysis(self):
|
||||
"""Test that the prompt includes task complexity analysis."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = AutoGenerator(
|
||||
topic="Test topic",
|
||||
framework="praisonai"
|
||||
)
|
||||
prompt = generator.get_user_content()
|
||||
|
||||
# Check for complexity analysis steps
|
||||
assert "TASK COMPLEXITY ANALYSIS" in prompt
|
||||
assert "DETERMINE OPTIMAL TEAM SIZE" in prompt
|
||||
assert "Simple tasks: 1-2 agents" in prompt
|
||||
assert "Complex tasks: 3-4 agents" in prompt
|
||||
|
||||
def test_prompt_discourages_unnecessary_complexity(self):
|
||||
"""Test that the prompt discourages unnecessary complexity."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = AutoGenerator(
|
||||
topic="Test topic",
|
||||
framework="praisonai"
|
||||
)
|
||||
prompt = generator.get_user_content()
|
||||
|
||||
assert "meaningful specialization" in prompt
|
||||
assert "Avoid unnecessary complexity" in prompt
|
||||
|
||||
|
||||
class TestWorkflowPatterns:
|
||||
"""Test suite for workflow patterns including new orchestrator-workers and evaluator-optimizer."""
|
||||
|
||||
def test_get_prompt_sequential(self):
|
||||
"""Test that sequential pattern generates correct prompt."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(topic="Test topic")
|
||||
prompt = generator._get_prompt("sequential")
|
||||
assert "sequential" in prompt.lower()
|
||||
assert "Sequential Workflow" in prompt
|
||||
|
||||
def test_get_prompt_orchestrator_workers(self):
|
||||
"""Test that orchestrator-workers pattern generates correct prompt."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(topic="Test topic")
|
||||
prompt = generator._get_prompt("orchestrator-workers")
|
||||
assert "orchestrator" in prompt.lower()
|
||||
assert "workers" in prompt.lower()
|
||||
assert "synthesizer" in prompt.lower()
|
||||
|
||||
def test_get_prompt_evaluator_optimizer(self):
|
||||
"""Test that evaluator-optimizer pattern generates correct prompt."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(topic="Test topic")
|
||||
prompt = generator._get_prompt("evaluator-optimizer")
|
||||
assert "evaluator" in prompt.lower()
|
||||
assert "generator" in prompt.lower()
|
||||
assert "loop" in prompt.lower()
|
||||
assert "APPROVED" in prompt
|
||||
|
||||
|
||||
class TestPatternRecommendation:
|
||||
"""Test suite for pattern recommendation based on task type."""
|
||||
|
||||
def test_recommend_sequential_for_simple_task(self):
|
||||
"""Test that sequential is recommended for simple tasks."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(topic="Write a blog post")
|
||||
assert generator.recommend_pattern() == "sequential"
|
||||
|
||||
def test_recommend_parallel_for_concurrent_tasks(self):
|
||||
"""Test that parallel is recommended for concurrent tasks."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(topic="Research from multiple sources simultaneously")
|
||||
assert generator.recommend_pattern() == "parallel"
|
||||
|
||||
def test_recommend_orchestrator_for_complex_tasks(self):
|
||||
"""Test that orchestrator-workers is recommended for complex tasks."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(topic="Comprehensive analysis and break down the problem")
|
||||
assert generator.recommend_pattern() == "orchestrator-workers"
|
||||
|
||||
def test_recommend_evaluator_for_quality_tasks(self):
|
||||
"""Test that evaluator-optimizer is recommended for quality-focused tasks."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(topic="Refine and improve the content quality")
|
||||
assert generator.recommend_pattern() == "evaluator-optimizer"
|
||||
|
||||
def test_recommend_routing_for_classification_tasks(self):
|
||||
"""Test that routing is recommended for classification tasks."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(topic="Classify and route different types of requests")
|
||||
assert generator.recommend_pattern() == "routing"
|
||||
|
||||
|
||||
class TestAutoGeneratorConfiguration:
|
||||
"""Test suite for configuration options."""
|
||||
|
||||
def test_custom_config_list(self):
|
||||
"""Test that custom config_list is respected."""
|
||||
custom_config = [
|
||||
{
|
||||
'model': 'custom-model',
|
||||
'base_url': 'https://custom.api.com',
|
||||
'api_key': 'custom-key'
|
||||
}
|
||||
]
|
||||
|
||||
generator = AutoGenerator(
|
||||
topic="Test topic",
|
||||
framework="praisonai",
|
||||
config_list=custom_config
|
||||
)
|
||||
|
||||
assert generator.config_list == custom_config
|
||||
|
||||
def test_base_url_priority(self):
|
||||
"""Test base_url environment variable priority."""
|
||||
with patch.dict(os.environ, {
|
||||
'OPENAI_BASE_URL': 'https://priority.api.com',
|
||||
'OPENAI_API_BASE': 'https://fallback.api.com',
|
||||
'OPENAI_API_KEY': 'test-key'
|
||||
}):
|
||||
generator = AutoGenerator(
|
||||
topic="Test topic",
|
||||
framework="praisonai"
|
||||
)
|
||||
|
||||
assert generator.config_list[0]['base_url'] == 'https://priority.api.com'
|
||||
|
||||
|
||||
class TestBaseAutoGenerator:
|
||||
"""Test suite for BaseAutoGenerator shared functionality."""
|
||||
|
||||
def test_analyze_complexity_simple(self):
|
||||
"""Test that simple tasks are detected correctly."""
|
||||
from praisonai.auto import BaseAutoGenerator
|
||||
|
||||
simple_tasks = [
|
||||
"Write a haiku about spring",
|
||||
"Create a simple summary",
|
||||
"Write a brief poem",
|
||||
"Make a quick list"
|
||||
]
|
||||
|
||||
for task in simple_tasks:
|
||||
assert BaseAutoGenerator.analyze_complexity(task) == 'simple', f"Failed for: {task}"
|
||||
|
||||
def test_analyze_complexity_complex(self):
|
||||
"""Test that complex tasks are detected correctly."""
|
||||
from praisonai.auto import BaseAutoGenerator
|
||||
|
||||
complex_tasks = [
|
||||
"Comprehensive market analysis and report",
|
||||
"Research and write a detailed analysis",
|
||||
"Multi-step process to coordinate teams",
|
||||
"In-depth investigation of multiple sources"
|
||||
]
|
||||
|
||||
for task in complex_tasks:
|
||||
assert BaseAutoGenerator.analyze_complexity(task) == 'complex', f"Failed for: {task}"
|
||||
|
||||
def test_analyze_complexity_moderate(self):
|
||||
"""Test that moderate tasks are detected correctly."""
|
||||
from praisonai.auto import BaseAutoGenerator
|
||||
|
||||
moderate_tasks = [
|
||||
"Research AI trends",
|
||||
"Analyze customer feedback",
|
||||
"Design a workflow"
|
||||
]
|
||||
|
||||
for task in moderate_tasks:
|
||||
assert BaseAutoGenerator.analyze_complexity(task) == 'moderate', f"Failed for: {task}"
|
||||
|
||||
def test_get_available_tools(self):
|
||||
"""Test that available tools list is returned correctly."""
|
||||
from praisonai.auto import BaseAutoGenerator, AVAILABLE_TOOLS
|
||||
|
||||
tools = BaseAutoGenerator.get_available_tools()
|
||||
|
||||
# Should return a copy, not the original
|
||||
assert tools == AVAILABLE_TOOLS
|
||||
assert tools is not AVAILABLE_TOOLS
|
||||
|
||||
# Should contain expected tools
|
||||
assert "WebsiteSearchTool" in tools
|
||||
assert "PDFSearchTool" in tools
|
||||
assert "ScrapeWebsiteTool" in tools
|
||||
|
||||
|
||||
class TestWorkflowDynamicAgentCount:
|
||||
"""Test suite for dynamic agent count in WorkflowAutoGenerator."""
|
||||
|
||||
def test_prompt_includes_complexity_analysis(self):
|
||||
"""Test that workflow prompt includes complexity analysis."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(topic="Research AI trends")
|
||||
prompt = generator._get_prompt("sequential")
|
||||
|
||||
assert "STEP 1: ANALYZE TASK COMPLEXITY" in prompt
|
||||
assert "STEP 2: DESIGN WORKFLOW" in prompt
|
||||
assert "STEP 3: ASSIGN TOOLS" in prompt
|
||||
|
||||
def test_prompt_includes_tools_list(self):
|
||||
"""Test that workflow prompt includes available tools."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(topic="Research AI trends")
|
||||
prompt = generator._get_prompt("sequential")
|
||||
|
||||
assert "Available Tools:" in prompt
|
||||
assert "WebsiteSearchTool" in prompt
|
||||
assert "PDFSearchTool" in prompt
|
||||
|
||||
def test_simple_task_gets_fewer_agents_guidance(self):
|
||||
"""Test that simple tasks get 1-2 agents guidance."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(topic="Write a simple haiku")
|
||||
prompt = generator._get_prompt("sequential")
|
||||
|
||||
assert "1-2 agents" in prompt
|
||||
assert "simple task detected" in prompt
|
||||
|
||||
def test_complex_task_gets_more_agents_guidance(self):
|
||||
"""Test that complex tasks get 3-4 agents guidance."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(topic="Comprehensive market analysis and detailed report")
|
||||
prompt = generator._get_prompt("sequential")
|
||||
|
||||
assert "3-4 agents" in prompt
|
||||
assert "complex task detected" in prompt
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TODO 2: Single-Agent Option Tests
|
||||
# =============================================================================
|
||||
class TestSingleAgentOption:
|
||||
"""Test suite for single-agent generation option."""
|
||||
|
||||
def test_single_agent_structure_exists(self):
|
||||
"""Test that SingleAgentStructure Pydantic model exists."""
|
||||
from praisonai.auto import SingleAgentStructure
|
||||
assert SingleAgentStructure is not None
|
||||
|
||||
def test_auto_generator_has_single_agent_mode(self):
|
||||
"""Test that AutoGenerator supports single_agent parameter."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = AutoGenerator(
|
||||
topic="Write a haiku",
|
||||
framework="praisonai",
|
||||
single_agent=True
|
||||
)
|
||||
assert generator.single_agent == True
|
||||
|
||||
def test_workflow_generator_has_single_agent_mode(self):
|
||||
"""Test that WorkflowAutoGenerator supports single_agent parameter."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(
|
||||
topic="Write a haiku",
|
||||
single_agent=True
|
||||
)
|
||||
assert generator.single_agent == True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TODO 3: LLM-Based Pattern Recommendation Tests
|
||||
# =============================================================================
|
||||
class TestLLMPatternRecommendation:
|
||||
"""Test suite for LLM-based pattern recommendation."""
|
||||
|
||||
def test_recommend_pattern_llm_method_exists(self):
|
||||
"""Test that recommend_pattern_llm method exists."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(topic="Test task")
|
||||
assert hasattr(generator, 'recommend_pattern_llm')
|
||||
|
||||
def test_pattern_recommendation_pydantic_model_exists(self):
|
||||
"""Test that PatternRecommendation Pydantic model exists."""
|
||||
from praisonai.auto import PatternRecommendation
|
||||
assert PatternRecommendation is not None
|
||||
|
||||
def test_pattern_recommendation_has_required_fields(self):
|
||||
"""Test PatternRecommendation has pattern, reasoning, confidence."""
|
||||
from praisonai.auto import PatternRecommendation
|
||||
fields = PatternRecommendation.model_fields
|
||||
assert 'pattern' in fields
|
||||
assert 'reasoning' in fields
|
||||
assert 'confidence' in fields
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TODO 4: Validation Gates Tests
|
||||
# =============================================================================
|
||||
class TestValidationGates:
|
||||
"""Test suite for validation gates in workflows."""
|
||||
|
||||
def test_validation_gate_model_exists(self):
|
||||
"""Test that ValidationGate Pydantic model exists."""
|
||||
from praisonai.auto import ValidationGate
|
||||
assert ValidationGate is not None
|
||||
|
||||
def test_validation_gate_has_required_fields(self):
|
||||
"""Test ValidationGate has criteria, pass_action, fail_action."""
|
||||
from praisonai.auto import ValidationGate
|
||||
fields = ValidationGate.model_fields
|
||||
assert 'criteria' in fields
|
||||
assert 'pass_action' in fields
|
||||
assert 'fail_action' in fields
|
||||
|
||||
def test_workflow_structure_supports_gates(self):
|
||||
"""Test that WorkflowStructure has optional gates field."""
|
||||
from praisonai.auto import WorkflowStructure
|
||||
fields = WorkflowStructure.model_fields
|
||||
assert 'gates' in fields
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TODO 6: Pattern Support for AutoGenerator Tests
|
||||
# =============================================================================
|
||||
class TestAutoGeneratorPatternSupport:
|
||||
"""Test suite for pattern support in AutoGenerator."""
|
||||
|
||||
def test_auto_generator_accepts_pattern_parameter(self):
|
||||
"""Test that AutoGenerator accepts pattern parameter."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = AutoGenerator(
|
||||
topic="Research and write",
|
||||
framework="praisonai",
|
||||
pattern="parallel"
|
||||
)
|
||||
assert generator.pattern == "parallel"
|
||||
|
||||
def test_auto_generator_has_recommend_pattern(self):
|
||||
"""Test that AutoGenerator has recommend_pattern method."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = AutoGenerator(topic="Test", framework="praisonai")
|
||||
assert hasattr(generator, 'recommend_pattern')
|
||||
|
||||
def test_auto_generator_prompt_includes_pattern(self):
|
||||
"""Test that AutoGenerator prompt includes pattern guidance."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = AutoGenerator(
|
||||
topic="Research from multiple sources",
|
||||
framework="praisonai",
|
||||
pattern="parallel"
|
||||
)
|
||||
prompt = generator.get_user_content()
|
||||
assert "parallel" in prompt.lower()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TODO 7: Merge Support for WorkflowAutoGenerator Tests
|
||||
# =============================================================================
|
||||
class TestWorkflowMergeSupport:
|
||||
"""Test suite for merge support in WorkflowAutoGenerator."""
|
||||
|
||||
def test_workflow_generator_accepts_merge_parameter(self):
|
||||
"""Test that WorkflowAutoGenerator.generate accepts merge parameter."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(topic="Test task")
|
||||
# Check that generate method accepts merge parameter
|
||||
import inspect
|
||||
sig = inspect.signature(generator.generate)
|
||||
assert 'merge' in sig.parameters
|
||||
|
||||
def test_merge_with_existing_workflow_method_exists(self):
|
||||
"""Test that merge_with_existing_workflow method exists."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(topic="Test task")
|
||||
assert hasattr(generator, 'merge_with_existing_workflow')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TODO 10: Framework Support for WorkflowAutoGenerator Tests
|
||||
# =============================================================================
|
||||
class TestWorkflowFrameworkSupport:
|
||||
"""Test suite for framework support in WorkflowAutoGenerator."""
|
||||
|
||||
def test_workflow_generator_accepts_framework_parameter(self):
|
||||
"""Test that WorkflowAutoGenerator accepts framework parameter."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(
|
||||
topic="Test task",
|
||||
framework="crewai"
|
||||
)
|
||||
assert generator.framework == "crewai"
|
||||
|
||||
def test_workflow_generator_default_framework_is_praisonai(self):
|
||||
"""Test that default framework is praisonai."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(topic="Test task")
|
||||
assert generator.framework == "praisonai"
|
||||
|
||||
def test_save_workflow_respects_framework(self):
|
||||
"""Test that _save_workflow uses the specified framework."""
|
||||
with patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}):
|
||||
generator = WorkflowAutoGenerator(
|
||||
topic="Test task",
|
||||
framework="crewai"
|
||||
)
|
||||
# The framework should be stored and used in output
|
||||
assert generator.framework == "crewai"
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
@@ -0,0 +1,580 @@
|
||||
"""
|
||||
Unit tests for Model Routing features.
|
||||
|
||||
Tests cover:
|
||||
1. Model config parsing from agents.yaml/workflow.yaml
|
||||
2. Per-agent LLM field support
|
||||
3. Unified CLI Router with ModelRouter
|
||||
4. Custom model profiles
|
||||
|
||||
TDD approach - tests written before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Feature 1: Model Config in YAML
|
||||
# =============================================================================
|
||||
|
||||
class TestModelConfigParsing:
|
||||
"""Tests for parsing 'models' section from agents.yaml/workflow.yaml."""
|
||||
|
||||
def test_parse_models_section_basic(self):
|
||||
"""Test parsing basic models configuration from YAML."""
|
||||
yaml_content = """
|
||||
name: Test Workflow
|
||||
description: Test workflow with custom models
|
||||
|
||||
models:
|
||||
gpt-4o-mini:
|
||||
provider: openai
|
||||
complexity: [simple, moderate]
|
||||
cost_per_1k: 0.00075
|
||||
capabilities: [text, function-calling]
|
||||
|
||||
agents:
|
||||
researcher:
|
||||
name: Researcher
|
||||
role: Research Analyst
|
||||
goal: Research topics
|
||||
|
||||
steps:
|
||||
- agent: researcher
|
||||
action: "Research: {{input}}"
|
||||
"""
|
||||
config = yaml.safe_load(yaml_content)
|
||||
|
||||
# Verify models section exists
|
||||
assert 'models' in config
|
||||
assert 'gpt-4o-mini' in config['models']
|
||||
|
||||
model_config = config['models']['gpt-4o-mini']
|
||||
assert model_config['provider'] == 'openai'
|
||||
assert model_config['complexity'] == ['simple', 'moderate']
|
||||
assert model_config['cost_per_1k'] == 0.00075
|
||||
assert 'text' in model_config['capabilities']
|
||||
|
||||
def test_parse_multiple_models(self):
|
||||
"""Test parsing multiple model configurations."""
|
||||
yaml_content = """
|
||||
models:
|
||||
gpt-4o-mini:
|
||||
provider: openai
|
||||
complexity: [simple, moderate]
|
||||
cost_per_1k: 0.00075
|
||||
capabilities: [text, function-calling]
|
||||
|
||||
claude-3-5-sonnet:
|
||||
provider: anthropic
|
||||
complexity: [moderate, complex, very_complex]
|
||||
cost_per_1k: 0.009
|
||||
capabilities: [text, vision, function-calling]
|
||||
|
||||
custom-openrouter-model:
|
||||
provider: openrouter
|
||||
complexity: [moderate, complex]
|
||||
cost_per_1k: 0.001
|
||||
capabilities: [text]
|
||||
context_window: 32000
|
||||
|
||||
agents:
|
||||
researcher:
|
||||
name: Researcher
|
||||
role: Research Analyst
|
||||
goal: Research topics
|
||||
"""
|
||||
config = yaml.safe_load(yaml_content)
|
||||
|
||||
assert len(config['models']) == 3
|
||||
assert 'gpt-4o-mini' in config['models']
|
||||
assert 'claude-3-5-sonnet' in config['models']
|
||||
assert 'custom-openrouter-model' in config['models']
|
||||
|
||||
# Verify custom model has context_window
|
||||
custom_model = config['models']['custom-openrouter-model']
|
||||
assert custom_model.get('context_window') == 32000
|
||||
|
||||
def test_parse_model_with_all_fields(self):
|
||||
"""Test parsing model with all optional fields."""
|
||||
yaml_content = """
|
||||
models:
|
||||
full-model:
|
||||
provider: openai
|
||||
complexity: [simple, moderate, complex]
|
||||
cost_per_1k: 0.005
|
||||
capabilities: [text, vision, function-calling, streaming]
|
||||
context_window: 128000
|
||||
supports_tools: true
|
||||
supports_streaming: true
|
||||
strengths: [reasoning, code-generation, analysis]
|
||||
"""
|
||||
config = yaml.safe_load(yaml_content)
|
||||
|
||||
model = config['models']['full-model']
|
||||
assert model['provider'] == 'openai'
|
||||
assert model['context_window'] == 128000
|
||||
assert model['supports_tools'] is True
|
||||
assert model['supports_streaming'] is True
|
||||
assert 'reasoning' in model['strengths']
|
||||
|
||||
def test_models_section_optional(self):
|
||||
"""Test that models section is optional - backward compatibility."""
|
||||
yaml_content = """
|
||||
name: Simple Workflow
|
||||
|
||||
agents:
|
||||
researcher:
|
||||
name: Researcher
|
||||
role: Research Analyst
|
||||
goal: Research topics
|
||||
|
||||
steps:
|
||||
- agent: researcher
|
||||
action: "Research: {{input}}"
|
||||
"""
|
||||
config = yaml.safe_load(yaml_content)
|
||||
|
||||
# models section should not exist
|
||||
assert 'models' not in config or config.get('models') is None
|
||||
# But agents should still work
|
||||
assert 'agents' in config
|
||||
|
||||
|
||||
class TestModelConfigToModelProfile:
|
||||
"""Tests for converting YAML model config to ModelProfile objects."""
|
||||
|
||||
def test_convert_yaml_to_model_profile(self):
|
||||
"""Test converting YAML model config to ModelProfile."""
|
||||
from praisonai.cli.features.router import RouterHandler
|
||||
|
||||
yaml_model_config = {
|
||||
'provider': 'openai',
|
||||
'complexity': ['simple', 'moderate'],
|
||||
'cost_per_1k': 0.00075,
|
||||
'capabilities': ['text', 'function-calling'],
|
||||
'context_window': 128000,
|
||||
'supports_tools': True,
|
||||
'strengths': ['speed', 'cost-effective']
|
||||
}
|
||||
|
||||
handler = RouterHandler()
|
||||
profile = handler.yaml_to_model_profile('gpt-4o-mini', yaml_model_config)
|
||||
|
||||
assert profile is not None
|
||||
assert profile.name == 'gpt-4o-mini'
|
||||
assert profile.provider == 'openai'
|
||||
assert profile.cost_per_1k_tokens == 0.00075
|
||||
assert profile.context_window == 128000
|
||||
|
||||
def test_convert_complexity_strings_to_enum(self):
|
||||
"""Test converting complexity strings to TaskComplexity enum."""
|
||||
from praisonai.cli.features.router import RouterHandler
|
||||
|
||||
yaml_model_config = {
|
||||
'provider': 'anthropic',
|
||||
'complexity': ['moderate', 'complex', 'very_complex'],
|
||||
'cost_per_1k': 0.009,
|
||||
'capabilities': ['text']
|
||||
}
|
||||
|
||||
handler = RouterHandler()
|
||||
profile = handler.yaml_to_model_profile('claude-3-5-sonnet', yaml_model_config)
|
||||
|
||||
# Complexity range should be (MODERATE, VERY_COMPLEX)
|
||||
min_complexity, max_complexity = profile.complexity_range
|
||||
assert min_complexity.value == 2 # MODERATE
|
||||
assert max_complexity.value == 4 # VERY_COMPLEX
|
||||
|
||||
|
||||
class TestRouterWithCustomModels:
|
||||
"""Tests for RouterHandler using custom models from YAML."""
|
||||
|
||||
def test_router_loads_custom_models(self):
|
||||
"""Test that router loads custom models from YAML config."""
|
||||
from praisonai.cli.features.router import RouterHandler
|
||||
|
||||
models_config = {
|
||||
'my-custom-model': {
|
||||
'provider': 'openrouter',
|
||||
'complexity': ['simple', 'moderate'],
|
||||
'cost_per_1k': 0.001,
|
||||
'capabilities': ['text', 'function-calling']
|
||||
}
|
||||
}
|
||||
|
||||
handler = RouterHandler()
|
||||
handler.load_models_from_config(models_config)
|
||||
|
||||
# Custom model should be available
|
||||
assert 'my-custom-model' in handler.get_available_models()
|
||||
|
||||
def test_router_selects_custom_model(self):
|
||||
"""Test that router can select custom model based on complexity."""
|
||||
from praisonai.cli.features.router import RouterHandler
|
||||
|
||||
models_config = {
|
||||
'cheap-model': {
|
||||
'provider': 'custom',
|
||||
'complexity': ['simple'],
|
||||
'cost_per_1k': 0.0001,
|
||||
'capabilities': ['text']
|
||||
},
|
||||
'expensive-model': {
|
||||
'provider': 'custom',
|
||||
'complexity': ['complex', 'very_complex'],
|
||||
'cost_per_1k': 0.05,
|
||||
'capabilities': ['text', 'function-calling']
|
||||
}
|
||||
}
|
||||
|
||||
handler = RouterHandler()
|
||||
handler.load_models_from_config(models_config)
|
||||
|
||||
# Simple task should select cheap model
|
||||
simple_model = handler.select_model("What is 2+2?")
|
||||
assert simple_model == 'cheap-model'
|
||||
|
||||
# Complex task should select expensive model
|
||||
complex_model = handler.select_model(
|
||||
"Analyze the comprehensive impact of quantum computing on cybersecurity"
|
||||
)
|
||||
assert complex_model == 'expensive-model'
|
||||
|
||||
def test_router_merges_with_default_models(self):
|
||||
"""Test that custom models merge with default models."""
|
||||
from praisonai.cli.features.router import RouterHandler
|
||||
|
||||
models_config = {
|
||||
'my-custom-model': {
|
||||
'provider': 'openrouter',
|
||||
'complexity': ['moderate'],
|
||||
'cost_per_1k': 0.002,
|
||||
'capabilities': ['text']
|
||||
}
|
||||
}
|
||||
|
||||
handler = RouterHandler()
|
||||
handler.load_models_from_config(models_config, merge_with_defaults=True)
|
||||
|
||||
available = handler.get_available_models()
|
||||
|
||||
# Should have both custom and default models
|
||||
assert 'my-custom-model' in available
|
||||
assert 'gpt-4o-mini' in available # Default model
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Feature 2: Per-Agent LLM Field
|
||||
# =============================================================================
|
||||
|
||||
class TestPerAgentLLM:
|
||||
"""Tests for per-agent LLM configuration in YAML."""
|
||||
|
||||
def test_parse_agent_with_llm_field(self):
|
||||
"""Test parsing agent with explicit llm field."""
|
||||
yaml_content = """
|
||||
agents:
|
||||
researcher:
|
||||
name: Researcher
|
||||
role: Research Analyst
|
||||
goal: Research topics
|
||||
llm: gpt-4o
|
||||
|
||||
writer:
|
||||
name: Writer
|
||||
role: Content Writer
|
||||
goal: Write content
|
||||
llm: claude-3-5-sonnet-20241022
|
||||
"""
|
||||
config = yaml.safe_load(yaml_content)
|
||||
|
||||
assert config['agents']['researcher']['llm'] == 'gpt-4o'
|
||||
assert config['agents']['writer']['llm'] == 'claude-3-5-sonnet-20241022'
|
||||
|
||||
def test_parse_agent_with_llm_routing(self):
|
||||
"""Test parsing agent with llm_routing strategy."""
|
||||
yaml_content = """
|
||||
agents:
|
||||
smart_agent:
|
||||
name: Smart Agent
|
||||
role: Adaptive Assistant
|
||||
goal: Handle various tasks
|
||||
llm_routing: auto
|
||||
llm_models:
|
||||
- gpt-4o-mini
|
||||
- gpt-4o
|
||||
- claude-3-5-sonnet-20241022
|
||||
"""
|
||||
config = yaml.safe_load(yaml_content)
|
||||
|
||||
agent = config['agents']['smart_agent']
|
||||
assert agent['llm_routing'] == 'auto'
|
||||
assert len(agent['llm_models']) == 3
|
||||
|
||||
def test_agent_llm_defaults_when_not_specified(self):
|
||||
"""Test that agent uses default LLM when not specified."""
|
||||
yaml_content = """
|
||||
agents:
|
||||
researcher:
|
||||
name: Researcher
|
||||
role: Research Analyst
|
||||
goal: Research topics
|
||||
# No llm field - should use default
|
||||
"""
|
||||
config = yaml.safe_load(yaml_content)
|
||||
|
||||
# llm field should not exist
|
||||
assert 'llm' not in config['agents']['researcher']
|
||||
|
||||
def test_agent_llm_with_provider_prefix(self):
|
||||
"""Test parsing agent LLM with provider/model format."""
|
||||
yaml_content = """
|
||||
agents:
|
||||
researcher:
|
||||
name: Researcher
|
||||
role: Research Analyst
|
||||
goal: Research topics
|
||||
llm: openrouter/anthropic/claude-3-5-sonnet
|
||||
"""
|
||||
config = yaml.safe_load(yaml_content)
|
||||
|
||||
assert config['agents']['researcher']['llm'] == 'openrouter/anthropic/claude-3-5-sonnet'
|
||||
|
||||
|
||||
class TestAgentCreationWithLLM:
|
||||
"""Tests for creating Agent objects with LLM from YAML config."""
|
||||
|
||||
def test_create_agent_with_custom_llm(self):
|
||||
"""Test that Agent config with custom LLM is properly parsed."""
|
||||
agent_config = {
|
||||
'name': 'Researcher',
|
||||
'role': 'Research Analyst',
|
||||
'goal': 'Research topics',
|
||||
'llm': 'gpt-4o'
|
||||
}
|
||||
|
||||
# This tests the parsing logic, not actual agent creation
|
||||
llm_value = agent_config.get('llm', 'gpt-4o-mini')
|
||||
assert llm_value == 'gpt-4o'
|
||||
|
||||
def test_agent_config_with_routing_strategy(self):
|
||||
"""Test agent config with routing strategy is properly structured."""
|
||||
agent_config = {
|
||||
'name': 'Smart Agent',
|
||||
'role': 'Adaptive Assistant',
|
||||
'goal': 'Handle various tasks',
|
||||
'llm_routing': 'cost-optimized',
|
||||
'llm_models': ['gpt-4o-mini', 'gpt-4o', 'claude-3-5-sonnet-20241022']
|
||||
}
|
||||
|
||||
# Verify structure
|
||||
assert agent_config['llm_routing'] == 'cost-optimized'
|
||||
assert isinstance(agent_config['llm_models'], list)
|
||||
assert len(agent_config['llm_models']) == 3
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Feature 3: Unified CLI Router with ModelRouter
|
||||
# =============================================================================
|
||||
|
||||
class TestUnifiedRouter:
|
||||
"""Tests for unified RouterHandler that uses ModelRouter internally."""
|
||||
|
||||
def test_router_handler_uses_model_router(self):
|
||||
"""Test that RouterHandler uses ModelRouter for selection."""
|
||||
from praisonai.cli.features.router import RouterHandler
|
||||
|
||||
handler = RouterHandler()
|
||||
|
||||
# Should have internal model_router
|
||||
assert hasattr(handler, 'model_router') or hasattr(handler, '_model_router')
|
||||
|
||||
def test_router_complexity_analysis_matches(self):
|
||||
"""Test that complexity analysis is consistent."""
|
||||
from praisonai.cli.features.router import RouterHandler
|
||||
|
||||
handler = RouterHandler()
|
||||
|
||||
# Simple prompts
|
||||
assert handler.analyze_complexity("What is 2+2?") == 'simple'
|
||||
assert handler.analyze_complexity("Define photosynthesis") == 'simple'
|
||||
|
||||
# Complex prompts
|
||||
assert handler.analyze_complexity(
|
||||
"Analyze and research the comprehensive impact of AI on healthcare"
|
||||
) == 'complex'
|
||||
|
||||
def test_router_cost_threshold(self):
|
||||
"""Test router respects cost threshold."""
|
||||
from praisonai.cli.features.router import RouterHandler
|
||||
|
||||
handler = RouterHandler()
|
||||
handler.set_cost_threshold(0.001) # Max $0.001 per 1k tokens
|
||||
|
||||
# Should only select cheap models
|
||||
model = handler.select_model("Complex analysis task")
|
||||
|
||||
# Model should be within cost threshold
|
||||
model_cost = handler.get_model_cost(model)
|
||||
assert model_cost <= 0.001
|
||||
|
||||
def test_router_provider_preference(self):
|
||||
"""Test router respects provider preference."""
|
||||
from praisonai.cli.features.router import RouterHandler
|
||||
|
||||
handler = RouterHandler()
|
||||
|
||||
# Prefer Anthropic
|
||||
model = handler.select_model(
|
||||
"Write a detailed analysis",
|
||||
preferred_provider='anthropic'
|
||||
)
|
||||
|
||||
assert 'claude' in model.lower()
|
||||
|
||||
|
||||
class TestRouterIntegrationWithWorkflow:
|
||||
"""Tests for router integration with workflow execution."""
|
||||
|
||||
def test_workflow_uses_models_config(self):
|
||||
"""Test that workflow uses models config for routing."""
|
||||
yaml_content = """
|
||||
name: Routing Workflow
|
||||
|
||||
models:
|
||||
fast-model:
|
||||
provider: openai
|
||||
complexity: [simple]
|
||||
cost_per_1k: 0.0001
|
||||
capabilities: [text]
|
||||
|
||||
smart-model:
|
||||
provider: anthropic
|
||||
complexity: [complex, very_complex]
|
||||
cost_per_1k: 0.01
|
||||
capabilities: [text, function-calling]
|
||||
|
||||
workflow:
|
||||
verbose: true
|
||||
router: true # Enable model routing
|
||||
|
||||
agents:
|
||||
researcher:
|
||||
name: Researcher
|
||||
role: Research Analyst
|
||||
goal: Research topics
|
||||
llm_routing: auto # Use auto-routing with models config
|
||||
"""
|
||||
config = yaml.safe_load(yaml_content)
|
||||
|
||||
# Verify workflow has router enabled
|
||||
assert config['workflow'].get('router') is True
|
||||
|
||||
# Verify agent has routing enabled
|
||||
assert config['agents']['researcher']['llm_routing'] == 'auto'
|
||||
|
||||
# Verify models are defined
|
||||
assert 'fast-model' in config['models']
|
||||
assert 'smart-model' in config['models']
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Integration Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestEndToEndModelRouting:
|
||||
"""End-to-end tests for model routing feature."""
|
||||
|
||||
def test_full_yaml_with_model_routing(self):
|
||||
"""Test complete YAML with all model routing features."""
|
||||
yaml_content = """
|
||||
name: Full Model Routing Workflow
|
||||
description: Demonstrates all model routing features
|
||||
|
||||
# Custom models configuration
|
||||
models:
|
||||
cheap-fast:
|
||||
provider: openai
|
||||
complexity: [simple]
|
||||
cost_per_1k: 0.0001
|
||||
capabilities: [text]
|
||||
context_window: 16000
|
||||
|
||||
balanced:
|
||||
provider: openai
|
||||
complexity: [moderate]
|
||||
cost_per_1k: 0.001
|
||||
capabilities: [text, function-calling]
|
||||
context_window: 128000
|
||||
|
||||
premium:
|
||||
provider: anthropic
|
||||
complexity: [complex, very_complex]
|
||||
cost_per_1k: 0.015
|
||||
capabilities: [text, vision, function-calling]
|
||||
context_window: 200000
|
||||
strengths: [reasoning, analysis, code-generation]
|
||||
|
||||
workflow:
|
||||
verbose: true
|
||||
router: true
|
||||
routing_strategy: cost-optimized
|
||||
|
||||
agents:
|
||||
classifier:
|
||||
name: Classifier
|
||||
role: Request Classifier
|
||||
goal: Classify incoming requests
|
||||
llm: cheap-fast # Always use cheap model for classification
|
||||
|
||||
researcher:
|
||||
name: Researcher
|
||||
role: Research Analyst
|
||||
goal: Research topics thoroughly
|
||||
llm_routing: auto # Auto-select based on task
|
||||
llm_models: [balanced, premium]
|
||||
|
||||
writer:
|
||||
name: Writer
|
||||
role: Content Writer
|
||||
goal: Write high-quality content
|
||||
llm: premium # Always use premium for writing
|
||||
|
||||
steps:
|
||||
- agent: classifier
|
||||
action: "Classify: {{input}}"
|
||||
|
||||
- name: routing
|
||||
route:
|
||||
simple: [researcher]
|
||||
complex: [researcher, writer]
|
||||
default: [researcher]
|
||||
"""
|
||||
config = yaml.safe_load(yaml_content)
|
||||
|
||||
# Verify complete structure
|
||||
assert 'models' in config
|
||||
assert len(config['models']) == 3
|
||||
|
||||
assert 'workflow' in config
|
||||
assert config['workflow']['router'] is True
|
||||
assert config['workflow']['routing_strategy'] == 'cost-optimized'
|
||||
|
||||
assert 'agents' in config
|
||||
assert config['agents']['classifier']['llm'] == 'cheap-fast'
|
||||
assert config['agents']['researcher']['llm_routing'] == 'auto'
|
||||
assert config['agents']['writer']['llm'] == 'premium'
|
||||
|
||||
assert 'steps' in config
|
||||
assert len(config['steps']) == 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
Reference in new issue
Block a user