mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 17:31:00 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ffb9d6e3f | ||
|
|
b686e5b00f | ||
|
|
d8d84ee3e3 | ||
|
|
9f2c4d10b5 | ||
|
|
42a86c831f | ||
|
|
48443b11d6 | ||
|
|
71745aa14a | ||
|
|
4f309ec16d | ||
|
|
e48afc8993 |
No files matched your search
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Auto-generated API server for PraisonAI agents.
|
||||
"""
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
from praisonai import PraisonAI
|
||||
import os
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# CORS configuration
|
||||
CORS(app)
|
||||
|
||||
# Authentication
|
||||
AUTH_ENABLED = False
|
||||
AUTH_TOKEN = None
|
||||
|
||||
def check_auth():
|
||||
"""Check authentication if enabled."""
|
||||
if not AUTH_ENABLED:
|
||||
return True
|
||||
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
return token == AUTH_TOKEN
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health():
|
||||
"""Health check endpoint."""
|
||||
return jsonify({"status": "ok", "service": "praisonai-api"})
|
||||
|
||||
@app.route('/chat', methods=['POST'])
|
||||
def chat():
|
||||
"""Chat endpoint for agent interaction."""
|
||||
if not check_auth():
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
data = request.get_json()
|
||||
if not data or 'message' not in data:
|
||||
return jsonify({"error": "Message required"}), 400
|
||||
|
||||
try:
|
||||
praisonai = PraisonAI(agent_file="agents.yaml")
|
||||
result = praisonai.run()
|
||||
|
||||
return jsonify({
|
||||
"response": result,
|
||||
"status": "success"
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"error": str(e),
|
||||
"status": "error"
|
||||
}), 500
|
||||
|
||||
@app.route('/agents', methods=['GET'])
|
||||
def list_agents():
|
||||
"""List available agents."""
|
||||
if not check_auth():
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
return jsonify({
|
||||
"agents": ["default"],
|
||||
"agent_file": "agents.yaml"
|
||||
})
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(
|
||||
host='0.0.0.0',
|
||||
port=8080,
|
||||
debug=False
|
||||
)
|
||||
@@ -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.8.3" \
|
||||
"praisonai>=2.9.2" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.8.3" \
|
||||
"praisonai>=2.9.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.8.3" \
|
||||
"praisonai>=2.9.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# ACP (Agent Client Protocol) Examples
|
||||
|
||||
This directory contains examples for using PraisonAI with the Agent Client Protocol (ACP).
|
||||
|
||||
## What is ACP?
|
||||
|
||||
The Agent Client Protocol (ACP) is a standardized JSON-RPC 2.0 protocol that allows code editors and IDEs to communicate with AI coding agents. It enables seamless integration between PraisonAI and editors like:
|
||||
|
||||
- **Zed**
|
||||
- **JetBrains IDEs** (IntelliJ, PyCharm, WebStorm, etc.)
|
||||
- **VSCode** (via extensions)
|
||||
- **Toad**
|
||||
|
||||
## Quick Start
|
||||
|
||||
### CLI Usage
|
||||
|
||||
```bash
|
||||
# Start ACP server with defaults
|
||||
praisonai acp
|
||||
|
||||
# Enable debug logging
|
||||
praisonai acp --debug
|
||||
|
||||
# Allow file writes
|
||||
praisonai acp --allow-write
|
||||
|
||||
# Use a specific model
|
||||
praisonai acp -m gpt-4o
|
||||
|
||||
# Resume last session
|
||||
praisonai acp --resume --last
|
||||
```
|
||||
|
||||
### Python API
|
||||
|
||||
```python
|
||||
from praisonai.acp import serve
|
||||
|
||||
serve(
|
||||
workspace=".",
|
||||
debug=True,
|
||||
allow_write=True,
|
||||
)
|
||||
```
|
||||
|
||||
## Editor Configuration
|
||||
|
||||
### Zed
|
||||
|
||||
Add to `~/.config/zed/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"PraisonAI": {
|
||||
"command": "praisonai",
|
||||
"args": ["acp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### JetBrains
|
||||
|
||||
Add to `~/.jetbrains/acp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"PraisonAI": {
|
||||
"command": "praisonai",
|
||||
"args": ["acp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Toad
|
||||
|
||||
```bash
|
||||
toad acp "praisonai acp"
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
- `basic_acp_server.py` - Simple ACP server with default settings
|
||||
- `custom_agent_acp.py` - ACP server with a custom agent
|
||||
|
||||
## CLI Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-w, --workspace` | Workspace root directory |
|
||||
| `-a, --agent` | Agent name or config file |
|
||||
| `-m, --model` | LLM model to use |
|
||||
| `-r, --resume` | Resume session by ID |
|
||||
| `--last` | Resume the last session |
|
||||
| `--approve` | Approval mode: manual, auto, scoped |
|
||||
| `--read-only` | Read-only mode (default) |
|
||||
| `--allow-write` | Allow file writes |
|
||||
| `--allow-shell` | Allow shell commands |
|
||||
| `--debug` | Enable debug logging |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install praisonai[acp]
|
||||
```
|
||||
|
||||
## Protocol Details
|
||||
|
||||
ACP uses JSON-RPC 2.0 over stdio:
|
||||
- **stdin**: Receives JSON-RPC requests from the client
|
||||
- **stdout**: Sends JSON-RPC responses to the client
|
||||
- **stderr**: Debug/log output (never pollutes stdout)
|
||||
|
||||
### Supported Methods
|
||||
|
||||
**Agent Methods (client → agent):**
|
||||
- `initialize` - Negotiate protocol version and capabilities
|
||||
- `authenticate` - Optional authentication
|
||||
- `session/new` - Create a new session
|
||||
- `session/load` - Load an existing session
|
||||
- `session/prompt` - Send user message
|
||||
- `session/cancel` - Cancel ongoing operations
|
||||
- `session/set_mode` - Change operating mode
|
||||
|
||||
**Client Methods (agent → client):**
|
||||
- `session/update` - Send progress updates
|
||||
- `session/request_permission` - Request user approval
|
||||
- `fs/read_text_file` - Read file contents
|
||||
- `fs/write_text_file` - Write file contents
|
||||
- `terminal/create` - Create terminal
|
||||
- `terminal/output` - Get terminal output
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic ACP Server Example
|
||||
|
||||
This example demonstrates how to run PraisonAI as an ACP server
|
||||
that can be connected to by IDEs like Zed, JetBrains, VSCode, or Toad.
|
||||
|
||||
Usage:
|
||||
# Run from command line
|
||||
praisonai acp
|
||||
|
||||
# Or run this script directly
|
||||
python basic_acp_server.py
|
||||
"""
|
||||
|
||||
from praisonai.acp import serve
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Start ACP server with default settings
|
||||
# This will listen on stdin/stdout for JSON-RPC messages
|
||||
serve(
|
||||
workspace=".", # Current directory as workspace
|
||||
agent="default", # Use default agent
|
||||
debug=False, # Set to True for debug logging to stderr
|
||||
read_only=True, # Safe by default
|
||||
approval_mode="manual", # Require approval for actions
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Custom Agent ACP Server Example
|
||||
|
||||
This example shows how to run a custom PraisonAI agent as an ACP server.
|
||||
|
||||
Usage:
|
||||
python custom_agent_acp.py
|
||||
"""
|
||||
|
||||
from praisonai.acp import serve, ACPServer, ACPConfig
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Create a custom agent
|
||||
agent = Agent(
|
||||
name="CodeAssistant",
|
||||
instructions="""You are an expert coding assistant. You help users:
|
||||
- Write clean, efficient code
|
||||
- Debug issues
|
||||
- Explain complex concepts
|
||||
- Review code for best practices
|
||||
|
||||
Always provide clear explanations with your code suggestions.""",
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Create config
|
||||
config = ACPConfig(
|
||||
workspace=".",
|
||||
debug=True,
|
||||
allow_write=True, # Allow file modifications
|
||||
approval_mode="manual",
|
||||
)
|
||||
|
||||
# Create server with custom agent
|
||||
server = ACPServer(config=config, agent=agent)
|
||||
|
||||
# Run the server
|
||||
serve(
|
||||
workspace=".",
|
||||
debug=True,
|
||||
allow_write=True,
|
||||
approval_mode="manual",
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Agent-Centric Tools Example
|
||||
|
||||
This example demonstrates how to use LSP/ACP-powered tools that make
|
||||
the Agent the central orchestrator for file operations.
|
||||
|
||||
How to run:
|
||||
export OPENAI_API_KEY=your_key
|
||||
python examples/agent_tools/agent_centric_example.py
|
||||
|
||||
Prerequisites:
|
||||
pip install praisonai praisonaiagents
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
# Add paths for development
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/praisonai'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/praisonai-agents'))
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 60)
|
||||
print("AGENT-CENTRIC TOOLS EXAMPLE")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Create temporary workspace
|
||||
workspace = tempfile.mkdtemp(prefix="praisonai_example_")
|
||||
print(f"Workspace: {workspace}")
|
||||
print()
|
||||
|
||||
try:
|
||||
# Import after path setup
|
||||
from praisonai.cli.features import (
|
||||
create_agent_centric_tools,
|
||||
InteractiveRuntime,
|
||||
RuntimeConfig
|
||||
)
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# 1. Create runtime with ACP enabled
|
||||
print("1. Creating InteractiveRuntime with ACP...")
|
||||
config = RuntimeConfig(
|
||||
workspace=workspace,
|
||||
lsp_enabled=False, # Skip LSP for this example
|
||||
acp_enabled=True,
|
||||
approval_mode="auto" # Auto-approve for demo
|
||||
)
|
||||
runtime = InteractiveRuntime(config)
|
||||
status = await runtime.start()
|
||||
|
||||
print(f" ACP ready: {runtime.acp_ready}")
|
||||
print(f" Read-only: {runtime.read_only}")
|
||||
print()
|
||||
|
||||
# 2. Create agent-centric tools
|
||||
print("2. Creating agent-centric tools...")
|
||||
tools = create_agent_centric_tools(runtime)
|
||||
print(f" Tools created: {len(tools)}")
|
||||
for tool in tools:
|
||||
print(f" - {tool.__name__}")
|
||||
print()
|
||||
|
||||
# 3. Create Agent with ACP-powered tools
|
||||
print("3. Creating Agent with ACP-powered tools...")
|
||||
agent = Agent(
|
||||
name="FileAgent",
|
||||
instructions="""You help create and manage files.
|
||||
Use acp_create_file to create files.
|
||||
Use read_file to read files.
|
||||
Use list_files to list directory contents.""",
|
||||
tools=tools,
|
||||
verbose=True
|
||||
)
|
||||
print()
|
||||
|
||||
# 4. Ask agent to create a file
|
||||
print("4. Asking agent to create a Python file...")
|
||||
print("-" * 40)
|
||||
result = agent.start(
|
||||
"Create a Python file called calculator.py with add and subtract functions"
|
||||
)
|
||||
print("-" * 40)
|
||||
print(f"Agent response: {result[:200]}..." if len(str(result)) > 200 else f"Agent response: {result}")
|
||||
print()
|
||||
|
||||
# 5. Verify file was created
|
||||
print("5. Verifying file creation...")
|
||||
calc_path = os.path.join(workspace, "calculator.py")
|
||||
if os.path.exists(calc_path):
|
||||
print(f" ✓ File created at {calc_path}")
|
||||
with open(calc_path) as f:
|
||||
content = f.read()
|
||||
print(f" Content preview:")
|
||||
for line in content.split('\n')[:10]:
|
||||
print(f" {line}")
|
||||
else:
|
||||
# Check what files exist
|
||||
files = os.listdir(workspace)
|
||||
print(f" Files in workspace: {files}")
|
||||
for f in files:
|
||||
fp = os.path.join(workspace, f)
|
||||
if os.path.isfile(fp):
|
||||
print(f" ✓ Found: {f}")
|
||||
print()
|
||||
|
||||
# 6. Ask agent to read the file
|
||||
print("6. Asking agent to read the file...")
|
||||
print("-" * 40)
|
||||
result2 = agent.start("Read the calculator.py file and tell me what functions it has")
|
||||
print("-" * 40)
|
||||
print(f"Agent response: {result2[:300]}..." if len(str(result2)) > 300 else f"Agent response: {result2}")
|
||||
print()
|
||||
|
||||
# Cleanup
|
||||
await runtime.stop()
|
||||
|
||||
finally:
|
||||
# Clean up workspace
|
||||
shutil.rmtree(workspace, ignore_errors=True)
|
||||
print(f"Cleaned up workspace: {workspace}")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("EXAMPLE COMPLETE")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Debug Interactive Mode Example
|
||||
|
||||
This example demonstrates how to use the debug CLI commands to test
|
||||
the Interactive Coding Assistant without entering TUI mode.
|
||||
|
||||
Features demonstrated:
|
||||
- LSP symbol listing
|
||||
- LSP definition lookup
|
||||
- ACP action planning
|
||||
- ACP action execution
|
||||
- Trace capture and JSON output
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the src directory to path for development
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src" / "praisonai"))
|
||||
|
||||
from praisonai.cli.features.interactive_runtime import create_runtime
|
||||
from praisonai.cli.features.code_intelligence import CodeIntelligenceRouter
|
||||
from praisonai.cli.features.action_orchestrator import ActionOrchestrator
|
||||
|
||||
|
||||
async def demo_lsp_queries(workspace: str):
|
||||
"""Demonstrate LSP-based code queries."""
|
||||
print("\n" + "=" * 60)
|
||||
print("LSP CODE QUERIES DEMO")
|
||||
print("=" * 60)
|
||||
|
||||
runtime = create_runtime(
|
||||
workspace=workspace,
|
||||
lsp=True,
|
||||
acp=False,
|
||||
trace=True
|
||||
)
|
||||
|
||||
try:
|
||||
await runtime.start()
|
||||
router = CodeIntelligenceRouter(runtime)
|
||||
|
||||
# Query 1: List symbols
|
||||
print("\n[Query 1] List all symbols in app.py")
|
||||
result = await router.handle_query("list all functions in app.py", "app.py")
|
||||
print(f" Success: {result.success}")
|
||||
print(f" LSP Used: {result.lsp_used}")
|
||||
print(f" Fallback Used: {result.fallback_used}")
|
||||
if result.data:
|
||||
print(f" Symbols found: {len(result.data)}")
|
||||
for symbol in result.data[:5]:
|
||||
print(f" - {symbol['name']} ({symbol['kind']}) at line {symbol['line']}")
|
||||
|
||||
# Query 2: Find definition
|
||||
print("\n[Query 2] Find definition of Calculator")
|
||||
result = await router.handle_query("where is Calculator defined", "app.py")
|
||||
print(f" Success: {result.success}")
|
||||
if result.citations:
|
||||
print(f" Found at: {result.citations[0]}")
|
||||
|
||||
# Get trace
|
||||
trace = runtime.get_trace()
|
||||
print(f"\n[Trace] {len(trace.entries)} entries captured")
|
||||
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
|
||||
async def demo_acp_actions(workspace: str):
|
||||
"""Demonstrate ACP-based code modifications."""
|
||||
print("\n" + "=" * 60)
|
||||
print("ACP CODE MODIFICATIONS DEMO")
|
||||
print("=" * 60)
|
||||
|
||||
runtime = create_runtime(
|
||||
workspace=workspace,
|
||||
lsp=False,
|
||||
acp=True,
|
||||
approval="auto",
|
||||
trace=True
|
||||
)
|
||||
|
||||
try:
|
||||
await runtime.start()
|
||||
print(f"\n ACP Ready: {runtime.acp_ready}")
|
||||
print(f" Read Only: {runtime.read_only}")
|
||||
|
||||
orchestrator = ActionOrchestrator(runtime)
|
||||
|
||||
# Action 1: Create a plan
|
||||
print("\n[Action 1] Create plan for new file")
|
||||
result = await orchestrator.create_plan("create a new file called demo_output.py")
|
||||
print(f" Success: {result.success}")
|
||||
if result.plan:
|
||||
print(f" Plan ID: {result.plan.id}")
|
||||
print(f" Steps: {len(result.plan.steps)}")
|
||||
for step in result.plan.steps:
|
||||
print(f" - {step.action_type.value}: {step.target}")
|
||||
|
||||
# Action 2: Execute with auto-approve
|
||||
print("\n[Action 2] Execute file creation")
|
||||
exec_result = await orchestrator.execute(
|
||||
"create a new file called demo_output.py with a hello function",
|
||||
auto_approve=True
|
||||
)
|
||||
print(f" Success: {exec_result.success}")
|
||||
print(f" Applied Steps: {exec_result.applied_steps}")
|
||||
|
||||
# Verify file was created
|
||||
output_file = Path(workspace) / "demo_output.py"
|
||||
if output_file.exists():
|
||||
print(f" File created: {output_file}")
|
||||
print(f" Content preview: {output_file.read_text()[:100]}...")
|
||||
|
||||
# Get trace
|
||||
trace = runtime.get_trace()
|
||||
print(f"\n[Trace] {len(trace.entries)} entries captured")
|
||||
for entry in trace.entries:
|
||||
print(f" [{entry.category}] {entry.action}")
|
||||
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
|
||||
async def demo_full_interactive_turn(workspace: str):
|
||||
"""Demonstrate full interactive turn simulation."""
|
||||
print("\n" + "=" * 60)
|
||||
print("FULL INTERACTIVE TURN SIMULATION")
|
||||
print("=" * 60)
|
||||
|
||||
runtime = create_runtime(
|
||||
workspace=workspace,
|
||||
lsp=True,
|
||||
acp=True,
|
||||
approval="auto",
|
||||
trace=True
|
||||
)
|
||||
|
||||
try:
|
||||
status = await runtime.start()
|
||||
print("\n Runtime Status:")
|
||||
print(f" LSP: {'ready' if status['lsp']['ready'] else 'fallback'}")
|
||||
print(f" ACP: {'ready' if status['acp']['ready'] else 'unavailable'}")
|
||||
|
||||
router = CodeIntelligenceRouter(runtime)
|
||||
orchestrator = ActionOrchestrator(runtime)
|
||||
|
||||
# Simulate user prompt: code query
|
||||
prompt1 = "What functions are in app.py?"
|
||||
print(f"\n[Turn 1] User: {prompt1}")
|
||||
|
||||
# Classify and route
|
||||
result = await router.handle_query(prompt1, "app.py")
|
||||
print(f" Intent: {result.intent.value}")
|
||||
print(f" Response: Found {len(result.data) if result.data else 0} symbols")
|
||||
|
||||
# Simulate user prompt: code modification
|
||||
prompt2 = "Create a new test file"
|
||||
print(f"\n[Turn 2] User: {prompt2}")
|
||||
|
||||
exec_result = await orchestrator.execute(
|
||||
"create a new file called test_demo.py",
|
||||
auto_approve=True
|
||||
)
|
||||
print(" Action: file_create")
|
||||
print(f" Result: {'success' if exec_result.success else 'failed'}")
|
||||
|
||||
# Export trace as JSON
|
||||
trace = runtime.get_trace()
|
||||
trace_json = trace.to_dict()
|
||||
print("[Trace Export]")
|
||||
print(json.dumps(trace_json, indent=2, default=str)[:500] + "...")
|
||||
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the debug demo."""
|
||||
# Create a temporary workspace
|
||||
workspace = Path(__file__).parent / "_demo_workspace"
|
||||
workspace.mkdir(exist_ok=True)
|
||||
|
||||
# Create a sample app.py
|
||||
app_file = workspace / "app.py"
|
||||
app_file.write_text('''"""Sample application."""
|
||||
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
class Calculator:
|
||||
"""Simple calculator."""
|
||||
|
||||
def __init__(self):
|
||||
self.value = 0
|
||||
|
||||
def add(self, x):
|
||||
self.value += x
|
||||
return self.value
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(greet("World"))
|
||||
''')
|
||||
|
||||
print("=" * 60)
|
||||
print("PRAISONAI DEBUG MODE DEMO")
|
||||
print("=" * 60)
|
||||
print(f"Workspace: {workspace}")
|
||||
|
||||
# Run demos
|
||||
asyncio.run(demo_lsp_queries(str(workspace)))
|
||||
asyncio.run(demo_acp_actions(str(workspace)))
|
||||
asyncio.run(demo_full_interactive_turn(str(workspace)))
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("DEMO COMPLETE")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
LSP Code Intelligence Example
|
||||
|
||||
This example demonstrates how to use LSP-powered tools through an Agent
|
||||
for semantic code analysis (listing symbols, finding definitions, etc.).
|
||||
|
||||
How to run:
|
||||
export OPENAI_API_KEY=your_key
|
||||
python examples/lsp/lsp_code_analysis_example.py
|
||||
|
||||
Prerequisites:
|
||||
pip install praisonai praisonaiagents
|
||||
pip install python-lsp-server # Optional, for full LSP support
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add paths for development
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/praisonai'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/praisonai-agents'))
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 60)
|
||||
print("LSP CODE INTELLIGENCE EXAMPLE")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Import after path setup
|
||||
from praisonai.cli.features import (
|
||||
create_agent_centric_tools,
|
||||
InteractiveRuntime,
|
||||
RuntimeConfig
|
||||
)
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Use the praisonaiagents source as workspace for analysis
|
||||
workspace = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
'../../src/praisonai-agents/praisonaiagents'
|
||||
)
|
||||
workspace = os.path.abspath(workspace)
|
||||
|
||||
print(f"Workspace: {workspace}")
|
||||
print()
|
||||
|
||||
# 1. Create runtime with LSP enabled
|
||||
print("1. Creating InteractiveRuntime with LSP...")
|
||||
config = RuntimeConfig(
|
||||
workspace=workspace,
|
||||
lsp_enabled=True, # Enable LSP for code intelligence
|
||||
acp_enabled=False, # Disable ACP for this example
|
||||
approval_mode="auto"
|
||||
)
|
||||
runtime = InteractiveRuntime(config)
|
||||
status = await runtime.start()
|
||||
|
||||
print(f" LSP enabled: {status['lsp']['enabled']}")
|
||||
print(f" LSP ready: {runtime.lsp_ready}")
|
||||
print(f" LSP status: {status['lsp']['status']}")
|
||||
if not runtime.lsp_ready:
|
||||
print(" Note: LSP not available, will use regex fallback")
|
||||
print()
|
||||
|
||||
# 2. Create agent-centric tools
|
||||
print("2. Creating agent-centric tools...")
|
||||
tools = create_agent_centric_tools(runtime)
|
||||
|
||||
# Filter to show only LSP tools
|
||||
lsp_tools = [t for t in tools if t.__name__.startswith('lsp_')]
|
||||
print(f" LSP tools available:")
|
||||
for tool in lsp_tools:
|
||||
print(f" - {tool.__name__}")
|
||||
print()
|
||||
|
||||
# 3. Create Agent with LSP-powered tools
|
||||
print("3. Creating Agent with LSP tools...")
|
||||
agent = Agent(
|
||||
name="CodeAnalyzer",
|
||||
instructions="""You are a code analysis assistant.
|
||||
Use lsp_list_symbols to list functions and classes in files.
|
||||
Use lsp_find_definition to find where symbols are defined.
|
||||
Use lsp_find_references to find where symbols are used.
|
||||
Provide clear, organized summaries of code structure.""",
|
||||
tools=tools,
|
||||
verbose=True
|
||||
)
|
||||
print()
|
||||
|
||||
# 4. Test 1: List symbols in a file
|
||||
print("4. TEST 1: List symbols in agent/agent.py")
|
||||
print("-" * 50)
|
||||
result1 = agent.start("List all the main classes and public methods in agent/agent.py. Give me a brief summary.")
|
||||
print("-" * 50)
|
||||
print(f"Summary: {str(result1)[:300]}...")
|
||||
print()
|
||||
|
||||
# 5. Test 2: Find definition of a symbol
|
||||
print("5. TEST 2: Find where 'Agent' class is defined")
|
||||
print("-" * 50)
|
||||
result2 = agent.start("Where is the Agent class defined? Use lsp_find_definition to find it.")
|
||||
print("-" * 50)
|
||||
print(f"Result: {str(result2)[:300]}...")
|
||||
print()
|
||||
|
||||
# 6. Test 3: Analyze main.py
|
||||
print("6. TEST 3: Analyze main.py structure")
|
||||
print("-" * 50)
|
||||
result3 = agent.start("List all the functions in main.py and briefly describe what the file does.")
|
||||
print("-" * 50)
|
||||
print(f"Analysis: {str(result3)[:300]}...")
|
||||
print()
|
||||
|
||||
# Cleanup
|
||||
await runtime.stop()
|
||||
|
||||
print("=" * 60)
|
||||
print("EXAMPLE COMPLETE")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("Key takeaways:")
|
||||
print("- LSP tools work through the Agent (agentic LSP)")
|
||||
print("- Falls back to regex if LSP server not available")
|
||||
print("- Returns structured data with citations")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
PraisonAI Output UX Examples
|
||||
|
||||
This example demonstrates the various output modes and configurations
|
||||
available in PraisonAI agents.
|
||||
|
||||
Features demonstrated:
|
||||
- verbose=True: Full output with agent info, task, and response panels
|
||||
- verbose=False: Clean output with just the result
|
||||
- Tool call display with consolidated output
|
||||
- Multi-agent output with clear attribution
|
||||
"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, '/Users/praison/praisonai-package/src/praisonai-agents')
|
||||
from praisonaiagents import Agent, Task, PraisonAIAgents
|
||||
|
||||
|
||||
def get_weather(city: str) -> str:
|
||||
"""Get the current weather for a city.
|
||||
|
||||
Args:
|
||||
city: The name of the city to get weather for
|
||||
|
||||
Returns:
|
||||
Weather information string
|
||||
"""
|
||||
return f"The weather in {city} is sunny and 72°F"
|
||||
|
||||
|
||||
def example_verbose_true():
|
||||
"""Example with verbose=True (default) - shows full output panels."""
|
||||
print("\n" + "=" * 60)
|
||||
print("EXAMPLE 1: verbose=True (Full Output)")
|
||||
print("=" * 60)
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
instructions="You are a helpful assistant. Be concise.",
|
||||
verbose=True # Default - shows Agent Info, Task, and Response panels
|
||||
)
|
||||
result = agent.start("What is the capital of France? Answer in one sentence.")
|
||||
print(f"\nReturned value: {result}")
|
||||
|
||||
|
||||
def example_verbose_false():
|
||||
"""Example with verbose=False - clean output, just the result."""
|
||||
print("\n" + "=" * 60)
|
||||
print("EXAMPLE 2: verbose=False (Clean Output)")
|
||||
print("=" * 60)
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
instructions="You are a helpful assistant. Be concise.",
|
||||
verbose=False # No panels, just returns the result
|
||||
)
|
||||
result = agent.start("What is 2+2? Answer in one word.")
|
||||
print(f"Result: {result}")
|
||||
|
||||
|
||||
def example_with_tools():
|
||||
"""Example showing tool call output."""
|
||||
print("\n" + "=" * 60)
|
||||
print("EXAMPLE 3: Agent with Tools")
|
||||
print("=" * 60)
|
||||
|
||||
agent = Agent(
|
||||
name="Weather Bot",
|
||||
instructions="You help users check the weather. Use the get_weather tool.",
|
||||
tools=[get_weather],
|
||||
verbose=True
|
||||
)
|
||||
result = agent.start("What's the weather in Tokyo?")
|
||||
print(f"\nReturned value: {result}")
|
||||
|
||||
|
||||
def example_multi_agent():
|
||||
"""Example showing multi-agent output with clear attribution."""
|
||||
print("\n" + "=" * 60)
|
||||
print("EXAMPLE 4: Multi-Agent Output")
|
||||
print("=" * 60)
|
||||
|
||||
researcher = Agent(
|
||||
name="Researcher",
|
||||
role="Research Analyst",
|
||||
instructions="You research topics and provide factual information.",
|
||||
verbose=True
|
||||
)
|
||||
|
||||
writer = Agent(
|
||||
name="Writer",
|
||||
role="Content Writer",
|
||||
instructions="You write clear summaries based on research.",
|
||||
verbose=True
|
||||
)
|
||||
|
||||
task1 = Task(
|
||||
description="Research what AI is in one sentence",
|
||||
expected_output="A brief factual description",
|
||||
agent=researcher
|
||||
)
|
||||
|
||||
task2 = Task(
|
||||
description="Write a one-sentence summary about AI",
|
||||
expected_output="A clear summary",
|
||||
agent=writer
|
||||
)
|
||||
|
||||
agents = PraisonAIAgents(
|
||||
agents=[researcher, writer],
|
||||
tasks=[task1, task2],
|
||||
process="sequential",
|
||||
verbose=1
|
||||
)
|
||||
|
||||
result = agents.start()
|
||||
print(f"\nFinal result: {result}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("PRAISONAI OUTPUT UX EXAMPLES")
|
||||
print("=" * 60)
|
||||
|
||||
# Run examples
|
||||
example_verbose_true()
|
||||
example_verbose_false()
|
||||
example_with_tools()
|
||||
example_multi_agent()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("ALL EXAMPLES COMPLETE")
|
||||
print("=" * 60)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Basic TUI Usage Example for PraisonAI.
|
||||
|
||||
This example demonstrates how to launch the interactive TUI.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Ensure API key is set
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("Please set OPENAI_API_KEY environment variable")
|
||||
exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
"""Launch the TUI."""
|
||||
try:
|
||||
from praisonai.cli.features.tui import run_tui
|
||||
|
||||
# Launch with default settings
|
||||
run_tui()
|
||||
|
||||
except ImportError:
|
||||
print("Textual is required for TUI. Install with:")
|
||||
print(" pip install praisonai[tui]")
|
||||
exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Headless TUI Simulation Example for PraisonAI.
|
||||
|
||||
This example demonstrates how to run TUI simulations without
|
||||
launching the interactive UI - useful for testing and CI.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from praisonai.cli.features.tui import (
|
||||
TuiOrchestrator,
|
||||
SimulationRunner,
|
||||
MockProvider,
|
||||
)
|
||||
from praisonai.cli.features.tui.orchestrator import OutputMode
|
||||
from praisonai.cli.features.queue import QueueConfig
|
||||
|
||||
|
||||
async def run_simulation():
|
||||
"""Run a headless simulation."""
|
||||
|
||||
# Create orchestrator with mock provider (no real API calls)
|
||||
config = QueueConfig(enable_persistence=False)
|
||||
orchestrator = TuiOrchestrator(
|
||||
queue_config=config,
|
||||
output_mode=OutputMode.PRETTY,
|
||||
debug=True,
|
||||
)
|
||||
|
||||
# Create simulation runner
|
||||
runner = SimulationRunner(orchestrator, assert_mode=True)
|
||||
|
||||
# Define simulation script
|
||||
script = {
|
||||
"session_id": "sim-example",
|
||||
"model": "gpt-4o-mini",
|
||||
"steps": [
|
||||
# Navigate to different screens
|
||||
{"action": "navigate", "args": {"screen": "main"}},
|
||||
{"action": "focus", "args": {"widget": "composer"}},
|
||||
|
||||
# Change model
|
||||
{
|
||||
"action": "model",
|
||||
"args": {"model": "gpt-4"},
|
||||
"expected": {"model": "gpt-4"}
|
||||
},
|
||||
|
||||
# Navigate to queue screen
|
||||
{"action": "navigate", "args": {"screen": "queue"}},
|
||||
|
||||
# Take a snapshot
|
||||
{"action": "snapshot"},
|
||||
|
||||
# Navigate back
|
||||
{"action": "navigate", "args": {"screen": "main"}},
|
||||
|
||||
# Small delay
|
||||
{"action": "sleep", "args": {"seconds": 0.5}},
|
||||
]
|
||||
}
|
||||
|
||||
# Run the simulation
|
||||
print("Running headless TUI simulation...")
|
||||
print("=" * 50)
|
||||
|
||||
success = await runner.run_script(script)
|
||||
|
||||
# Print results
|
||||
print("=" * 50)
|
||||
summary = runner.get_summary()
|
||||
|
||||
print(f"\nSimulation {'PASSED' if success else 'FAILED'}")
|
||||
print(f" Assertions passed: {summary['assertions_passed']}")
|
||||
print(f" Assertions failed: {summary['assertions_failed']}")
|
||||
|
||||
if summary['errors']:
|
||||
print("\nErrors:")
|
||||
for error in summary['errors']:
|
||||
print(f" - {error}")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
async def run_with_events():
|
||||
"""Run simulation and capture all events."""
|
||||
|
||||
config = QueueConfig(enable_persistence=False)
|
||||
orchestrator = TuiOrchestrator(
|
||||
queue_config=config,
|
||||
output_mode=OutputMode.SILENT,
|
||||
)
|
||||
|
||||
# Capture events
|
||||
events = []
|
||||
orchestrator.add_event_callback(lambda e: events.append(e))
|
||||
|
||||
await orchestrator.start(session_id="event-capture")
|
||||
|
||||
# Perform some actions
|
||||
orchestrator.set_model("gpt-4")
|
||||
orchestrator.navigate_screen("queue")
|
||||
orchestrator.set_focus("queue-panel")
|
||||
orchestrator.navigate_screen("main")
|
||||
|
||||
await orchestrator.stop()
|
||||
|
||||
# Print captured events
|
||||
print("\nCaptured Events:")
|
||||
print("-" * 40)
|
||||
for event in events:
|
||||
print(f" {event.event_type.value}")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
print("PraisonAI TUI Headless Simulation Example")
|
||||
print("=" * 50)
|
||||
|
||||
# Run simulation
|
||||
success = asyncio.run(run_simulation())
|
||||
|
||||
# Run event capture
|
||||
print("\n" + "=" * 50)
|
||||
events = asyncio.run(run_with_events())
|
||||
|
||||
print(f"\nTotal events captured: {len(events)}")
|
||||
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Queue Management Example for PraisonAI.
|
||||
|
||||
This example demonstrates how to use the queue system programmatically.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from praisonai.cli.features.queue import (
|
||||
QueueManager,
|
||||
QueueConfig,
|
||||
QueuedRun,
|
||||
RunState,
|
||||
RunPriority,
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Demonstrate queue management."""
|
||||
|
||||
print("PraisonAI Queue Management Example")
|
||||
print("=" * 50)
|
||||
|
||||
# Create queue configuration
|
||||
config = QueueConfig(
|
||||
max_concurrent_global=2,
|
||||
max_concurrent_per_agent=1,
|
||||
max_queue_size=10,
|
||||
enable_persistence=False, # Use in-memory for demo
|
||||
)
|
||||
|
||||
# Track outputs
|
||||
outputs = {}
|
||||
|
||||
async def on_output(run_id: str, chunk: str):
|
||||
if run_id not in outputs:
|
||||
outputs[run_id] = ""
|
||||
outputs[run_id] += chunk
|
||||
print(f" [{run_id[:8]}] {chunk}", end="", flush=True)
|
||||
|
||||
async def on_complete(run_id: str, run: QueuedRun):
|
||||
print(f"\n [{run_id[:8]}] Completed: {run.state.value}")
|
||||
|
||||
async def on_error(run_id: str, error: Exception):
|
||||
print(f"\n [{run_id[:8]}] Error: {error}")
|
||||
|
||||
# Create queue manager
|
||||
manager = QueueManager(
|
||||
config=config,
|
||||
on_output=on_output,
|
||||
on_complete=on_complete,
|
||||
on_error=on_error,
|
||||
)
|
||||
|
||||
# Start the manager
|
||||
await manager.start(recover=False)
|
||||
print("Queue manager started")
|
||||
|
||||
# Submit some runs with different priorities
|
||||
print("\nSubmitting runs...")
|
||||
|
||||
run1 = await manager.submit(
|
||||
input_content="Low priority task",
|
||||
agent_name="Agent1",
|
||||
priority=RunPriority.LOW,
|
||||
)
|
||||
print(f" Submitted run1: {run1} (LOW priority)")
|
||||
|
||||
run2 = await manager.submit(
|
||||
input_content="High priority task",
|
||||
agent_name="Agent2",
|
||||
priority=RunPriority.HIGH,
|
||||
)
|
||||
print(f" Submitted run2: {run2} (HIGH priority)")
|
||||
|
||||
run3 = await manager.submit(
|
||||
input_content="Normal priority task",
|
||||
agent_name="Agent1",
|
||||
priority=RunPriority.NORMAL,
|
||||
)
|
||||
print(f" Submitted run3: {run3} (NORMAL priority)")
|
||||
|
||||
# Check queue status
|
||||
print(f"\nQueue status:")
|
||||
print(f" Queued: {manager.queued_count}")
|
||||
print(f" Running: {manager.running_count}")
|
||||
|
||||
# List runs
|
||||
runs = manager.list_runs()
|
||||
print(f"\nAll runs ({len(runs)}):")
|
||||
for run in runs:
|
||||
print(f" {run.run_id[:8]}: {run.agent_name} - {run.state.value} ({run.priority.name})")
|
||||
|
||||
# Wait a bit for processing
|
||||
print("\nWaiting for processing...")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Cancel a run
|
||||
if runs:
|
||||
cancel_id = runs[-1].run_id
|
||||
print(f"\nCancelling run: {cancel_id[:8]}")
|
||||
await manager.cancel(cancel_id)
|
||||
|
||||
# Get stats
|
||||
stats = manager.get_stats()
|
||||
print(f"\nQueue statistics:")
|
||||
print(f" Total runs: {stats.total_runs}")
|
||||
print(f" Queued: {stats.queued_count}")
|
||||
print(f" Running: {stats.running_count}")
|
||||
print(f" Succeeded: {stats.succeeded_count}")
|
||||
print(f" Failed: {stats.failed_count}")
|
||||
print(f" Cancelled: {stats.cancelled_count}")
|
||||
|
||||
# Stop the manager
|
||||
await manager.stop()
|
||||
print("\nQueue manager stopped")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("Example complete!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -150,7 +150,10 @@ def _clean_display_content(content: str, max_length: int = 20000) -> str:
|
||||
return content.strip()
|
||||
|
||||
def display_interaction(message, response, markdown=True, generation_time=None, console=None, agent_name=None, agent_role=None, agent_tools=None, task_name=None, task_description=None, task_id=None):
|
||||
"""Synchronous version of display_interaction."""
|
||||
"""Synchronous version of display_interaction.
|
||||
|
||||
Displays the task/message and response in clean panels with response time in title.
|
||||
"""
|
||||
if console is None:
|
||||
console = Console()
|
||||
|
||||
@@ -179,16 +182,18 @@ def display_interaction(message, response, markdown=True, generation_time=None,
|
||||
task_description=task_description,
|
||||
task_id=task_id
|
||||
)
|
||||
# Rest of the display logic...
|
||||
|
||||
# Build response title with time (Agno-style: "Response (1.2s)")
|
||||
response_title = "Response"
|
||||
if generation_time:
|
||||
console.print(Text(f"Response generated in {generation_time:.1f}s", style="dim"))
|
||||
response_title = f"Response ({generation_time:.1f}s)"
|
||||
|
||||
if markdown:
|
||||
console.print(Panel.fit(Markdown(message), title="Task", border_style="cyan"))
|
||||
console.print(Panel.fit(Markdown(response), title="Response", border_style="cyan"))
|
||||
console.print(Panel.fit(Markdown(response), title=response_title, border_style="cyan"))
|
||||
else:
|
||||
console.print(Panel.fit(Text(message, style="bold green"), title="Task", border_style="cyan"))
|
||||
console.print(Panel.fit(Text(response, style="bold blue"), title="Response", border_style="cyan"))
|
||||
console.print(Panel.fit(Text(response, style="bold blue"), title=response_title, border_style="cyan"))
|
||||
|
||||
def display_self_reflection(message: str, console=None):
|
||||
if not message or not message.strip():
|
||||
@@ -226,7 +231,16 @@ def display_instruction(message: str, console=None, agent_name: str = None, agen
|
||||
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
|
||||
console.print(Panel.fit(Text(message, style="bold blue"), title="Instruction", border_style="cyan"))
|
||||
|
||||
def display_tool_call(message: str, console=None):
|
||||
def display_tool_call(message: str, console=None, tool_name: str = None, tool_input: dict = None, tool_output: str = None):
|
||||
"""Display tool call information in a consolidated panel.
|
||||
|
||||
Args:
|
||||
message: The tool call message (legacy format)
|
||||
console: Rich console for output
|
||||
tool_name: Name of the tool being called
|
||||
tool_input: Input arguments to the tool
|
||||
tool_output: Output from the tool (if available)
|
||||
"""
|
||||
logging.debug(f"display_tool_call called with message: {repr(message)}")
|
||||
if not message or not message.strip():
|
||||
logging.debug("Empty message in display_tool_call, returning early")
|
||||
@@ -235,11 +249,21 @@ def display_tool_call(message: str, console=None):
|
||||
logging.debug(f"Cleaned message in display_tool_call: {repr(message)}")
|
||||
|
||||
# Execute synchronous callbacks (always, even when console is None)
|
||||
execute_sync_callback('tool_call', message=message)
|
||||
execute_sync_callback('tool_call', message=message, tool_name=tool_name, tool_input=tool_input, tool_output=tool_output)
|
||||
|
||||
# Only print panel if console is provided (verbose mode)
|
||||
if console is not None:
|
||||
console.print(Panel.fit(Text(message, style="bold cyan"), title="Tool Call", border_style="green"))
|
||||
# Build consolidated tool call display (Agno-style)
|
||||
if tool_name and tool_input is not None:
|
||||
# Format: tool_name(arg1=val1, arg2=val2)
|
||||
args_str = ", ".join(f"{k}={v}" for k, v in tool_input.items()) if tool_input else ""
|
||||
tool_display = f"• {tool_name}({args_str})"
|
||||
if tool_output:
|
||||
tool_display += f"\n → {tool_output}"
|
||||
console.print(Panel.fit(Text(tool_display, style="bold cyan"), title="Tool Call", border_style="green"))
|
||||
else:
|
||||
# Legacy format
|
||||
console.print(Panel.fit(Text(message, style="bold cyan"), title="Tool Call", border_style="green"))
|
||||
|
||||
def display_error(message: str, console=None):
|
||||
if not message or not message.strip():
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "0.5.2"
|
||||
version = "0.5.3"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
Generated
+1
-1
@@ -2422,7 +2422,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.5.2"
|
||||
version = "0.5.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
chainlit.md
|
||||
@@ -0,0 +1,137 @@
|
||||
# ACP (Agent Client Protocol) Examples
|
||||
|
||||
This directory contains examples for using PraisonAI with the Agent Client Protocol (ACP).
|
||||
|
||||
## What is ACP?
|
||||
|
||||
The Agent Client Protocol (ACP) is a standardized JSON-RPC 2.0 protocol that allows code editors and IDEs to communicate with AI coding agents. It enables seamless integration between PraisonAI and editors like:
|
||||
|
||||
- **Zed**
|
||||
- **JetBrains IDEs** (IntelliJ, PyCharm, WebStorm, etc.)
|
||||
- **VSCode** (via extensions)
|
||||
- **Toad**
|
||||
|
||||
## Quick Start
|
||||
|
||||
### CLI Usage
|
||||
|
||||
```bash
|
||||
# Start ACP server with defaults
|
||||
praisonai acp
|
||||
|
||||
# Enable debug logging
|
||||
praisonai acp --debug
|
||||
|
||||
# Allow file writes
|
||||
praisonai acp --allow-write
|
||||
|
||||
# Use a specific model
|
||||
praisonai acp -m gpt-4o
|
||||
|
||||
# Resume last session
|
||||
praisonai acp --resume --last
|
||||
```
|
||||
|
||||
### Python API
|
||||
|
||||
```python
|
||||
from praisonai.acp import serve
|
||||
|
||||
serve(
|
||||
workspace=".",
|
||||
debug=True,
|
||||
allow_write=True,
|
||||
)
|
||||
```
|
||||
|
||||
## Editor Configuration
|
||||
|
||||
### Zed
|
||||
|
||||
Add to `~/.config/zed/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"PraisonAI": {
|
||||
"command": "praisonai",
|
||||
"args": ["acp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### JetBrains
|
||||
|
||||
Add to `~/.jetbrains/acp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"PraisonAI": {
|
||||
"command": "praisonai",
|
||||
"args": ["acp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Toad
|
||||
|
||||
```bash
|
||||
toad acp "praisonai acp"
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
- `basic_acp_server.py` - Simple ACP server with default settings
|
||||
- `custom_agent_acp.py` - ACP server with a custom agent
|
||||
|
||||
## CLI Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-w, --workspace` | Workspace root directory |
|
||||
| `-a, --agent` | Agent name or config file |
|
||||
| `-m, --model` | LLM model to use |
|
||||
| `-r, --resume` | Resume session by ID |
|
||||
| `--last` | Resume the last session |
|
||||
| `--approve` | Approval mode: manual, auto, scoped |
|
||||
| `--read-only` | Read-only mode (default) |
|
||||
| `--allow-write` | Allow file writes |
|
||||
| `--allow-shell` | Allow shell commands |
|
||||
| `--debug` | Enable debug logging |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install praisonai[acp]
|
||||
```
|
||||
|
||||
## Protocol Details
|
||||
|
||||
ACP uses JSON-RPC 2.0 over stdio:
|
||||
- **stdin**: Receives JSON-RPC requests from the client
|
||||
- **stdout**: Sends JSON-RPC responses to the client
|
||||
- **stderr**: Debug/log output (never pollutes stdout)
|
||||
|
||||
### Supported Methods
|
||||
|
||||
**Agent Methods (client → agent):**
|
||||
- `initialize` - Negotiate protocol version and capabilities
|
||||
- `authenticate` - Optional authentication
|
||||
- `session/new` - Create a new session
|
||||
- `session/load` - Load an existing session
|
||||
- `session/prompt` - Send user message
|
||||
- `session/cancel` - Cancel ongoing operations
|
||||
- `session/set_mode` - Change operating mode
|
||||
|
||||
**Client Methods (agent → client):**
|
||||
- `session/update` - Send progress updates
|
||||
- `session/request_permission` - Request user approval
|
||||
- `fs/read_text_file` - Read file contents
|
||||
- `fs/write_text_file` - Write file contents
|
||||
- `terminal/create` - Create terminal
|
||||
- `terminal/output` - Get terminal output
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic ACP Server Example
|
||||
|
||||
This example demonstrates how to run PraisonAI as an ACP server
|
||||
that can be connected to by IDEs like Zed, JetBrains, VSCode, or Toad.
|
||||
|
||||
Usage:
|
||||
# Run from command line
|
||||
praisonai acp
|
||||
|
||||
# Or run this script directly
|
||||
python basic_acp_server.py
|
||||
"""
|
||||
|
||||
from praisonai.acp import serve
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Start ACP server with default settings
|
||||
# This will listen on stdin/stdout for JSON-RPC messages
|
||||
serve(
|
||||
workspace=".", # Current directory as workspace
|
||||
agent="default", # Use default agent
|
||||
debug=False, # Set to True for debug logging to stderr
|
||||
read_only=True, # Safe by default
|
||||
approval_mode="manual", # Require approval for actions
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Custom Agent ACP Server Example
|
||||
|
||||
This example shows how to run a custom PraisonAI agent as an ACP server.
|
||||
|
||||
Usage:
|
||||
python custom_agent_acp.py
|
||||
"""
|
||||
|
||||
from praisonai.acp import serve, ACPServer, ACPConfig
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Create a custom agent
|
||||
agent = Agent(
|
||||
name="CodeAssistant",
|
||||
instructions="""You are an expert coding assistant. You help users:
|
||||
- Write clean, efficient code
|
||||
- Debug issues
|
||||
- Explain complex concepts
|
||||
- Review code for best practices
|
||||
|
||||
Always provide clear explanations with your code suggestions.""",
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Create config
|
||||
config = ACPConfig(
|
||||
workspace=".",
|
||||
debug=True,
|
||||
allow_write=True, # Allow file modifications
|
||||
approval_mode="manual",
|
||||
)
|
||||
|
||||
# Create server with custom agent
|
||||
server = ACPServer(config=config, agent=agent)
|
||||
|
||||
# Run the server
|
||||
serve(
|
||||
workspace=".",
|
||||
debug=True,
|
||||
allow_write=True,
|
||||
approval_mode="manual",
|
||||
)
|
||||
@@ -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.8.3.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.8.3.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.9.2.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.9.2.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
# __main__.py
|
||||
"""
|
||||
PraisonAI CLI Entry Point.
|
||||
|
||||
from .cli import PraisonAI
|
||||
Supports both new Typer-based CLI and legacy argparse CLI.
|
||||
"""
|
||||
|
||||
def main():
|
||||
praison_ai = PraisonAI()
|
||||
praison_ai.main()
|
||||
"""Main entry point with legacy support."""
|
||||
from .cli.legacy import main_with_legacy_support
|
||||
main_with_legacy_support()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Agent Client Protocol (ACP) support for PraisonAI.
|
||||
|
||||
This module provides ACP server functionality that allows IDEs/editors
|
||||
(Zed, JetBrains, VSCode, Toad) to connect to PraisonAI agents.
|
||||
|
||||
Usage:
|
||||
# CLI
|
||||
praisonai acp [OPTIONS]
|
||||
|
||||
# Python API
|
||||
from praisonai.acp import serve
|
||||
serve(workspace=".", agent="default")
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
|
||||
__all__ = [
|
||||
"serve",
|
||||
"ACPServer",
|
||||
"ACPSession",
|
||||
"ACPConfig",
|
||||
]
|
||||
|
||||
# Lazy imports for performance
|
||||
def __getattr__(name: str):
|
||||
"""Lazy import ACP components."""
|
||||
if name == "serve":
|
||||
from .server import serve
|
||||
return serve
|
||||
if name == "ACPServer":
|
||||
from .server import ACPServer
|
||||
return ACPServer
|
||||
if name == "ACPSession":
|
||||
from .session import ACPSession
|
||||
return ACPSession
|
||||
if name == "ACPConfig":
|
||||
from .config import ACPConfig
|
||||
return ACPConfig
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def _check_acp_available() -> bool:
|
||||
"""Check if agent-client-protocol package is installed."""
|
||||
return importlib.util.find_spec("acp") is not None
|
||||
|
||||
|
||||
def _get_install_instructions() -> str:
|
||||
"""Get installation instructions for ACP support."""
|
||||
return (
|
||||
"ACP support requires the agent-client-protocol package.\n"
|
||||
"Install with: pip install praisonai[acp]\n"
|
||||
"Or: pip install agent-client-protocol"
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
ACP configuration management.
|
||||
|
||||
Handles configuration precedence:
|
||||
1. CLI flags (highest)
|
||||
2. Environment variables
|
||||
3. Config file (~/.praison/config.yaml)
|
||||
4. Defaults (lowest)
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ACPConfig:
|
||||
"""Configuration for ACP server."""
|
||||
|
||||
# Workspace settings
|
||||
workspace: Path = field(default_factory=Path.cwd)
|
||||
|
||||
# Agent settings
|
||||
agent: str = "default"
|
||||
agents_config: Optional[Path] = None
|
||||
router_enabled: bool = False
|
||||
model: Optional[str] = None
|
||||
|
||||
# Session settings
|
||||
resume_session: Optional[str] = None
|
||||
resume_last: bool = False
|
||||
|
||||
# Permission settings
|
||||
read_only: bool = True
|
||||
allow_write: bool = False
|
||||
allow_shell: bool = False
|
||||
allow_network: bool = False
|
||||
approval_mode: str = "manual" # manual, auto, scoped
|
||||
|
||||
# Workspace boundaries
|
||||
allowed_paths: List[Path] = field(default_factory=list)
|
||||
|
||||
# Debug settings
|
||||
debug: bool = False
|
||||
|
||||
# Profile
|
||||
profile: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate and normalize configuration."""
|
||||
if isinstance(self.workspace, str):
|
||||
self.workspace = Path(self.workspace).resolve()
|
||||
|
||||
if self.agents_config and isinstance(self.agents_config, str):
|
||||
self.agents_config = Path(self.agents_config)
|
||||
|
||||
# Set up default allowed paths
|
||||
if not self.allowed_paths:
|
||||
self.allowed_paths = [
|
||||
self.workspace,
|
||||
Path.home() / ".praison",
|
||||
]
|
||||
|
||||
# Normalize allowed paths
|
||||
self.allowed_paths = [
|
||||
Path(p).resolve() if isinstance(p, str) else p.resolve()
|
||||
for p in self.allowed_paths
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "ACPConfig":
|
||||
"""Create config from environment variables."""
|
||||
return cls(
|
||||
workspace=Path(os.environ.get("PRAISONAI_WORKSPACE", ".")).resolve(),
|
||||
agent=os.environ.get("PRAISONAI_AGENT", "default"),
|
||||
model=os.environ.get("PRAISONAI_MODEL") or os.environ.get("OPENAI_MODEL"),
|
||||
debug=os.environ.get("PRAISONAI_DEBUG", "").lower() in ("1", "true", "yes"),
|
||||
read_only=os.environ.get("PRAISONAI_READ_ONLY", "true").lower() in ("1", "true", "yes"),
|
||||
approval_mode=os.environ.get("PRAISONAI_APPROVAL_MODE", "manual"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Optional[Path] = None) -> "ACPConfig":
|
||||
"""Load config from YAML file."""
|
||||
if path is None:
|
||||
path = Path.home() / ".praison" / "config.yaml"
|
||||
|
||||
if not path.exists():
|
||||
return cls()
|
||||
|
||||
try:
|
||||
import yaml
|
||||
with open(path) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
acp_config = data.get("acp", {})
|
||||
return cls(
|
||||
workspace=Path(acp_config.get("workspace", ".")).resolve(),
|
||||
agent=acp_config.get("agent", "default"),
|
||||
model=acp_config.get("model"),
|
||||
debug=acp_config.get("debug", False),
|
||||
read_only=acp_config.get("read_only", True),
|
||||
approval_mode=acp_config.get("approval_mode", "manual"),
|
||||
)
|
||||
except Exception:
|
||||
return cls()
|
||||
|
||||
@classmethod
|
||||
def merge(cls, *configs: "ACPConfig") -> "ACPConfig":
|
||||
"""Merge multiple configs, later configs override earlier ones."""
|
||||
result = cls()
|
||||
for config in configs:
|
||||
for field_name in [
|
||||
"workspace", "agent", "agents_config", "router_enabled",
|
||||
"model", "resume_session", "resume_last", "read_only",
|
||||
"allow_write", "allow_shell", "allow_network", "approval_mode",
|
||||
"debug", "profile"
|
||||
]:
|
||||
value = getattr(config, field_name)
|
||||
# Only override if value is not default/None
|
||||
if value is not None and value != getattr(cls(), field_name, None):
|
||||
setattr(result, field_name, value)
|
||||
return result
|
||||
|
||||
def is_path_allowed(self, path: Path) -> bool:
|
||||
"""Check if a path is within allowed boundaries."""
|
||||
path = Path(path).resolve()
|
||||
return any(
|
||||
path == allowed or allowed in path.parents
|
||||
for allowed in self.allowed_paths
|
||||
)
|
||||
|
||||
def can_write(self) -> bool:
|
||||
"""Check if write operations are allowed."""
|
||||
return not self.read_only or self.allow_write
|
||||
|
||||
def can_execute_shell(self) -> bool:
|
||||
"""Check if shell commands are allowed."""
|
||||
return self.allow_shell
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert config to dictionary."""
|
||||
return {
|
||||
"workspace": str(self.workspace),
|
||||
"agent": self.agent,
|
||||
"agents_config": str(self.agents_config) if self.agents_config else None,
|
||||
"router_enabled": self.router_enabled,
|
||||
"model": self.model,
|
||||
"resume_session": self.resume_session,
|
||||
"resume_last": self.resume_last,
|
||||
"read_only": self.read_only,
|
||||
"allow_write": self.allow_write,
|
||||
"allow_shell": self.allow_shell,
|
||||
"allow_network": self.allow_network,
|
||||
"approval_mode": self.approval_mode,
|
||||
"debug": self.debug,
|
||||
"profile": self.profile,
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
"""
|
||||
ACP Server implementation for PraisonAI.
|
||||
|
||||
This module implements the Agent Client Protocol server that allows
|
||||
IDEs/editors to communicate with PraisonAI agents.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from .config import ACPConfig
|
||||
from .session import ACPSession, SessionStore
|
||||
|
||||
# Configure logging to stderr only (stdout reserved for JSON-RPC)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _setup_stderr_logging(debug: bool = False) -> None:
|
||||
"""Configure logging to stderr only."""
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||
))
|
||||
|
||||
root_logger = logging.getLogger("praisonai.acp")
|
||||
root_logger.handlers.clear()
|
||||
root_logger.addHandler(handler)
|
||||
root_logger.setLevel(logging.DEBUG if debug else logging.INFO)
|
||||
|
||||
|
||||
class ACPServer:
|
||||
"""
|
||||
ACP Server that exposes PraisonAI agents to IDE clients.
|
||||
|
||||
Implements the Agent Client Protocol (JSON-RPC 2.0 over stdio).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[ACPConfig] = None,
|
||||
agent: Optional[Any] = None,
|
||||
agents: Optional[Any] = None,
|
||||
):
|
||||
"""
|
||||
Initialize ACP server.
|
||||
|
||||
Args:
|
||||
config: ACP configuration
|
||||
agent: Optional pre-configured Agent instance
|
||||
agents: Optional pre-configured PraisonAIAgents instance
|
||||
"""
|
||||
self.config = config or ACPConfig()
|
||||
self._agent = agent
|
||||
self._agents = agents
|
||||
self._session_store = SessionStore()
|
||||
self._sessions: Dict[str, ACPSession] = {}
|
||||
self._client = None # ACP client connection
|
||||
self._cancelled_sessions: set = set()
|
||||
|
||||
_setup_stderr_logging(self.config.debug)
|
||||
|
||||
def _get_agent(self):
|
||||
"""Lazy load or create agent."""
|
||||
if self._agent is not None:
|
||||
return self._agent
|
||||
|
||||
if self._agents is not None:
|
||||
return self._agents
|
||||
|
||||
# Create default agent
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
self._agent = Agent(
|
||||
name="PraisonAI",
|
||||
instructions="You are a helpful AI coding assistant.",
|
||||
model=self.config.model or "gpt-4o-mini",
|
||||
)
|
||||
return self._agent
|
||||
except ImportError:
|
||||
logger.error("praisonaiagents not installed")
|
||||
raise RuntimeError("praisonaiagents package required")
|
||||
|
||||
def on_connect(self, client) -> None:
|
||||
"""Called when client connects."""
|
||||
self._client = client
|
||||
logger.info("Client connected")
|
||||
|
||||
async def initialize(
|
||||
self,
|
||||
protocol_version: int,
|
||||
client_capabilities: Optional[Dict[str, Any]] = None,
|
||||
client_info: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Handle initialize request.
|
||||
|
||||
Negotiate protocol version and exchange capabilities.
|
||||
"""
|
||||
logger.info(f"Initialize: protocol_version={protocol_version}, client_info={client_info}")
|
||||
|
||||
# Import here to avoid circular imports and check availability
|
||||
try:
|
||||
from acp import PROTOCOL_VERSION
|
||||
except ImportError:
|
||||
PROTOCOL_VERSION = 1
|
||||
|
||||
return {
|
||||
"protocolVersion": min(protocol_version, PROTOCOL_VERSION),
|
||||
"agentCapabilities": {
|
||||
"loadSession": True,
|
||||
"promptCapabilities": {
|
||||
"image": False,
|
||||
"audio": False,
|
||||
"embeddedContext": True,
|
||||
},
|
||||
"mcpCapabilities": {
|
||||
"http": False,
|
||||
"sse": False,
|
||||
},
|
||||
},
|
||||
"agentInfo": {
|
||||
"name": "praisonai",
|
||||
"title": "PraisonAI",
|
||||
"version": self._get_version(),
|
||||
},
|
||||
"authMethods": [],
|
||||
}
|
||||
|
||||
async def authenticate(self, method_id: str, **kwargs: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Handle authenticate request (optional)."""
|
||||
logger.info(f"Authenticate: method_id={method_id}")
|
||||
return {}
|
||||
|
||||
async def new_session(
|
||||
self,
|
||||
cwd: str,
|
||||
mcp_servers: List[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new conversation session.
|
||||
|
||||
Args:
|
||||
cwd: Working directory for the session
|
||||
mcp_servers: List of MCP server configurations
|
||||
"""
|
||||
logger.info(f"New session: cwd={cwd}")
|
||||
|
||||
workspace = Path(cwd).resolve()
|
||||
session = ACPSession.create(
|
||||
workspace=workspace,
|
||||
agent_id=self.config.agent,
|
||||
)
|
||||
session.mcp_servers = mcp_servers
|
||||
|
||||
self._sessions[session.session_id] = session
|
||||
self._session_store.save(session)
|
||||
|
||||
logger.info(f"Created session: {session.session_id}")
|
||||
|
||||
return {
|
||||
"sessionId": session.session_id,
|
||||
"modes": self._get_available_modes(),
|
||||
}
|
||||
|
||||
async def load_session(
|
||||
self,
|
||||
cwd: str,
|
||||
mcp_servers: List[Dict[str, Any]],
|
||||
session_id: str,
|
||||
**kwargs: Any,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Load an existing session.
|
||||
|
||||
Replays conversation history via session/update notifications.
|
||||
"""
|
||||
logger.info(f"Load session: session_id={session_id}")
|
||||
|
||||
# Try to load from store
|
||||
session = self._session_store.load(session_id)
|
||||
if session is None:
|
||||
logger.warning(f"Session not found: {session_id}")
|
||||
return None
|
||||
|
||||
# Update workspace and MCP servers
|
||||
session.workspace = Path(cwd).resolve()
|
||||
session.mcp_servers = mcp_servers
|
||||
session.update_activity()
|
||||
|
||||
self._sessions[session_id] = session
|
||||
|
||||
# Replay conversation history
|
||||
await self._replay_session_history(session)
|
||||
|
||||
return {}
|
||||
|
||||
async def list_sessions(
|
||||
self,
|
||||
cursor: Optional[str] = None,
|
||||
cwd: Optional[str] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""List available sessions."""
|
||||
sessions = self._session_store.list_sessions()
|
||||
|
||||
# Filter by cwd if provided
|
||||
if cwd:
|
||||
cwd_path = Path(cwd).resolve()
|
||||
sessions = [s for s in sessions if s.workspace == cwd_path]
|
||||
|
||||
return {
|
||||
"sessions": [
|
||||
{
|
||||
"sessionId": s.session_id,
|
||||
"createdAt": s.created_at,
|
||||
"lastActivity": s.last_activity,
|
||||
}
|
||||
for s in sessions
|
||||
],
|
||||
}
|
||||
|
||||
async def set_session_mode(
|
||||
self,
|
||||
mode_id: str,
|
||||
session_id: str,
|
||||
**kwargs: Any,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Set the operating mode for a session."""
|
||||
logger.info(f"Set session mode: session_id={session_id}, mode_id={mode_id}")
|
||||
|
||||
session = self._sessions.get(session_id)
|
||||
if session:
|
||||
session.mode = mode_id
|
||||
self._session_store.save(session)
|
||||
|
||||
return {}
|
||||
|
||||
async def set_session_model(
|
||||
self,
|
||||
model_id: str,
|
||||
session_id: str,
|
||||
**kwargs: Any,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Set the LLM model for a session."""
|
||||
logger.info(f"Set session model: session_id={session_id}, model_id={model_id}")
|
||||
|
||||
session = self._sessions.get(session_id)
|
||||
if session:
|
||||
session.model = model_id
|
||||
self._session_store.save(session)
|
||||
|
||||
return {}
|
||||
|
||||
async def prompt(
|
||||
self,
|
||||
prompt: List[Dict[str, Any]],
|
||||
session_id: str,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Handle user prompt.
|
||||
|
||||
This is the main interaction method where the user sends a message
|
||||
and the agent processes it.
|
||||
"""
|
||||
logger.info(f"Prompt: session_id={session_id}")
|
||||
|
||||
session = self._sessions.get(session_id)
|
||||
if session is None:
|
||||
logger.error(f"Session not found: {session_id}")
|
||||
return {"stopReason": "refusal"}
|
||||
|
||||
# Check if cancelled
|
||||
if session_id in self._cancelled_sessions:
|
||||
self._cancelled_sessions.discard(session_id)
|
||||
return {"stopReason": "cancelled"}
|
||||
|
||||
# Extract text from prompt blocks
|
||||
user_message = self._extract_prompt_text(prompt)
|
||||
session.add_message("user", user_message)
|
||||
|
||||
try:
|
||||
# Get agent response
|
||||
agent = self._get_agent()
|
||||
|
||||
# Send thinking update
|
||||
await self._send_thought(session_id, "Processing your request...")
|
||||
|
||||
# Generate response
|
||||
response = await self._generate_response(agent, user_message, session)
|
||||
|
||||
# Send response as agent message chunks
|
||||
await self._send_agent_message(session_id, response)
|
||||
|
||||
# Save session
|
||||
session.add_message("assistant", response)
|
||||
self._session_store.save(session)
|
||||
|
||||
return {"stopReason": "end_turn"}
|
||||
|
||||
except asyncio.CancelledError:
|
||||
return {"stopReason": "cancelled"}
|
||||
except Exception as e:
|
||||
logger.exception(f"Error processing prompt: {e}")
|
||||
await self._send_agent_message(session_id, f"Error: {str(e)}")
|
||||
return {"stopReason": "refusal"}
|
||||
|
||||
async def fork_session(
|
||||
self,
|
||||
cwd: str,
|
||||
session_id: str,
|
||||
mcp_servers: Optional[List[Dict[str, Any]]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Fork an existing session."""
|
||||
logger.info(f"Fork session: session_id={session_id}")
|
||||
|
||||
original = self._sessions.get(session_id)
|
||||
if original is None:
|
||||
original = self._session_store.load(session_id)
|
||||
|
||||
if original is None:
|
||||
# Create new session if original not found
|
||||
return await self.new_session(cwd, mcp_servers or [])
|
||||
|
||||
# Create forked session
|
||||
forked = ACPSession.create(
|
||||
workspace=Path(cwd).resolve(),
|
||||
agent_id=original.agent_id,
|
||||
)
|
||||
forked.messages = original.messages.copy()
|
||||
forked.mcp_servers = mcp_servers or original.mcp_servers
|
||||
|
||||
self._sessions[forked.session_id] = forked
|
||||
self._session_store.save(forked)
|
||||
|
||||
return {"sessionId": forked.session_id}
|
||||
|
||||
async def resume_session(
|
||||
self,
|
||||
cwd: str,
|
||||
session_id: str,
|
||||
mcp_servers: Optional[List[Dict[str, Any]]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Resume an existing session."""
|
||||
return await self.load_session(cwd, mcp_servers or [], session_id)
|
||||
|
||||
async def cancel(self, session_id: str, **kwargs: Any) -> None:
|
||||
"""Cancel ongoing operations for a session."""
|
||||
logger.info(f"Cancel: session_id={session_id}")
|
||||
self._cancelled_sessions.add(session_id)
|
||||
|
||||
async def ext_method(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Handle extension method calls."""
|
||||
logger.info(f"Extension method: {method}")
|
||||
return {"error": f"Unknown extension method: {method}"}
|
||||
|
||||
async def ext_notification(self, method: str, params: Dict[str, Any]) -> None:
|
||||
"""Handle extension notifications."""
|
||||
logger.info(f"Extension notification: {method}")
|
||||
|
||||
# Helper methods
|
||||
|
||||
def _get_version(self) -> str:
|
||||
"""Get PraisonAI version."""
|
||||
try:
|
||||
from praisonai.version import __version__
|
||||
return __version__
|
||||
except ImportError:
|
||||
return "0.0.0"
|
||||
|
||||
def _get_available_modes(self) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Get available operating modes."""
|
||||
return [
|
||||
{
|
||||
"id": "manual",
|
||||
"name": "Manual",
|
||||
"description": "Requires approval for all actions",
|
||||
},
|
||||
{
|
||||
"id": "auto",
|
||||
"name": "Auto",
|
||||
"description": "Auto-approve within workspace",
|
||||
},
|
||||
]
|
||||
|
||||
def _extract_prompt_text(self, prompt: List[Dict[str, Any]]) -> str:
|
||||
"""Extract text content from prompt blocks."""
|
||||
texts = []
|
||||
for block in prompt:
|
||||
if isinstance(block, dict):
|
||||
block_type = block.get("type", "")
|
||||
if block_type == "text":
|
||||
texts.append(block.get("text", ""))
|
||||
elif block_type == "resource":
|
||||
resource = block.get("resource", {})
|
||||
if "text" in resource:
|
||||
texts.append(f"[File: {resource.get('uri', 'unknown')}]\n{resource['text']}")
|
||||
return "\n".join(texts)
|
||||
|
||||
async def _generate_response(
|
||||
self,
|
||||
agent: Any,
|
||||
message: str,
|
||||
session: ACPSession,
|
||||
) -> str:
|
||||
"""Generate agent response."""
|
||||
try:
|
||||
# Try async chat first
|
||||
if hasattr(agent, "achat"):
|
||||
response = await agent.achat(message)
|
||||
elif hasattr(agent, "chat"):
|
||||
# Run sync chat in executor
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(None, agent.chat, message)
|
||||
else:
|
||||
response = "Agent does not support chat interface"
|
||||
|
||||
return str(response) if response else ""
|
||||
except Exception as e:
|
||||
logger.exception(f"Error generating response: {e}")
|
||||
return f"Error generating response: {str(e)}"
|
||||
|
||||
async def _send_agent_message(self, session_id: str, text: str) -> None:
|
||||
"""Send agent message chunk to client."""
|
||||
if self._client is None:
|
||||
return
|
||||
|
||||
try:
|
||||
from acp import update_agent_message_text
|
||||
update = update_agent_message_text(text)
|
||||
await self._client.session_update(session_id=session_id, update=update)
|
||||
except ImportError:
|
||||
# Fallback without ACP SDK
|
||||
logger.debug(f"Agent message: {text[:100]}...")
|
||||
|
||||
async def _send_thought(self, session_id: str, text: str) -> None:
|
||||
"""Send agent thought to client."""
|
||||
if self._client is None:
|
||||
return
|
||||
|
||||
try:
|
||||
from acp import update_agent_thought_text
|
||||
update = update_agent_thought_text(text)
|
||||
await self._client.session_update(session_id=session_id, update=update)
|
||||
except ImportError:
|
||||
logger.debug(f"Agent thought: {text}")
|
||||
|
||||
async def _replay_session_history(self, session: ACPSession) -> None:
|
||||
"""Replay session history to client."""
|
||||
if self._client is None:
|
||||
return
|
||||
|
||||
try:
|
||||
from acp import update_user_message_text, update_agent_message_text
|
||||
|
||||
for msg in session.messages:
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
|
||||
if role == "user":
|
||||
update = update_user_message_text(str(content))
|
||||
elif role == "assistant":
|
||||
update = update_agent_message_text(str(content))
|
||||
else:
|
||||
continue
|
||||
|
||||
await self._client.session_update(
|
||||
session_id=session.session_id,
|
||||
update=update,
|
||||
)
|
||||
except ImportError:
|
||||
logger.debug("Skipping history replay (ACP SDK not available)")
|
||||
|
||||
|
||||
async def _run_server(config: ACPConfig) -> None:
|
||||
"""Run the ACP server."""
|
||||
try:
|
||||
from acp import Agent as ACPAgent, run_agent
|
||||
except ImportError:
|
||||
logger.error(
|
||||
"agent-client-protocol package not installed.\n"
|
||||
"Install with: pip install praisonai[acp]\n"
|
||||
"Or: pip install agent-client-protocol"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
server = ACPServer(config=config)
|
||||
|
||||
# Create ACP-compatible agent wrapper
|
||||
class PraisonACPAgent(ACPAgent):
|
||||
def on_connect(self, conn):
|
||||
server.on_connect(conn)
|
||||
|
||||
async def initialize(self, protocol_version, client_capabilities=None, client_info=None, **kwargs):
|
||||
return await server.initialize(protocol_version, client_capabilities, client_info, **kwargs)
|
||||
|
||||
async def authenticate(self, method_id, **kwargs):
|
||||
return await server.authenticate(method_id, **kwargs)
|
||||
|
||||
async def new_session(self, cwd, mcp_servers, **kwargs):
|
||||
return await server.new_session(cwd, mcp_servers, **kwargs)
|
||||
|
||||
async def load_session(self, cwd, mcp_servers, session_id, **kwargs):
|
||||
return await server.load_session(cwd, mcp_servers, session_id, **kwargs)
|
||||
|
||||
async def list_sessions(self, cursor=None, cwd=None, **kwargs):
|
||||
return await server.list_sessions(cursor, cwd, **kwargs)
|
||||
|
||||
async def set_session_mode(self, mode_id, session_id, **kwargs):
|
||||
return await server.set_session_mode(mode_id, session_id, **kwargs)
|
||||
|
||||
async def set_session_model(self, model_id, session_id, **kwargs):
|
||||
return await server.set_session_model(model_id, session_id, **kwargs)
|
||||
|
||||
async def prompt(self, prompt, session_id, **kwargs):
|
||||
return await server.prompt(prompt, session_id, **kwargs)
|
||||
|
||||
async def fork_session(self, cwd, session_id, mcp_servers=None, **kwargs):
|
||||
return await server.fork_session(cwd, session_id, mcp_servers, **kwargs)
|
||||
|
||||
async def resume_session(self, cwd, session_id, mcp_servers=None, **kwargs):
|
||||
return await server.resume_session(cwd, session_id, mcp_servers, **kwargs)
|
||||
|
||||
async def cancel(self, session_id, **kwargs):
|
||||
return await server.cancel(session_id, **kwargs)
|
||||
|
||||
async def ext_method(self, method, params):
|
||||
return await server.ext_method(method, params)
|
||||
|
||||
async def ext_notification(self, method, params):
|
||||
return await server.ext_notification(method, params)
|
||||
|
||||
logger.info("Starting PraisonAI ACP server...")
|
||||
await run_agent(PraisonACPAgent(), use_unstable_protocol=True)
|
||||
|
||||
|
||||
def serve(
|
||||
workspace: Union[str, Path] = ".",
|
||||
agent: str = "default",
|
||||
model: Optional[str] = None,
|
||||
resume: Optional[str] = None,
|
||||
resume_last: bool = False,
|
||||
debug: bool = False,
|
||||
read_only: bool = True,
|
||||
allow_write: bool = False,
|
||||
allow_shell: bool = False,
|
||||
approval_mode: str = "manual",
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Start the ACP server.
|
||||
|
||||
This is the main entry point for running PraisonAI as an ACP agent.
|
||||
|
||||
Args:
|
||||
workspace: Working directory for the session
|
||||
agent: Agent name or configuration
|
||||
model: LLM model to use
|
||||
resume: Session ID to resume
|
||||
resume_last: Resume the last session
|
||||
debug: Enable debug logging
|
||||
read_only: Read-only mode (default True)
|
||||
allow_write: Allow file writes
|
||||
allow_shell: Allow shell commands
|
||||
approval_mode: Approval mode (manual, auto, scoped)
|
||||
"""
|
||||
config = ACPConfig(
|
||||
workspace=Path(workspace).resolve(),
|
||||
agent=agent,
|
||||
model=model,
|
||||
resume_session=resume,
|
||||
resume_last=resume_last,
|
||||
debug=debug,
|
||||
read_only=read_only,
|
||||
allow_write=allow_write,
|
||||
allow_shell=allow_shell,
|
||||
approval_mode=approval_mode,
|
||||
)
|
||||
|
||||
asyncio.run(_run_server(config))
|
||||
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
ACP session management.
|
||||
|
||||
Handles session creation, persistence, and resume functionality.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ACPSession:
|
||||
"""Represents an ACP conversation session."""
|
||||
|
||||
session_id: str
|
||||
workspace: Path
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_activity: float = field(default_factory=time.time)
|
||||
|
||||
# Agent attribution
|
||||
agent_id: Optional[str] = None
|
||||
run_id: Optional[str] = None
|
||||
trace_id: Optional[str] = None
|
||||
|
||||
# Session state
|
||||
mode: str = "manual" # manual, auto, full_auto
|
||||
model: Optional[str] = None
|
||||
|
||||
# Conversation history (for resume)
|
||||
messages: List[Dict[str, Any]] = field(default_factory=list)
|
||||
tool_calls: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# MCP servers
|
||||
mcp_servers: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def create(cls, workspace: Path, agent_id: Optional[str] = None) -> "ACPSession":
|
||||
"""Create a new session."""
|
||||
session_id = f"sess_{uuid.uuid4().hex[:16]}"
|
||||
return cls(
|
||||
session_id=session_id,
|
||||
workspace=workspace,
|
||||
agent_id=agent_id,
|
||||
run_id=f"run_{uuid.uuid4().hex[:8]}",
|
||||
trace_id=f"trace_{uuid.uuid4().hex[:8]}",
|
||||
)
|
||||
|
||||
def update_activity(self) -> None:
|
||||
"""Update last activity timestamp."""
|
||||
self.last_activity = time.time()
|
||||
|
||||
def add_message(self, role: str, content: Any) -> None:
|
||||
"""Add a message to the conversation history."""
|
||||
self.messages.append({
|
||||
"role": role,
|
||||
"content": content,
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
self.update_activity()
|
||||
|
||||
def add_tool_call(self, tool_call_id: str, title: str, status: str, **kwargs) -> None:
|
||||
"""Add a tool call to the history."""
|
||||
self.tool_calls.append({
|
||||
"tool_call_id": tool_call_id,
|
||||
"title": title,
|
||||
"status": status,
|
||||
"timestamp": time.time(),
|
||||
**kwargs,
|
||||
})
|
||||
self.update_activity()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize session to dictionary."""
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"workspace": str(self.workspace),
|
||||
"created_at": self.created_at,
|
||||
"last_activity": self.last_activity,
|
||||
"agent_id": self.agent_id,
|
||||
"run_id": self.run_id,
|
||||
"trace_id": self.trace_id,
|
||||
"mode": self.mode,
|
||||
"model": self.model,
|
||||
"messages": self.messages,
|
||||
"tool_calls": self.tool_calls,
|
||||
"mcp_servers": self.mcp_servers,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ACPSession":
|
||||
"""Deserialize session from dictionary."""
|
||||
return cls(
|
||||
session_id=data["session_id"],
|
||||
workspace=Path(data["workspace"]),
|
||||
created_at=data.get("created_at", time.time()),
|
||||
last_activity=data.get("last_activity", time.time()),
|
||||
agent_id=data.get("agent_id"),
|
||||
run_id=data.get("run_id"),
|
||||
trace_id=data.get("trace_id"),
|
||||
mode=data.get("mode", "manual"),
|
||||
model=data.get("model"),
|
||||
messages=data.get("messages", []),
|
||||
tool_calls=data.get("tool_calls", []),
|
||||
mcp_servers=data.get("mcp_servers", []),
|
||||
)
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""Persistent storage for ACP sessions."""
|
||||
|
||||
def __init__(self, storage_dir: Optional[Path] = None):
|
||||
"""Initialize session store."""
|
||||
if storage_dir is None:
|
||||
storage_dir = Path.home() / ".praison" / "acp" / "sessions"
|
||||
self.storage_dir = storage_dir
|
||||
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._last_session_file = self.storage_dir / ".last_session"
|
||||
|
||||
def _session_path(self, session_id: str) -> Path:
|
||||
"""Get path for session file."""
|
||||
return self.storage_dir / f"{session_id}.json"
|
||||
|
||||
def save(self, session: ACPSession) -> None:
|
||||
"""Save session to disk."""
|
||||
try:
|
||||
path = self._session_path(session.session_id)
|
||||
with open(path, "w") as f:
|
||||
json.dump(session.to_dict(), f, indent=2)
|
||||
|
||||
# Update last session pointer
|
||||
with open(self._last_session_file, "w") as f:
|
||||
f.write(session.session_id)
|
||||
|
||||
logger.debug(f"Saved session {session.session_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save session: {e}")
|
||||
|
||||
def load(self, session_id: str) -> Optional[ACPSession]:
|
||||
"""Load session from disk."""
|
||||
try:
|
||||
path = self._session_path(session_id)
|
||||
if not path.exists():
|
||||
logger.warning(f"Session not found: {session_id}")
|
||||
return None
|
||||
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
return ACPSession.from_dict(data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load session: {e}")
|
||||
return None
|
||||
|
||||
def load_last(self) -> Optional[ACPSession]:
|
||||
"""Load the most recent session."""
|
||||
try:
|
||||
if not self._last_session_file.exists():
|
||||
return None
|
||||
|
||||
with open(self._last_session_file) as f:
|
||||
session_id = f.read().strip()
|
||||
|
||||
if session_id:
|
||||
return self.load(session_id)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load last session: {e}")
|
||||
return None
|
||||
|
||||
def delete(self, session_id: str) -> bool:
|
||||
"""Delete a session."""
|
||||
try:
|
||||
path = self._session_path(session_id)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
logger.debug(f"Deleted session {session_id}")
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete session: {e}")
|
||||
return False
|
||||
|
||||
def list_sessions(self, limit: int = 50) -> List[ACPSession]:
|
||||
"""List all sessions, sorted by last activity."""
|
||||
sessions = []
|
||||
try:
|
||||
for path in self.storage_dir.glob("sess_*.json"):
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
sessions.append(ACPSession.from_dict(data))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Sort by last activity, most recent first
|
||||
sessions.sort(key=lambda s: s.last_activity, reverse=True)
|
||||
return sessions[:limit]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list sessions: {e}")
|
||||
return []
|
||||
|
||||
def cleanup_old_sessions(self, max_age_days: int = 30) -> int:
|
||||
"""Remove sessions older than max_age_days."""
|
||||
cutoff = time.time() - (max_age_days * 24 * 60 * 60)
|
||||
removed = 0
|
||||
|
||||
for session in self.list_sessions(limit=1000):
|
||||
if session.last_activity < cutoff:
|
||||
if self.delete(session.session_id):
|
||||
removed += 1
|
||||
|
||||
return removed
|
||||
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
PraisonAI CLI Typer Application.
|
||||
|
||||
Main Typer app that registers all command groups and handles global options.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from .output.console import OutputController, OutputMode, set_output_controller
|
||||
from .state.identifiers import create_context
|
||||
|
||||
|
||||
class OutputFormat(str, Enum):
|
||||
"""Output format options."""
|
||||
text = "text"
|
||||
json = "json"
|
||||
stream_json = "stream-json"
|
||||
|
||||
|
||||
# Create main Typer app
|
||||
app = typer.Typer(
|
||||
name="praisonai",
|
||||
help="PraisonAI - AI Agents Framework CLI",
|
||||
add_completion=False, # We handle completion manually
|
||||
no_args_is_help=False, # Allow running without args for legacy compatibility
|
||||
rich_markup_mode="rich",
|
||||
)
|
||||
|
||||
|
||||
# Global state for options
|
||||
class GlobalState:
|
||||
"""Global state for CLI options."""
|
||||
output_format: OutputFormat = OutputFormat.text
|
||||
no_color: bool = False
|
||||
quiet: bool = False
|
||||
verbose: bool = False
|
||||
screen_reader: bool = False
|
||||
output_controller: Optional[OutputController] = None
|
||||
|
||||
|
||||
state = GlobalState()
|
||||
|
||||
|
||||
def version_callback(value: bool):
|
||||
"""Show version and exit."""
|
||||
if value:
|
||||
from praisonai.version import __version__
|
||||
typer.echo(f"PraisonAI version {__version__}")
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def main_callback(
|
||||
ctx: typer.Context,
|
||||
version: bool = typer.Option(
|
||||
False,
|
||||
"--version",
|
||||
"-V",
|
||||
callback=version_callback,
|
||||
is_eager=True,
|
||||
help="Show version and exit",
|
||||
),
|
||||
output_format: OutputFormat = typer.Option(
|
||||
OutputFormat.text,
|
||||
"--output-format",
|
||||
"-o",
|
||||
help="Output format",
|
||||
envvar="PRAISONAI_OUTPUT_FORMAT",
|
||||
),
|
||||
json_output: bool = typer.Option(
|
||||
False,
|
||||
"--json",
|
||||
help="Output in JSON format (alias for --output-format json)",
|
||||
),
|
||||
no_color: bool = typer.Option(
|
||||
False,
|
||||
"--no-color",
|
||||
help="Disable colored output",
|
||||
envvar="NO_COLOR",
|
||||
),
|
||||
quiet: bool = typer.Option(
|
||||
False,
|
||||
"--quiet",
|
||||
"-q",
|
||||
help="Minimal output",
|
||||
),
|
||||
verbose: bool = typer.Option(
|
||||
False,
|
||||
"--verbose",
|
||||
"-v",
|
||||
help="Verbose output with debug details",
|
||||
),
|
||||
screen_reader: bool = typer.Option(
|
||||
False,
|
||||
"--screen-reader",
|
||||
help="Screen reader friendly output (no spinners/panels)",
|
||||
),
|
||||
):
|
||||
"""
|
||||
PraisonAI - AI Agents Framework CLI.
|
||||
|
||||
Run agents, manage configuration, and more.
|
||||
"""
|
||||
# Store global options
|
||||
state.output_format = output_format
|
||||
state.no_color = no_color
|
||||
state.quiet = quiet
|
||||
state.verbose = verbose
|
||||
state.screen_reader = screen_reader
|
||||
|
||||
# Handle --json alias
|
||||
if json_output:
|
||||
state.output_format = OutputFormat.json
|
||||
|
||||
# Determine output mode
|
||||
if state.quiet:
|
||||
mode = OutputMode.QUIET
|
||||
elif state.verbose:
|
||||
mode = OutputMode.VERBOSE
|
||||
elif state.screen_reader:
|
||||
mode = OutputMode.SCREEN_READER
|
||||
elif state.output_format == OutputFormat.json:
|
||||
mode = OutputMode.JSON
|
||||
elif state.output_format == OutputFormat.stream_json:
|
||||
mode = OutputMode.STREAM_JSON
|
||||
else:
|
||||
mode = OutputMode.TEXT
|
||||
|
||||
# Create run context
|
||||
context = create_context()
|
||||
|
||||
# Create and set output controller
|
||||
state.output_controller = OutputController(
|
||||
mode=mode,
|
||||
no_color=state.no_color,
|
||||
run_id=context.run_id,
|
||||
trace_id=context.trace_id,
|
||||
)
|
||||
set_output_controller(state.output_controller)
|
||||
|
||||
# If no command provided, show help or handle legacy behavior
|
||||
if ctx.invoked_subcommand is None:
|
||||
# Check if there are remaining args (legacy behavior)
|
||||
# This is handled by the legacy adapter in main.py
|
||||
pass
|
||||
|
||||
|
||||
def get_output_controller() -> OutputController:
|
||||
"""Get the current output controller."""
|
||||
if state.output_controller is None:
|
||||
state.output_controller = OutputController()
|
||||
return state.output_controller
|
||||
|
||||
|
||||
# Import and register command groups
|
||||
def register_commands():
|
||||
"""Register all command groups."""
|
||||
# Import command modules
|
||||
from .commands.config import app as config_app
|
||||
from .commands.traces import app as traces_app
|
||||
from .commands.environment import app as env_app
|
||||
from .commands.session import app as session_app
|
||||
from .commands.completion import app as completion_app
|
||||
from .commands.version import app as version_app
|
||||
from .commands.debug import app as debug_app
|
||||
from .commands.lsp import app as lsp_app
|
||||
from .commands.diag import app as diag_app
|
||||
from .commands.doctor import app as doctor_app
|
||||
from .commands.acp import app as acp_app
|
||||
from .commands.mcp import app as mcp_app
|
||||
from .commands.serve import app as serve_app
|
||||
from .commands.schedule import app as schedule_app
|
||||
from .commands.run import app as run_app
|
||||
|
||||
# Import TUI and queue commands
|
||||
from .features.tui.debug import create_debug_app as create_tui_debug_app
|
||||
from .features.tui.cli import create_queue_app
|
||||
|
||||
# Register sub-apps
|
||||
app.add_typer(config_app, name="config", help="Configuration management")
|
||||
app.add_typer(traces_app, name="traces", help="Trace collection management")
|
||||
app.add_typer(env_app, name="env", help="Environment and diagnostics")
|
||||
app.add_typer(session_app, name="session", help="Session management")
|
||||
app.add_typer(completion_app, name="completion", help="Shell completion scripts")
|
||||
app.add_typer(version_app, name="version", help="Version information")
|
||||
app.add_typer(debug_app, name="debug", help="Debug and test interactive flows")
|
||||
app.add_typer(lsp_app, name="lsp", help="LSP service lifecycle")
|
||||
app.add_typer(diag_app, name="diag", help="Diagnostics export")
|
||||
app.add_typer(doctor_app, name="doctor", help="Health checks and diagnostics")
|
||||
app.add_typer(acp_app, name="acp", help="Agent Client Protocol server")
|
||||
app.add_typer(mcp_app, name="mcp", help="MCP server management")
|
||||
app.add_typer(serve_app, name="serve", help="API server management")
|
||||
app.add_typer(schedule_app, name="schedule", help="Scheduler management")
|
||||
app.add_typer(run_app, name="run", help="Run agents")
|
||||
|
||||
# Register TUI and queue commands
|
||||
tui_app = create_tui_debug_app()
|
||||
queue_app = create_queue_app()
|
||||
if tui_app:
|
||||
app.add_typer(tui_app, name="tui", help="Interactive TUI and simulation")
|
||||
if queue_app:
|
||||
app.add_typer(queue_app, name="queue", help="Queue management")
|
||||
|
||||
|
||||
# Register commands on import
|
||||
register_commands()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
PraisonAI CLI Commands Module.
|
||||
|
||||
This module contains all Typer command groups and subcommands.
|
||||
Each file represents a command group (e.g., config, traces, session).
|
||||
"""
|
||||
|
||||
# Lazy imports to avoid loading all commands at startup
|
||||
__all__ = [
|
||||
'run_app',
|
||||
'config_app',
|
||||
'traces_app',
|
||||
'env_app',
|
||||
'session_app',
|
||||
'schedule_app',
|
||||
'serve_app',
|
||||
'completion_app',
|
||||
'version_app',
|
||||
'debug_app',
|
||||
'lsp_app',
|
||||
'diag_app',
|
||||
'doctor_app',
|
||||
'acp_app',
|
||||
'mcp_app',
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load command apps."""
|
||||
if name == 'run_app':
|
||||
from .run import app as run_app
|
||||
return run_app
|
||||
elif name == 'config_app':
|
||||
from .config import app as config_app
|
||||
return config_app
|
||||
elif name == 'traces_app':
|
||||
from .traces import app as traces_app
|
||||
return traces_app
|
||||
elif name == 'env_app':
|
||||
from .environment import app as env_app
|
||||
return env_app
|
||||
elif name == 'session_app':
|
||||
from .session import app as session_app
|
||||
return session_app
|
||||
elif name == 'schedule_app':
|
||||
from .schedule import app as schedule_app
|
||||
return schedule_app
|
||||
elif name == 'serve_app':
|
||||
from .serve import app as serve_app
|
||||
return serve_app
|
||||
elif name == 'completion_app':
|
||||
from .completion import app as completion_app
|
||||
return completion_app
|
||||
elif name == 'version_app':
|
||||
from .version import app as version_app
|
||||
return version_app
|
||||
elif name == 'debug_app':
|
||||
from .debug import app as debug_app
|
||||
return debug_app
|
||||
elif name == 'lsp_app':
|
||||
from .lsp import app as lsp_app
|
||||
return lsp_app
|
||||
elif name == 'diag_app':
|
||||
from .diag import app as diag_app
|
||||
return diag_app
|
||||
elif name == 'doctor_app':
|
||||
from .doctor import app as doctor_app
|
||||
return doctor_app
|
||||
elif name == 'acp_app':
|
||||
from .acp import app as acp_app
|
||||
return acp_app
|
||||
elif name == 'mcp_app':
|
||||
from .mcp import app as mcp_app
|
||||
return mcp_app
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
ACP command group for PraisonAI CLI.
|
||||
|
||||
Wraps existing ACP functionality from features/acp.py.
|
||||
Provides Agent Client Protocol server for IDE integration.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from ..output.console import get_output_controller
|
||||
|
||||
app = typer.Typer(help="Agent Client Protocol server")
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def acp_main(
|
||||
ctx: typer.Context,
|
||||
workspace: str = typer.Option(".", "--workspace", "-w", help="Workspace root directory"),
|
||||
agent: str = typer.Option("default", "--agent", "-a", help="Agent name or configuration file"),
|
||||
agents_file: Optional[str] = typer.Option(None, "--agents", help="Multi-agent configuration YAML file"),
|
||||
router: bool = typer.Option(False, "--router", help="Enable router agent for task delegation"),
|
||||
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
|
||||
resume: Optional[str] = typer.Option(None, "--resume", "-r", help="Resume session by ID"),
|
||||
last: bool = typer.Option(False, "--last", help="Resume the last session"),
|
||||
approve: str = typer.Option("manual", "--approve", help="Approval mode: manual, auto, scoped"),
|
||||
read_only: bool = typer.Option(True, "--read-only/--allow-write", help="Read-only mode"),
|
||||
allow_shell: bool = typer.Option(False, "--allow-shell", help="Allow shell command execution"),
|
||||
allow_network: bool = typer.Option(False, "--allow-network", help="Allow network requests"),
|
||||
debug: bool = typer.Option(False, "--debug", help="Enable debug logging"),
|
||||
profile: Optional[str] = typer.Option(None, "--profile", help="Use named profile from config"),
|
||||
):
|
||||
"""Start ACP server for IDE integration."""
|
||||
# Build args for existing handler
|
||||
args = [
|
||||
"--workspace", workspace,
|
||||
"--agent", agent,
|
||||
"--approve", approve,
|
||||
]
|
||||
|
||||
if agents_file:
|
||||
args.extend(["--agents", agents_file])
|
||||
if router:
|
||||
args.append("--router")
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
if resume:
|
||||
args.extend(["--resume", resume])
|
||||
if last:
|
||||
args.append("--last")
|
||||
if not read_only:
|
||||
args.append("--allow-write")
|
||||
if allow_shell:
|
||||
args.append("--allow-shell")
|
||||
if allow_network:
|
||||
args.append("--allow-network")
|
||||
if debug:
|
||||
args.append("--debug")
|
||||
if profile:
|
||||
args.extend(["--profile", profile])
|
||||
|
||||
try:
|
||||
from ..features.acp import run_acp_command
|
||||
exit_code = run_acp_command(args)
|
||||
raise typer.Exit(exit_code)
|
||||
except ImportError as e:
|
||||
output = get_output_controller()
|
||||
output.print_error(f"ACP module not available: {e}")
|
||||
raise typer.Exit(4)
|
||||
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
Completion command group for PraisonAI CLI.
|
||||
|
||||
Provides shell completion script generation:
|
||||
- completion bash: Generate bash completions
|
||||
- completion zsh: Generate zsh completions
|
||||
- completion fish: Generate fish completions
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
from ..output.console import get_output_controller
|
||||
|
||||
app = typer.Typer(help="Shell completion scripts")
|
||||
|
||||
|
||||
BASH_COMPLETION = '''
|
||||
# PraisonAI bash completion
|
||||
_praisonai_completion() {
|
||||
local cur prev opts
|
||||
COMPREPLY=()
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
# Top-level commands
|
||||
opts="run config traces env session schedule serve completion version debug lsp diag doctor acp mcp chat code call realtime train ui context research memory rules workflow hooks knowledge tools todo docs commit skills profile eval templates recipe endpoints agents thinking compaction output deploy registry package install uninstall"
|
||||
|
||||
# Subcommands for specific commands
|
||||
case "${prev}" in
|
||||
config)
|
||||
COMPREPLY=( $(compgen -W "list get set reset path" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
traces)
|
||||
COMPREPLY=( $(compgen -W "enable disable status list" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
env)
|
||||
COMPREPLY=( $(compgen -W "view check doctor" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
session)
|
||||
COMPREPLY=( $(compgen -W "list resume delete export show" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
schedule)
|
||||
COMPREPLY=( $(compgen -W "start stop list logs restart delete describe save stop-all stats" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
serve)
|
||||
COMPREPLY=( $(compgen -W "start stop status" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
completion)
|
||||
COMPREPLY=( $(compgen -W "bash zsh fish" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
debug)
|
||||
COMPREPLY=( $(compgen -W "interactive lsp acp trace" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
lsp)
|
||||
COMPREPLY=( $(compgen -W "start stop status logs" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
diag)
|
||||
COMPREPLY=( $(compgen -W "export" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
doctor)
|
||||
COMPREPLY=( $(compgen -W "env config tools db mcp obs skills memory permissions network performance ci selftest" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
mcp)
|
||||
COMPREPLY=( $(compgen -W "list add remove test" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
|
||||
# Global options
|
||||
if [[ ${cur} == -* ]] ; then
|
||||
COMPREPLY=( $(compgen -W "--help --version --output-format --json --no-color --quiet --verbose --screen-reader" -- ${cur}) )
|
||||
return 0
|
||||
fi
|
||||
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
|
||||
return 0
|
||||
}
|
||||
|
||||
complete -F _praisonai_completion praisonai
|
||||
'''
|
||||
|
||||
ZSH_COMPLETION = '''
|
||||
#compdef praisonai
|
||||
|
||||
_praisonai() {
|
||||
local -a commands
|
||||
local -a subcommands
|
||||
|
||||
commands=(
|
||||
'run:Run agents'
|
||||
'config:Configuration management'
|
||||
'traces:Trace collection management'
|
||||
'env:Environment and diagnostics'
|
||||
'session:Session management'
|
||||
'schedule:Scheduler management'
|
||||
'serve:API server management'
|
||||
'completion:Shell completion scripts'
|
||||
'version:Version information'
|
||||
'debug:Debug and test interactive flows'
|
||||
'lsp:LSP service lifecycle'
|
||||
'diag:Diagnostics export'
|
||||
'doctor:Health checks and diagnostics'
|
||||
'acp:Agent Client Protocol server'
|
||||
'mcp:MCP server management'
|
||||
'chat:Start chat UI'
|
||||
'code:Start code UI'
|
||||
'call:Start call server'
|
||||
'realtime:Start realtime interface'
|
||||
'train:Training commands'
|
||||
'ui:Start UI'
|
||||
'context:Context engineering'
|
||||
'research:Deep research'
|
||||
'memory:Memory management'
|
||||
'rules:Rules management'
|
||||
'workflow:Workflow management'
|
||||
'hooks:Hooks management'
|
||||
'knowledge:Knowledge management'
|
||||
'tools:Tools management'
|
||||
'todo:Todo management'
|
||||
'docs:Documentation'
|
||||
'commit:Git commit'
|
||||
'skills:Skills management'
|
||||
'profile:Profile management'
|
||||
'eval:Evaluation'
|
||||
'templates:Templates'
|
||||
'recipe:Recipe management'
|
||||
'endpoints:Endpoints'
|
||||
'agents:Agents management'
|
||||
'deploy:Deployment'
|
||||
'registry:Registry'
|
||||
'package:Package management'
|
||||
'install:Install packages'
|
||||
'uninstall:Uninstall packages'
|
||||
)
|
||||
|
||||
_arguments -C \\
|
||||
'--help[Show help]' \\
|
||||
'--version[Show version]' \\
|
||||
'--output-format[Output format]:format:(text json stream-json)' \\
|
||||
'--json[JSON output]' \\
|
||||
'--no-color[Disable colors]' \\
|
||||
'--quiet[Minimal output]' \\
|
||||
'--verbose[Verbose output]' \\
|
||||
'--screen-reader[Screen reader mode]' \\
|
||||
'1: :->command' \\
|
||||
'*::arg:->args'
|
||||
|
||||
case $state in
|
||||
command)
|
||||
_describe 'command' commands
|
||||
;;
|
||||
args)
|
||||
case $words[1] in
|
||||
config)
|
||||
subcommands=('list:List config' 'get:Get value' 'set:Set value' 'reset:Reset config' 'path:Show path')
|
||||
_describe 'subcommand' subcommands
|
||||
;;
|
||||
traces)
|
||||
subcommands=('enable:Enable traces' 'disable:Disable traces' 'status:Show status' 'list:List traces')
|
||||
_describe 'subcommand' subcommands
|
||||
;;
|
||||
env)
|
||||
subcommands=('view:View env vars' 'check:Check API keys' 'doctor:Run diagnostics')
|
||||
_describe 'subcommand' subcommands
|
||||
;;
|
||||
session)
|
||||
subcommands=('list:List sessions' 'resume:Resume session' 'delete:Delete session' 'export:Export session' 'show:Show session')
|
||||
_describe 'subcommand' subcommands
|
||||
;;
|
||||
schedule)
|
||||
subcommands=('start:Start scheduler' 'stop:Stop scheduler' 'list:List jobs' 'logs:View logs' 'restart:Restart' 'delete:Delete job' 'describe:Describe job' 'save:Save config' 'stop-all:Stop all' 'stats:Show stats')
|
||||
_describe 'subcommand' subcommands
|
||||
;;
|
||||
serve)
|
||||
subcommands=('start:Start server' 'stop:Stop server' 'status:Show status')
|
||||
_describe 'subcommand' subcommands
|
||||
;;
|
||||
completion)
|
||||
subcommands=('bash:Bash completion' 'zsh:Zsh completion' 'fish:Fish completion')
|
||||
_describe 'subcommand' subcommands
|
||||
;;
|
||||
debug)
|
||||
subcommands=('interactive:Interactive debug' 'lsp:LSP debug' 'acp:ACP debug' 'trace:Trace debug')
|
||||
_describe 'subcommand' subcommands
|
||||
;;
|
||||
lsp)
|
||||
subcommands=('start:Start LSP' 'stop:Stop LSP' 'status:LSP status' 'logs:LSP logs')
|
||||
_describe 'subcommand' subcommands
|
||||
;;
|
||||
diag)
|
||||
subcommands=('export:Export diagnostics')
|
||||
_describe 'subcommand' subcommands
|
||||
;;
|
||||
doctor)
|
||||
subcommands=('env:Check env' 'config:Check config' 'tools:Check tools' 'db:Check DB' 'mcp:Check MCP' 'obs:Check observability' 'skills:Check skills' 'memory:Check memory' 'permissions:Check permissions' 'network:Check network' 'performance:Check performance' 'ci:CI checks' 'selftest:Self test')
|
||||
_describe 'subcommand' subcommands
|
||||
;;
|
||||
mcp)
|
||||
subcommands=('list:List servers' 'add:Add server' 'remove:Remove server' 'test:Test server')
|
||||
_describe 'subcommand' subcommands
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
_praisonai "$@"
|
||||
'''
|
||||
|
||||
FISH_COMPLETION = '''
|
||||
# PraisonAI fish completion
|
||||
|
||||
# Top-level commands
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "run" -d "Run agents"
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "config" -d "Configuration management"
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "traces" -d "Trace collection management"
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "env" -d "Environment and diagnostics"
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "session" -d "Session management"
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "schedule" -d "Scheduler management"
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "serve" -d "API server management"
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "completion" -d "Shell completion scripts"
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "version" -d "Version information"
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "debug" -d "Debug and test interactive flows"
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "lsp" -d "LSP service lifecycle"
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "diag" -d "Diagnostics export"
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "doctor" -d "Health checks and diagnostics"
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "acp" -d "Agent Client Protocol server"
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "mcp" -d "MCP server management"
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "chat" -d "Start chat UI"
|
||||
complete -c praisonai -f -n "__fish_use_subcommand" -a "code" -d "Start code UI"
|
||||
|
||||
# Global options
|
||||
complete -c praisonai -l help -d "Show help"
|
||||
complete -c praisonai -l version -d "Show version"
|
||||
complete -c praisonai -l output-format -x -a "text json stream-json" -d "Output format"
|
||||
complete -c praisonai -l json -d "JSON output"
|
||||
complete -c praisonai -l no-color -d "Disable colors"
|
||||
complete -c praisonai -l quiet -s q -d "Minimal output"
|
||||
complete -c praisonai -l verbose -s v -d "Verbose output"
|
||||
complete -c praisonai -l screen-reader -d "Screen reader mode"
|
||||
|
||||
# config subcommands
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from config" -a "list" -d "List config"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from config" -a "get" -d "Get value"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from config" -a "set" -d "Set value"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from config" -a "reset" -d "Reset config"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from config" -a "path" -d "Show path"
|
||||
|
||||
# traces subcommands
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from traces" -a "enable" -d "Enable traces"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from traces" -a "disable" -d "Disable traces"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from traces" -a "status" -d "Show status"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from traces" -a "list" -d "List traces"
|
||||
|
||||
# env subcommands
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from env" -a "view" -d "View env vars"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from env" -a "check" -d "Check API keys"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from env" -a "doctor" -d "Run diagnostics"
|
||||
|
||||
# session subcommands
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from session" -a "list" -d "List sessions"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from session" -a "resume" -d "Resume session"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from session" -a "delete" -d "Delete session"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from session" -a "export" -d "Export session"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from session" -a "show" -d "Show session"
|
||||
|
||||
# completion subcommands
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from completion" -a "bash" -d "Bash completion"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from completion" -a "zsh" -d "Zsh completion"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from completion" -a "fish" -d "Fish completion"
|
||||
|
||||
# debug subcommands
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from debug" -a "interactive" -d "Interactive debug"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from debug" -a "lsp" -d "LSP debug"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from debug" -a "acp" -d "ACP debug"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from debug" -a "trace" -d "Trace debug"
|
||||
|
||||
# doctor subcommands
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from doctor" -a "env" -d "Check env"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from doctor" -a "config" -d "Check config"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from doctor" -a "tools" -d "Check tools"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from doctor" -a "db" -d "Check DB"
|
||||
complete -c praisonai -f -n "__fish_seen_subcommand_from doctor" -a "mcp" -d "Check MCP"
|
||||
'''
|
||||
|
||||
|
||||
@app.command("bash")
|
||||
def completion_bash():
|
||||
"""Generate bash completion script."""
|
||||
print(BASH_COMPLETION.strip())
|
||||
|
||||
|
||||
@app.command("zsh")
|
||||
def completion_zsh():
|
||||
"""Generate zsh completion script."""
|
||||
print(ZSH_COMPLETION.strip())
|
||||
|
||||
|
||||
@app.command("fish")
|
||||
def completion_fish():
|
||||
"""Generate fish completion script."""
|
||||
print(FISH_COMPLETION.strip())
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def completion_callback(ctx: typer.Context):
|
||||
"""Show completion installation instructions."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
output = get_output_controller()
|
||||
output.print_panel(
|
||||
"Generate shell completion scripts.\n\n"
|
||||
"Usage:\n"
|
||||
" # Bash\n"
|
||||
" praisonai completion bash >> ~/.bashrc\n\n"
|
||||
" # Zsh\n"
|
||||
" praisonai completion zsh > ~/.zsh/completions/_praisonai\n\n"
|
||||
" # Fish\n"
|
||||
" praisonai completion fish > ~/.config/fish/completions/praisonai.fish",
|
||||
title="Shell Completions"
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Config command group for PraisonAI CLI.
|
||||
|
||||
Provides configuration management commands:
|
||||
- config list: Show all configuration
|
||||
- config get: Get a specific value
|
||||
- config set: Set a configuration value
|
||||
- config reset: Reset to defaults
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from ..configuration.loader import get_config_loader
|
||||
from ..output.console import get_output_controller
|
||||
|
||||
app = typer.Typer(help="Configuration management")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def config_list(
|
||||
scope: str = typer.Option(
|
||||
"all",
|
||||
"--scope",
|
||||
"-s",
|
||||
help="Scope to list: all, user, project",
|
||||
),
|
||||
):
|
||||
"""List all configuration values."""
|
||||
output = get_output_controller()
|
||||
loader = get_config_loader()
|
||||
|
||||
config = loader.list_all()
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json(config)
|
||||
return
|
||||
|
||||
output.print_panel("Configuration", title="PraisonAI Config")
|
||||
|
||||
def print_dict(d, prefix=""):
|
||||
for key, value in d.items():
|
||||
full_key = f"{prefix}.{key}" if prefix else key
|
||||
if isinstance(value, dict):
|
||||
print_dict(value, full_key)
|
||||
else:
|
||||
output.print(f" {full_key} = {value}")
|
||||
|
||||
print_dict(config)
|
||||
|
||||
|
||||
@app.command("get")
|
||||
def config_get(
|
||||
key: str = typer.Argument(..., help="Configuration key (dotted notation, e.g., output.format)"),
|
||||
):
|
||||
"""Get a configuration value."""
|
||||
output = get_output_controller()
|
||||
loader = get_config_loader()
|
||||
|
||||
value = loader.get(key)
|
||||
|
||||
if value is None:
|
||||
output.print_error(f"Key not found: {key}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({"key": key, "value": value})
|
||||
else:
|
||||
output.print(f"{key} = {value}")
|
||||
|
||||
|
||||
@app.command("set")
|
||||
def config_set(
|
||||
key: str = typer.Argument(..., help="Configuration key (dotted notation)"),
|
||||
value: str = typer.Argument(..., help="Value to set"),
|
||||
scope: str = typer.Option(
|
||||
"user",
|
||||
"--scope",
|
||||
"-s",
|
||||
help="Scope: user or project",
|
||||
),
|
||||
):
|
||||
"""Set a configuration value."""
|
||||
output = get_output_controller()
|
||||
loader = get_config_loader()
|
||||
|
||||
# Parse value
|
||||
if value.lower() == "true":
|
||||
parsed_value = True
|
||||
elif value.lower() == "false":
|
||||
parsed_value = False
|
||||
else:
|
||||
try:
|
||||
parsed_value = int(value)
|
||||
except ValueError:
|
||||
try:
|
||||
parsed_value = float(value)
|
||||
except ValueError:
|
||||
parsed_value = value
|
||||
|
||||
loader.set(key, parsed_value, scope=scope)
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({"key": key, "value": parsed_value, "scope": scope})
|
||||
else:
|
||||
output.print_success(f"Set {key} = {parsed_value} ({scope})")
|
||||
|
||||
|
||||
@app.command("reset")
|
||||
def config_reset(
|
||||
scope: str = typer.Option(
|
||||
"user",
|
||||
"--scope",
|
||||
"-s",
|
||||
help="Scope to reset: user or project",
|
||||
),
|
||||
confirm: bool = typer.Option(
|
||||
False,
|
||||
"--yes",
|
||||
"-y",
|
||||
help="Skip confirmation",
|
||||
),
|
||||
):
|
||||
"""Reset configuration to defaults."""
|
||||
output = get_output_controller()
|
||||
|
||||
if not confirm:
|
||||
confirmed = typer.confirm(f"Reset {scope} configuration to defaults?")
|
||||
if not confirmed:
|
||||
output.print_info("Cancelled")
|
||||
raise typer.Exit(0)
|
||||
|
||||
loader = get_config_loader()
|
||||
loader.reset(scope=scope)
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({"reset": True, "scope": scope})
|
||||
else:
|
||||
output.print_success(f"Reset {scope} configuration to defaults")
|
||||
|
||||
|
||||
@app.command("path")
|
||||
def config_path(
|
||||
scope: str = typer.Option(
|
||||
"user",
|
||||
"--scope",
|
||||
"-s",
|
||||
help="Scope: user or project",
|
||||
),
|
||||
):
|
||||
"""Show configuration file path."""
|
||||
output = get_output_controller()
|
||||
|
||||
from ..configuration.paths import get_user_config_path, get_project_config_path
|
||||
|
||||
if scope == "project":
|
||||
path = get_project_config_path()
|
||||
else:
|
||||
path = get_user_config_path()
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({"path": str(path), "exists": path.exists()})
|
||||
else:
|
||||
exists = "✓" if path.exists() else "✗"
|
||||
output.print(f"{path} [{exists}]")
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Debug command group for PraisonAI CLI.
|
||||
|
||||
Wraps existing debug functionality from features/debug.py.
|
||||
Provides debug commands for testing interactive flows.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import List, Optional
|
||||
|
||||
import typer
|
||||
|
||||
from ..output.console import get_output_controller
|
||||
|
||||
app = typer.Typer(help="Debug and test interactive flows")
|
||||
|
||||
|
||||
@app.command("interactive")
|
||||
def debug_interactive(
|
||||
prompt: str = typer.Option(..., "--prompt", "-p", help="Prompt to execute"),
|
||||
workspace: str = typer.Option(".", "--workspace", "-w", help="Workspace root directory"),
|
||||
lsp: bool = typer.Option(True, "--lsp/--no-lsp", help="Enable/disable LSP"),
|
||||
acp: bool = typer.Option(True, "--acp/--no-acp", help="Enable/disable ACP"),
|
||||
approval: str = typer.Option("auto", "--approval", help="Approval mode: manual, auto, scoped"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON trace"),
|
||||
trace_file: Optional[str] = typer.Option(None, "--trace-file", help="Save trace to file"),
|
||||
timeout: float = typer.Option(60.0, "--timeout", help="Timeout in seconds"),
|
||||
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
|
||||
):
|
||||
"""Run single interactive turn non-interactively."""
|
||||
output = get_output_controller()
|
||||
|
||||
# Build args for existing handler
|
||||
args = [
|
||||
"interactive",
|
||||
"--prompt", prompt,
|
||||
"--workspace", workspace,
|
||||
"--approval", approval,
|
||||
"--timeout", str(timeout),
|
||||
]
|
||||
|
||||
if not lsp:
|
||||
args.append("--no-lsp")
|
||||
if not acp:
|
||||
args.append("--no-acp")
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
if trace_file:
|
||||
args.extend(["--trace-file", trace_file])
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
|
||||
# Delegate to existing handler
|
||||
try:
|
||||
from ..features.debug import run_debug_command
|
||||
exit_code = run_debug_command(args)
|
||||
raise typer.Exit(exit_code)
|
||||
except ImportError as e:
|
||||
output.print_error(f"Debug module not available: {e}")
|
||||
raise typer.Exit(4)
|
||||
|
||||
|
||||
@app.command("lsp")
|
||||
def debug_lsp(
|
||||
workspace: str = typer.Option(".", "--workspace", "-w", help="Workspace root directory"),
|
||||
language: Optional[str] = typer.Option(None, "--language", "-l", help="Language to probe"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Direct LSP probes."""
|
||||
args = ["lsp", "--workspace", workspace]
|
||||
if language:
|
||||
args.extend(["--language", language])
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
|
||||
try:
|
||||
from ..features.debug import run_debug_command
|
||||
exit_code = run_debug_command(args)
|
||||
raise typer.Exit(exit_code)
|
||||
except ImportError as e:
|
||||
output = get_output_controller()
|
||||
output.print_error(f"Debug module not available: {e}")
|
||||
raise typer.Exit(4)
|
||||
|
||||
|
||||
@app.command("acp")
|
||||
def debug_acp(
|
||||
workspace: str = typer.Option(".", "--workspace", "-w", help="Workspace root directory"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Direct ACP probes."""
|
||||
args = ["acp", "--workspace", workspace]
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
|
||||
try:
|
||||
from ..features.debug import run_debug_command
|
||||
exit_code = run_debug_command(args)
|
||||
raise typer.Exit(exit_code)
|
||||
except ImportError as e:
|
||||
output = get_output_controller()
|
||||
output.print_error(f"Debug module not available: {e}")
|
||||
raise typer.Exit(4)
|
||||
|
||||
|
||||
@app.command("trace")
|
||||
def debug_trace(
|
||||
action: str = typer.Argument("list", help="Action: record, replay, diff, list"),
|
||||
trace_id: Optional[str] = typer.Option(None, "--trace-id", "-t", help="Trace ID"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Trace record/replay/diff."""
|
||||
args = ["trace", action]
|
||||
if trace_id:
|
||||
args.extend(["--trace-id", trace_id])
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
|
||||
try:
|
||||
from ..features.debug import run_debug_command
|
||||
exit_code = run_debug_command(args)
|
||||
raise typer.Exit(exit_code)
|
||||
except ImportError as e:
|
||||
output = get_output_controller()
|
||||
output.print_error(f"Debug module not available: {e}")
|
||||
raise typer.Exit(4)
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def debug_callback(ctx: typer.Context):
|
||||
"""Show debug help."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
output = get_output_controller()
|
||||
output.print_panel(
|
||||
"Debug commands for testing interactive coding assistant flows.\n\n"
|
||||
"Commands:\n"
|
||||
" interactive Run single interactive turn\n"
|
||||
" lsp Direct LSP probes\n"
|
||||
" acp Direct ACP probes\n"
|
||||
" trace Trace record/replay/diff",
|
||||
title="Debug Commands"
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Diag command group for PraisonAI CLI.
|
||||
|
||||
Wraps existing diagnostics functionality from features/diag.py.
|
||||
Provides diagnostic export for bug reports.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from ..output.console import get_output_controller
|
||||
|
||||
app = typer.Typer(help="Diagnostics export")
|
||||
|
||||
|
||||
@app.command("export")
|
||||
def diag_export(
|
||||
output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output path"),
|
||||
workspace: str = typer.Option(".", "--workspace", "-w", help="Workspace root directory"),
|
||||
include_logs: bool = typer.Option(True, "--include-logs/--no-logs", help="Include log files"),
|
||||
include_config: bool = typer.Option(True, "--include-config/--no-config", help="Include configuration"),
|
||||
include_trace: bool = typer.Option(True, "--include-trace/--no-trace", help="Include recent traces"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Export diagnostic bundle for bug reports."""
|
||||
args = ["export", "--workspace", workspace]
|
||||
|
||||
if output_path:
|
||||
args.extend(["--output", output_path])
|
||||
if not include_logs:
|
||||
args.append("--no-logs")
|
||||
if not include_config:
|
||||
args.append("--no-config")
|
||||
if not include_trace:
|
||||
args.append("--no-trace")
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
|
||||
try:
|
||||
from ..features.diag import run_diag_command
|
||||
exit_code = run_diag_command(args)
|
||||
raise typer.Exit(exit_code)
|
||||
except ImportError as e:
|
||||
output = get_output_controller()
|
||||
output.print_error(f"Diag module not available: {e}")
|
||||
raise typer.Exit(4)
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def diag_callback(ctx: typer.Context):
|
||||
"""Show diag help or run export."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
# Default to export
|
||||
diag_export()
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Doctor command group for PraisonAI CLI.
|
||||
|
||||
Wraps existing doctor functionality from features/doctor/.
|
||||
Provides health checks and diagnostics.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from ..output.console import get_output_controller
|
||||
|
||||
app = typer.Typer(help="Health checks and diagnostics")
|
||||
|
||||
|
||||
def _run_doctor(args: list) -> int:
|
||||
"""Run doctor command with args."""
|
||||
try:
|
||||
from ..features.doctor.handler import DoctorHandler
|
||||
handler = DoctorHandler()
|
||||
# DoctorHandler uses execute(action, action_args) pattern
|
||||
action = args[0] if args else None
|
||||
action_args = args[1:] if len(args) > 1 else []
|
||||
return handler.execute(action, action_args)
|
||||
except ImportError as e:
|
||||
output = get_output_controller()
|
||||
output.print_error(f"Doctor module not available: {e}")
|
||||
return 4
|
||||
except Exception as e:
|
||||
output = get_output_controller()
|
||||
output.print_error(f"Doctor error: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
@app.command("env")
|
||||
def doctor_env(
|
||||
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
timeout: float = typer.Option(10.0, "--timeout", help="Per-check timeout"),
|
||||
):
|
||||
"""Check environment variables and API keys."""
|
||||
args = ["env"]
|
||||
if deep:
|
||||
args.append("--deep")
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
args.extend(["--timeout", str(timeout)])
|
||||
raise typer.Exit(_run_doctor(args))
|
||||
|
||||
|
||||
@app.command("config")
|
||||
def doctor_config(
|
||||
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Check configuration files."""
|
||||
args = ["config"]
|
||||
if deep:
|
||||
args.append("--deep")
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
raise typer.Exit(_run_doctor(args))
|
||||
|
||||
|
||||
@app.command("tools")
|
||||
def doctor_tools(
|
||||
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Check tools availability."""
|
||||
args = ["tools"]
|
||||
if deep:
|
||||
args.append("--deep")
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
raise typer.Exit(_run_doctor(args))
|
||||
|
||||
|
||||
@app.command("db")
|
||||
def doctor_db(
|
||||
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Check database connections."""
|
||||
args = ["db"]
|
||||
if deep:
|
||||
args.append("--deep")
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
raise typer.Exit(_run_doctor(args))
|
||||
|
||||
|
||||
@app.command("mcp")
|
||||
def doctor_mcp(
|
||||
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Check MCP servers."""
|
||||
args = ["mcp"]
|
||||
if deep:
|
||||
args.append("--deep")
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
raise typer.Exit(_run_doctor(args))
|
||||
|
||||
|
||||
@app.command("network")
|
||||
def doctor_network(
|
||||
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Check network connectivity."""
|
||||
args = ["network"]
|
||||
if deep:
|
||||
args.append("--deep")
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
raise typer.Exit(_run_doctor(args))
|
||||
|
||||
|
||||
@app.command("performance")
|
||||
def doctor_performance(
|
||||
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Check performance metrics."""
|
||||
args = ["performance"]
|
||||
if deep:
|
||||
args.append("--deep")
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
raise typer.Exit(_run_doctor(args))
|
||||
|
||||
|
||||
@app.command("selftest")
|
||||
def doctor_selftest(
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Run self-test."""
|
||||
args = ["selftest"]
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
raise typer.Exit(_run_doctor(args))
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def doctor_callback(
|
||||
ctx: typer.Context,
|
||||
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
strict: bool = typer.Option(False, "--strict", help="Treat warnings as failures"),
|
||||
quiet: bool = typer.Option(False, "--quiet", "-q", help="Minimal output"),
|
||||
):
|
||||
"""Run all fast health checks."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
args = []
|
||||
if deep:
|
||||
args.append("--deep")
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
if strict:
|
||||
args.append("--strict")
|
||||
if quiet:
|
||||
args.append("--quiet")
|
||||
raise typer.Exit(_run_doctor(args))
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Environment command group for PraisonAI CLI.
|
||||
|
||||
Provides environment management and diagnostics:
|
||||
- env view: Show environment variables
|
||||
- env check: Validate API keys
|
||||
- env doctor: Run diagnostics (alias for doctor command)
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from ..output.console import get_output_controller
|
||||
|
||||
app = typer.Typer(help="Environment and diagnostics")
|
||||
|
||||
|
||||
# Known API key patterns
|
||||
API_KEY_PATTERNS = {
|
||||
"OPENAI_API_KEY": {"prefix": "sk-", "min_length": 20},
|
||||
"ANTHROPIC_API_KEY": {"prefix": "sk-ant-", "min_length": 20},
|
||||
"GOOGLE_API_KEY": {"prefix": "AI", "min_length": 20},
|
||||
"GEMINI_API_KEY": {"prefix": "AI", "min_length": 20},
|
||||
"TAVILY_API_KEY": {"prefix": "tvly-", "min_length": 20},
|
||||
"GROQ_API_KEY": {"prefix": "gsk_", "min_length": 20},
|
||||
}
|
||||
|
||||
|
||||
def _redact_key(value: str, show_chars: int = 4) -> str:
|
||||
"""Redact an API key for display."""
|
||||
if len(value) <= show_chars * 2:
|
||||
return "*" * len(value)
|
||||
return value[:show_chars] + "*" * (len(value) - show_chars * 2) + value[-show_chars:]
|
||||
|
||||
|
||||
def _validate_key(name: str, value: str) -> tuple[bool, str]:
|
||||
"""Validate an API key format."""
|
||||
if name not in API_KEY_PATTERNS:
|
||||
return True, "Unknown key type"
|
||||
|
||||
pattern = API_KEY_PATTERNS[name]
|
||||
|
||||
if len(value) < pattern["min_length"]:
|
||||
return False, f"Too short (expected >= {pattern['min_length']} chars)"
|
||||
|
||||
if pattern.get("prefix") and not value.startswith(pattern["prefix"]):
|
||||
return False, f"Invalid prefix (expected {pattern['prefix']}...)"
|
||||
|
||||
return True, "Valid format"
|
||||
|
||||
|
||||
@app.command("view")
|
||||
def env_view(
|
||||
show_values: bool = typer.Option(
|
||||
False,
|
||||
"--show-values",
|
||||
help="Show actual values (redacted by default)",
|
||||
),
|
||||
filter_prefix: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--filter",
|
||||
"-f",
|
||||
help="Filter by prefix (e.g., OPENAI, PRAISONAI)",
|
||||
),
|
||||
):
|
||||
"""Show relevant environment variables."""
|
||||
output = get_output_controller()
|
||||
|
||||
# Relevant prefixes
|
||||
prefixes = ["OPENAI", "ANTHROPIC", "GOOGLE", "GEMINI", "TAVILY", "GROQ",
|
||||
"PRAISONAI", "MODEL", "LLM", "OLLAMA", "AZURE"]
|
||||
|
||||
if filter_prefix:
|
||||
prefixes = [filter_prefix.upper()]
|
||||
|
||||
env_vars = {}
|
||||
for key, value in os.environ.items():
|
||||
for prefix in prefixes:
|
||||
if key.upper().startswith(prefix):
|
||||
if show_values:
|
||||
if "KEY" in key.upper() or "SECRET" in key.upper() or "TOKEN" in key.upper():
|
||||
env_vars[key] = _redact_key(value)
|
||||
else:
|
||||
env_vars[key] = value
|
||||
else:
|
||||
env_vars[key] = "***" if value else "(empty)"
|
||||
break
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({"environment": env_vars})
|
||||
return
|
||||
|
||||
if not env_vars:
|
||||
output.print_info("No relevant environment variables found")
|
||||
return
|
||||
|
||||
headers = ["Variable", "Value"]
|
||||
rows = [[k, v] for k, v in sorted(env_vars.items())]
|
||||
output.print_table(headers, rows, title="Environment Variables")
|
||||
|
||||
|
||||
@app.command("check")
|
||||
def env_check():
|
||||
"""Validate API keys and configuration."""
|
||||
output = get_output_controller()
|
||||
|
||||
results = []
|
||||
all_valid = True
|
||||
|
||||
for key_name, pattern in API_KEY_PATTERNS.items():
|
||||
value = os.environ.get(key_name)
|
||||
|
||||
if value:
|
||||
valid, message = _validate_key(key_name, value)
|
||||
results.append({
|
||||
"key": key_name,
|
||||
"present": True,
|
||||
"valid": valid,
|
||||
"message": message,
|
||||
})
|
||||
if not valid:
|
||||
all_valid = False
|
||||
else:
|
||||
results.append({
|
||||
"key": key_name,
|
||||
"present": False,
|
||||
"valid": False,
|
||||
"message": "Not set",
|
||||
})
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({"checks": results, "all_valid": all_valid})
|
||||
return
|
||||
|
||||
headers = ["Key", "Status", "Message"]
|
||||
rows = []
|
||||
for r in results:
|
||||
if r["present"]:
|
||||
status = "✅" if r["valid"] else "⚠️"
|
||||
else:
|
||||
status = "❌"
|
||||
rows.append([r["key"], status, r["message"]])
|
||||
|
||||
output.print_table(headers, rows, title="API Key Validation")
|
||||
|
||||
if not all_valid:
|
||||
output.print_warning("Some API keys are missing or invalid")
|
||||
raise typer.Exit(3) # Exit code 3 = missing config/env
|
||||
|
||||
|
||||
@app.command("doctor")
|
||||
def env_doctor(
|
||||
deep: bool = typer.Option(
|
||||
False,
|
||||
"--deep",
|
||||
help="Run deep checks (network, DB connections)",
|
||||
),
|
||||
):
|
||||
"""Run environment diagnostics (alias for 'praisonai doctor env')."""
|
||||
output = get_output_controller()
|
||||
|
||||
# Delegate to doctor command
|
||||
try:
|
||||
from ..features.doctor.handler import DoctorHandler
|
||||
handler = DoctorHandler()
|
||||
|
||||
# Run env checks
|
||||
if output.is_json_mode:
|
||||
# Run and get JSON output
|
||||
result = handler.handle(["env", "--json"] if not deep else ["env", "--json", "--deep"])
|
||||
else:
|
||||
result = handler.handle(["env"] if not deep else ["env", "--deep"])
|
||||
|
||||
raise typer.Exit(result)
|
||||
except ImportError:
|
||||
# Fallback to basic env check
|
||||
env_check()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
LSP command group for PraisonAI CLI.
|
||||
|
||||
Wraps existing LSP functionality from features/lsp_cli.py.
|
||||
Provides LSP service lifecycle management.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from ..output.console import get_output_controller
|
||||
|
||||
app = typer.Typer(help="LSP service lifecycle management")
|
||||
|
||||
|
||||
@app.command("start")
|
||||
def lsp_start(
|
||||
workspace: str = typer.Option(".", "--workspace", "-w", help="Workspace root directory"),
|
||||
language: Optional[str] = typer.Option(None, "--language", "-l", help="Language to start"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Start LSP server(s)."""
|
||||
args = ["start", "--workspace", workspace]
|
||||
if language:
|
||||
args.extend(["--language", language])
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
|
||||
try:
|
||||
from ..features.lsp_cli import run_lsp_command
|
||||
exit_code = run_lsp_command(args)
|
||||
raise typer.Exit(exit_code)
|
||||
except ImportError as e:
|
||||
output = get_output_controller()
|
||||
output.print_error(f"LSP module not available: {e}")
|
||||
raise typer.Exit(4)
|
||||
|
||||
|
||||
@app.command("stop")
|
||||
def lsp_stop(
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Stop LSP server(s)."""
|
||||
args = ["stop"]
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
|
||||
try:
|
||||
from ..features.lsp_cli import run_lsp_command
|
||||
exit_code = run_lsp_command(args)
|
||||
raise typer.Exit(exit_code)
|
||||
except ImportError as e:
|
||||
output = get_output_controller()
|
||||
output.print_error(f"LSP module not available: {e}")
|
||||
raise typer.Exit(4)
|
||||
|
||||
|
||||
@app.command("status")
|
||||
def lsp_status(
|
||||
workspace: str = typer.Option(".", "--workspace", "-w", help="Workspace root directory"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Show LSP status."""
|
||||
args = ["status", "--workspace", workspace]
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
|
||||
try:
|
||||
from ..features.lsp_cli import run_lsp_command
|
||||
exit_code = run_lsp_command(args)
|
||||
raise typer.Exit(exit_code)
|
||||
except ImportError as e:
|
||||
output = get_output_controller()
|
||||
output.print_error(f"LSP module not available: {e}")
|
||||
raise typer.Exit(4)
|
||||
|
||||
|
||||
@app.command("logs")
|
||||
def lsp_logs(
|
||||
tail: int = typer.Option(50, "--tail", "-n", help="Number of lines to show"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Show recent LSP logs."""
|
||||
args = ["logs", "--tail", str(tail)]
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
|
||||
try:
|
||||
from ..features.lsp_cli import run_lsp_command
|
||||
exit_code = run_lsp_command(args)
|
||||
raise typer.Exit(exit_code)
|
||||
except ImportError as e:
|
||||
output = get_output_controller()
|
||||
output.print_error(f"LSP module not available: {e}")
|
||||
raise typer.Exit(4)
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def lsp_callback(ctx: typer.Context):
|
||||
"""Show LSP help."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
output = get_output_controller()
|
||||
output.print_panel(
|
||||
"LSP service lifecycle management.\n\n"
|
||||
"Commands:\n"
|
||||
" start Start LSP server(s)\n"
|
||||
" stop Stop LSP server(s)\n"
|
||||
" status Show LSP status\n"
|
||||
" logs Show recent logs",
|
||||
title="LSP Commands"
|
||||
)
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
MCP command group for PraisonAI CLI.
|
||||
|
||||
Provides MCP (Model Context Protocol) server management.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from ..output.console import get_output_controller
|
||||
from ..configuration.loader import get_config_loader
|
||||
|
||||
app = typer.Typer(help="MCP server management")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def mcp_list(
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""List configured MCP servers."""
|
||||
output = get_output_controller()
|
||||
loader = get_config_loader()
|
||||
|
||||
config = loader.load()
|
||||
servers = config.mcp.servers
|
||||
|
||||
if output.is_json_mode or json_output:
|
||||
output.print_json({
|
||||
"servers": {
|
||||
name: {
|
||||
"command": s.command,
|
||||
"args": s.args,
|
||||
"enabled": s.enabled,
|
||||
}
|
||||
for name, s in servers.items()
|
||||
}
|
||||
})
|
||||
return
|
||||
|
||||
if not servers:
|
||||
output.print_info("No MCP servers configured")
|
||||
output.print("\nAdd a server with: praisonai mcp add <name> <command>")
|
||||
return
|
||||
|
||||
headers = ["Name", "Command", "Enabled"]
|
||||
rows = []
|
||||
for name, server in servers.items():
|
||||
cmd = f"{server.command} {' '.join(server.args)}"
|
||||
if len(cmd) > 50:
|
||||
cmd = cmd[:47] + "..."
|
||||
rows.append([name, cmd, "✓" if server.enabled else "✗"])
|
||||
|
||||
output.print_table(headers, rows, title="MCP Servers")
|
||||
|
||||
|
||||
@app.command("add")
|
||||
def mcp_add(
|
||||
name: str = typer.Argument(..., help="Server name"),
|
||||
command: str = typer.Argument(..., help="Command to run"),
|
||||
args: Optional[str] = typer.Option(None, "--args", "-a", help="Command arguments (space-separated)"),
|
||||
env: Optional[str] = typer.Option(None, "--env", "-e", help="Environment variables (KEY=VALUE,...)"),
|
||||
):
|
||||
"""Add an MCP server."""
|
||||
output = get_output_controller()
|
||||
loader = get_config_loader()
|
||||
|
||||
# Parse args
|
||||
arg_list = args.split() if args else []
|
||||
|
||||
# Parse env
|
||||
env_dict = {}
|
||||
if env:
|
||||
for pair in env.split(","):
|
||||
if "=" in pair:
|
||||
key, value = pair.split("=", 1)
|
||||
env_dict[key.strip()] = value.strip()
|
||||
|
||||
# Build server config
|
||||
server_config = {
|
||||
"command": command,
|
||||
"args": arg_list,
|
||||
"env": env_dict,
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
# Save to config
|
||||
loader.set(f"mcp.servers.{name}", server_config)
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({"added": name, "config": server_config})
|
||||
else:
|
||||
output.print_success(f"Added MCP server: {name}")
|
||||
|
||||
|
||||
@app.command("remove")
|
||||
def mcp_remove(
|
||||
name: str = typer.Argument(..., help="Server name to remove"),
|
||||
confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
||||
):
|
||||
"""Remove an MCP server."""
|
||||
output = get_output_controller()
|
||||
loader = get_config_loader()
|
||||
|
||||
config = loader.load()
|
||||
if name not in config.mcp.servers:
|
||||
output.print_error(f"Server not found: {name}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not confirm:
|
||||
confirmed = typer.confirm(f"Remove MCP server '{name}'?")
|
||||
if not confirmed:
|
||||
output.print_info("Cancelled")
|
||||
raise typer.Exit(0)
|
||||
|
||||
# Remove from config by setting to None (will be cleaned up)
|
||||
# For now, we need to reload and modify the raw config
|
||||
from ..configuration.paths import get_user_config_path
|
||||
from ..configuration.loader import _load_toml, _save_toml
|
||||
|
||||
config_path = get_user_config_path()
|
||||
if config_path.exists():
|
||||
raw_config = _load_toml(config_path)
|
||||
if "mcp" in raw_config and "servers" in raw_config["mcp"]:
|
||||
if name in raw_config["mcp"]["servers"]:
|
||||
del raw_config["mcp"]["servers"][name]
|
||||
_save_toml(config_path, raw_config)
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({"removed": name})
|
||||
else:
|
||||
output.print_success(f"Removed MCP server: {name}")
|
||||
|
||||
|
||||
@app.command("test")
|
||||
def mcp_test(
|
||||
name: str = typer.Argument(..., help="Server name to test"),
|
||||
timeout: float = typer.Option(10.0, "--timeout", "-t", help="Timeout in seconds"),
|
||||
):
|
||||
"""Test an MCP server connection."""
|
||||
output = get_output_controller()
|
||||
loader = get_config_loader()
|
||||
|
||||
config = loader.load()
|
||||
if name not in config.mcp.servers:
|
||||
output.print_error(f"Server not found: {name}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
server = config.mcp.servers[name]
|
||||
|
||||
output.print_info(f"Testing MCP server: {name}")
|
||||
output.print(f" Command: {server.command} {' '.join(server.args)}")
|
||||
|
||||
# Try to start the server and check if it responds
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
try:
|
||||
cmd = [server.command] + server.args
|
||||
env = dict(**server.env) if server.env else None
|
||||
|
||||
# Start process
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
|
||||
# Wait briefly and check if it's running
|
||||
time.sleep(0.5)
|
||||
|
||||
if proc.poll() is None:
|
||||
# Process is running
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({"name": name, "status": "ok", "message": "Server started successfully"})
|
||||
else:
|
||||
output.print_success(f"Server '{name}' started successfully")
|
||||
else:
|
||||
# Process exited
|
||||
stderr = proc.stderr.read().decode() if proc.stderr else ""
|
||||
if output.is_json_mode:
|
||||
output.print_json({"name": name, "status": "error", "message": stderr or "Server exited immediately"})
|
||||
else:
|
||||
output.print_error(f"Server exited immediately: {stderr}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
except FileNotFoundError:
|
||||
if output.is_json_mode:
|
||||
output.print_json({"name": name, "status": "error", "message": f"Command not found: {server.command}"})
|
||||
else:
|
||||
output.print_error(f"Command not found: {server.command}")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
if output.is_json_mode:
|
||||
output.print_json({"name": name, "status": "error", "message": str(e)})
|
||||
else:
|
||||
output.print_error(f"Test failed: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def mcp_callback(ctx: typer.Context):
|
||||
"""Show MCP help or list servers."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
mcp_list(json_output=False)
|
||||
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Run command group for PraisonAI CLI.
|
||||
|
||||
Provides agent execution commands.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from ..output.console import get_output_controller
|
||||
from ..state.identifiers import get_current_context
|
||||
|
||||
app = typer.Typer(help="Run agents")
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def run_main(
|
||||
ctx: typer.Context,
|
||||
target: Optional[str] = typer.Argument(None, help="Agent file or prompt"),
|
||||
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
|
||||
framework: Optional[str] = typer.Option(None, "--framework", "-f", help="Framework: praisonai, crewai, autogen"),
|
||||
interactive: bool = typer.Option(False, "--interactive", "-i", help="Interactive mode"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
stream: bool = typer.Option(True, "--stream/--no-stream", help="Stream output"),
|
||||
trace: bool = typer.Option(False, "--trace", help="Enable tracing"),
|
||||
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
|
||||
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Tools file path"),
|
||||
max_tokens: int = typer.Option(16000, "--max-tokens", help="Maximum output tokens"),
|
||||
):
|
||||
"""
|
||||
Run agents from a file or prompt.
|
||||
|
||||
Examples:
|
||||
praisonai run agents.yaml
|
||||
praisonai run "What is the weather?"
|
||||
praisonai run agents.yaml --interactive
|
||||
"""
|
||||
output = get_output_controller()
|
||||
context = get_current_context()
|
||||
|
||||
if not target:
|
||||
output.print_panel(
|
||||
"Run agents from a file or prompt.\n\n"
|
||||
"Usage:\n"
|
||||
" praisonai run agents.yaml\n"
|
||||
" praisonai run \"What is the weather?\"\n"
|
||||
" praisonai run agents.yaml --interactive\n\n"
|
||||
"Options:\n"
|
||||
" --model, -m LLM model to use\n"
|
||||
" --framework, -f Framework (praisonai, crewai, autogen)\n"
|
||||
" --interactive, -i Interactive mode\n"
|
||||
" --verbose, -v Verbose output\n"
|
||||
" --trace Enable tracing\n"
|
||||
" --memory Enable memory",
|
||||
title="Run Command"
|
||||
)
|
||||
return
|
||||
|
||||
# Emit start event
|
||||
output.emit_start(
|
||||
message=f"Starting run: {target[:50]}..." if len(target) > 50 else f"Starting run: {target}",
|
||||
data={
|
||||
"target": target,
|
||||
"model": model,
|
||||
"framework": framework,
|
||||
}
|
||||
)
|
||||
|
||||
# Check if target is a file or prompt
|
||||
import os
|
||||
is_file = os.path.exists(target) and (target.endswith('.yaml') or target.endswith('.yml'))
|
||||
|
||||
if is_file:
|
||||
# Run from file
|
||||
_run_from_file(
|
||||
target,
|
||||
model=model,
|
||||
framework=framework,
|
||||
interactive=interactive,
|
||||
verbose=verbose,
|
||||
stream=stream,
|
||||
trace=trace,
|
||||
memory=memory,
|
||||
tools=tools,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
else:
|
||||
# Run as prompt
|
||||
_run_prompt(
|
||||
target,
|
||||
model=model,
|
||||
verbose=verbose,
|
||||
stream=stream,
|
||||
trace=trace,
|
||||
memory=memory,
|
||||
tools=tools,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
|
||||
def _run_from_file(
|
||||
file_path: str,
|
||||
model: Optional[str] = None,
|
||||
framework: Optional[str] = None,
|
||||
interactive: bool = False,
|
||||
verbose: bool = False,
|
||||
stream: bool = True,
|
||||
trace: bool = False,
|
||||
memory: bool = False,
|
||||
tools: Optional[str] = None,
|
||||
max_tokens: int = 16000,
|
||||
):
|
||||
"""Run agents from a YAML file."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
# Use existing PraisonAI class
|
||||
from praisonai.cli.main import PraisonAI
|
||||
|
||||
praison = PraisonAI(
|
||||
agent_file=file_path,
|
||||
framework=framework or "praisonai",
|
||||
)
|
||||
|
||||
# Set model if provided
|
||||
if model:
|
||||
praison.config_list[0]['model'] = model
|
||||
|
||||
# Run
|
||||
result = praison.run()
|
||||
|
||||
output.emit_result(
|
||||
message="Run completed",
|
||||
data={"result": str(result) if result else None}
|
||||
)
|
||||
|
||||
if result:
|
||||
if not output.is_json_mode:
|
||||
output.print_success("Run completed")
|
||||
|
||||
except Exception as e:
|
||||
output.emit_error(message=str(e))
|
||||
output.print_error(str(e))
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _run_prompt(
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
verbose: bool = False,
|
||||
stream: bool = True,
|
||||
trace: bool = False,
|
||||
memory: bool = False,
|
||||
tools: Optional[str] = None,
|
||||
max_tokens: int = 16000,
|
||||
):
|
||||
"""Run a direct prompt."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
# Use existing handle_direct_prompt
|
||||
from praisonai.cli.main import PraisonAI
|
||||
|
||||
praison = PraisonAI()
|
||||
|
||||
if model:
|
||||
praison.config_list[0]['model'] = model
|
||||
|
||||
# Create args-like object for handle_direct_prompt
|
||||
class Args:
|
||||
pass
|
||||
|
||||
args = Args()
|
||||
args.llm = model
|
||||
args.verbose = verbose
|
||||
args.memory = memory
|
||||
args.tools = tools
|
||||
args.max_tokens = max_tokens
|
||||
args.web_search = False
|
||||
args.web_fetch = False
|
||||
args.prompt_caching = False
|
||||
args.planning = False
|
||||
args.planning_tools = None
|
||||
args.planning_reasoning = False
|
||||
args.auto_approve_plan = False
|
||||
args.final_agent = None
|
||||
args.user_id = None
|
||||
args.auto_save = None
|
||||
args.history = None
|
||||
args.include_rules = None
|
||||
args.workflow = None
|
||||
args.workflow_var = None
|
||||
args.claude_memory = False
|
||||
args.guardrail = None
|
||||
args.metrics = False
|
||||
args.image = None
|
||||
args.image_generate = False
|
||||
args.telemetry = False
|
||||
args.mcp = None
|
||||
args.mcp_env = None
|
||||
args.fast_context = None
|
||||
args.handoff = None
|
||||
args.auto_memory = False
|
||||
args.todo = False
|
||||
args.router = False
|
||||
args.router_provider = None
|
||||
args.query_rewrite = False
|
||||
args.rewrite_tools = None
|
||||
args.expand_prompt = False
|
||||
args.expand_tools = None
|
||||
args.no_tools = False
|
||||
|
||||
praison.args = args
|
||||
result = praison.handle_direct_prompt(prompt)
|
||||
|
||||
output.emit_result(
|
||||
message="Prompt completed",
|
||||
data={"result": str(result) if result else None}
|
||||
)
|
||||
|
||||
if result and not output.is_json_mode:
|
||||
print(result)
|
||||
|
||||
except Exception as e:
|
||||
output.emit_error(message=str(e))
|
||||
output.print_error(str(e))
|
||||
raise typer.Exit(1)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Schedule command group for PraisonAI CLI.
|
||||
|
||||
Provides scheduler management.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from ..output.console import get_output_controller
|
||||
|
||||
app = typer.Typer(help="Scheduler management")
|
||||
|
||||
|
||||
def _run_schedule(args: list) -> int:
|
||||
"""Run schedule command with args."""
|
||||
try:
|
||||
from ..features.agent_scheduler import AgentSchedulerHandler
|
||||
|
||||
# Parse subcommand
|
||||
if args and args[0] in ['start', 'list', 'stop', 'logs', 'restart', 'delete', 'describe', 'save', 'stop-all', 'stats']:
|
||||
subcommand = args[0]
|
||||
remaining = args[1:] if len(args) > 1 else []
|
||||
|
||||
# Create minimal args namespace
|
||||
class Args:
|
||||
pass
|
||||
|
||||
fake_args = Args()
|
||||
return AgentSchedulerHandler.handle_daemon_command(subcommand, fake_args, remaining)
|
||||
|
||||
return 0
|
||||
except ImportError as e:
|
||||
output = get_output_controller()
|
||||
output.print_error(f"Scheduler module not available: {e}")
|
||||
return 4
|
||||
|
||||
|
||||
@app.command("start")
|
||||
def schedule_start(
|
||||
agents_file: str = typer.Argument("agents.yaml", help="Agents YAML file"),
|
||||
interval: Optional[str] = typer.Option(None, "--interval", "-i", help="Schedule interval (e.g., 'hourly', '*/30m')"),
|
||||
daemon: bool = typer.Option(True, "--daemon/--no-daemon", help="Run as daemon"),
|
||||
name: Optional[str] = typer.Option(None, "--name", "-n", help="Job name"),
|
||||
):
|
||||
"""Start scheduled agent execution."""
|
||||
args = ["start", agents_file]
|
||||
if interval:
|
||||
args.extend(["--interval", interval])
|
||||
if not daemon:
|
||||
args.append("--no-daemon")
|
||||
if name:
|
||||
args.extend(["--name", name])
|
||||
|
||||
raise typer.Exit(_run_schedule(args))
|
||||
|
||||
|
||||
@app.command("stop")
|
||||
def schedule_stop(
|
||||
job_id: Optional[str] = typer.Argument(None, help="Job ID to stop (or 'all')"),
|
||||
):
|
||||
"""Stop scheduled job(s)."""
|
||||
if job_id == "all":
|
||||
raise typer.Exit(_run_schedule(["stop-all"]))
|
||||
elif job_id:
|
||||
raise typer.Exit(_run_schedule(["stop", job_id]))
|
||||
else:
|
||||
raise typer.Exit(_run_schedule(["stop"]))
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def schedule_list(
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""List scheduled jobs."""
|
||||
args = ["list"]
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
raise typer.Exit(_run_schedule(args))
|
||||
|
||||
|
||||
@app.command("logs")
|
||||
def schedule_logs(
|
||||
job_id: Optional[str] = typer.Argument(None, help="Job ID"),
|
||||
tail: int = typer.Option(50, "--tail", "-n", help="Number of lines"),
|
||||
follow: bool = typer.Option(False, "--follow", "-f", help="Follow log output"),
|
||||
):
|
||||
"""View scheduler logs."""
|
||||
args = ["logs"]
|
||||
if job_id:
|
||||
args.append(job_id)
|
||||
args.extend(["--tail", str(tail)])
|
||||
if follow:
|
||||
args.append("--follow")
|
||||
raise typer.Exit(_run_schedule(args))
|
||||
|
||||
|
||||
@app.command("restart")
|
||||
def schedule_restart(
|
||||
job_id: str = typer.Argument(..., help="Job ID to restart"),
|
||||
):
|
||||
"""Restart a scheduled job."""
|
||||
raise typer.Exit(_run_schedule(["restart", job_id]))
|
||||
|
||||
|
||||
@app.command("delete")
|
||||
def schedule_delete(
|
||||
job_id: str = typer.Argument(..., help="Job ID to delete"),
|
||||
confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
||||
):
|
||||
"""Delete a scheduled job."""
|
||||
if not confirm:
|
||||
confirmed = typer.confirm(f"Delete job {job_id}?")
|
||||
if not confirmed:
|
||||
output = get_output_controller()
|
||||
output.print_info("Cancelled")
|
||||
raise typer.Exit(0)
|
||||
|
||||
raise typer.Exit(_run_schedule(["delete", job_id]))
|
||||
|
||||
|
||||
@app.command("describe")
|
||||
def schedule_describe(
|
||||
job_id: str = typer.Argument(..., help="Job ID"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Show job details."""
|
||||
args = ["describe", job_id]
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
raise typer.Exit(_run_schedule(args))
|
||||
|
||||
|
||||
@app.command("stats")
|
||||
def schedule_stats(
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Show scheduler statistics."""
|
||||
args = ["stats"]
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
raise typer.Exit(_run_schedule(args))
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def schedule_callback(ctx: typer.Context):
|
||||
"""Show schedule help or list jobs."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
schedule_list(json_output=False)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Serve command group for PraisonAI CLI.
|
||||
|
||||
Provides API server management.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from ..output.console import get_output_controller
|
||||
|
||||
app = typer.Typer(help="API server management")
|
||||
|
||||
|
||||
@app.command("start")
|
||||
def serve_start(
|
||||
host: str = typer.Option("0.0.0.0", "--host", "-h", help="Host to bind to"),
|
||||
port: int = typer.Option(8000, "--port", "-p", help="Port to bind to"),
|
||||
agents_file: Optional[str] = typer.Option(None, "--agents", "-a", help="Agents YAML file"),
|
||||
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"),
|
||||
workers: int = typer.Option(1, "--workers", "-w", help="Number of workers"),
|
||||
):
|
||||
"""Start API server."""
|
||||
output = get_output_controller()
|
||||
|
||||
# Build args for existing handler
|
||||
args = [
|
||||
"start",
|
||||
"--host", host,
|
||||
"--port", str(port),
|
||||
"--workers", str(workers),
|
||||
]
|
||||
|
||||
if agents_file:
|
||||
args.extend(["--agents", agents_file])
|
||||
if reload:
|
||||
args.append("--reload")
|
||||
|
||||
try:
|
||||
from ..features.serve import handle_serve_command
|
||||
exit_code = handle_serve_command(args)
|
||||
raise typer.Exit(exit_code)
|
||||
except ImportError as e:
|
||||
output.print_error(f"Serve module not available: {e}")
|
||||
raise typer.Exit(4)
|
||||
|
||||
|
||||
@app.command("stop")
|
||||
def serve_stop():
|
||||
"""Stop API server."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
from ..features.serve import handle_serve_command
|
||||
exit_code = handle_serve_command(["stop"])
|
||||
raise typer.Exit(exit_code)
|
||||
except ImportError as e:
|
||||
output.print_error(f"Serve module not available: {e}")
|
||||
raise typer.Exit(4)
|
||||
|
||||
|
||||
@app.command("status")
|
||||
def serve_status(
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Show server status."""
|
||||
output = get_output_controller()
|
||||
|
||||
args = ["status"]
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
|
||||
try:
|
||||
from ..features.serve import handle_serve_command
|
||||
exit_code = handle_serve_command(args)
|
||||
raise typer.Exit(exit_code)
|
||||
except ImportError as e:
|
||||
output.print_error(f"Serve module not available: {e}")
|
||||
raise typer.Exit(4)
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def serve_callback(ctx: typer.Context):
|
||||
"""Show serve help or start server."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
output = get_output_controller()
|
||||
output.print_panel(
|
||||
"API server management.\n\n"
|
||||
"Commands:\n"
|
||||
" start Start API server\n"
|
||||
" stop Stop API server\n"
|
||||
" status Show server status\n\n"
|
||||
"Quick start:\n"
|
||||
" praisonai serve start --agents agents.yaml",
|
||||
title="Serve Commands"
|
||||
)
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Session command group for PraisonAI CLI.
|
||||
|
||||
Provides session management:
|
||||
- session list: List all sessions
|
||||
- session resume: Resume a session
|
||||
- session delete: Delete a session
|
||||
- session export: Export a session
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from ..output.console import get_output_controller
|
||||
from ..state.sessions import get_session_manager
|
||||
|
||||
app = typer.Typer(help="Session management")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def session_list(
|
||||
limit: int = typer.Option(
|
||||
20,
|
||||
"--limit",
|
||||
"-n",
|
||||
help="Maximum number of sessions to show",
|
||||
),
|
||||
):
|
||||
"""List all sessions."""
|
||||
output = get_output_controller()
|
||||
manager = get_session_manager()
|
||||
|
||||
sessions = manager.list(limit=limit)
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({
|
||||
"sessions": [s.to_dict() for s in sessions]
|
||||
})
|
||||
return
|
||||
|
||||
if not sessions:
|
||||
output.print_info("No sessions found")
|
||||
return
|
||||
|
||||
headers = ["ID", "Name", "Status", "Events", "Updated"]
|
||||
rows = []
|
||||
for s in sessions:
|
||||
rows.append([
|
||||
s.session_id[:20] + "..." if len(s.session_id) > 20 else s.session_id,
|
||||
s.name or "-",
|
||||
s.status,
|
||||
str(s.event_count),
|
||||
s.updated_at.strftime("%Y-%m-%d %H:%M"),
|
||||
])
|
||||
|
||||
output.print_table(headers, rows, title="Sessions")
|
||||
|
||||
|
||||
@app.command("resume")
|
||||
def session_resume(
|
||||
session_id: str = typer.Argument(..., help="Session ID to resume"),
|
||||
):
|
||||
"""Resume a session."""
|
||||
output = get_output_controller()
|
||||
manager = get_session_manager()
|
||||
|
||||
session = manager.get(session_id)
|
||||
|
||||
if not session:
|
||||
output.print_error(
|
||||
f"Session not found: {session_id}",
|
||||
remediation="Use 'praisonai session list' to see available sessions"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Load session events
|
||||
events = manager.get_events(session_id)
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({
|
||||
"session": session.to_dict(),
|
||||
"events": events,
|
||||
"message": "Session loaded. True resume not supported - showing transcript.",
|
||||
})
|
||||
return
|
||||
|
||||
output.print_panel(
|
||||
f"Session: {session.name or session.session_id}\n"
|
||||
f"Run ID: {session.run_id}\n"
|
||||
f"Trace ID: {session.trace_id}\n"
|
||||
f"Events: {session.event_count}\n"
|
||||
f"Status: {session.status}",
|
||||
title="Session Loaded"
|
||||
)
|
||||
|
||||
output.print_warning(
|
||||
"True session resume is not yet supported. "
|
||||
"Showing session transcript instead."
|
||||
)
|
||||
|
||||
# Show recent events
|
||||
if events:
|
||||
output.print("\n--- Recent Events ---\n")
|
||||
for event in events[-10:]:
|
||||
event_type = event.get("event", "unknown")
|
||||
message = event.get("message", "")
|
||||
output.print(f"[{event_type}] {message}")
|
||||
|
||||
|
||||
@app.command("delete")
|
||||
def session_delete(
|
||||
session_id: str = typer.Argument(..., help="Session ID to delete"),
|
||||
confirm: bool = typer.Option(
|
||||
False,
|
||||
"--yes",
|
||||
"-y",
|
||||
help="Skip confirmation",
|
||||
),
|
||||
):
|
||||
"""Delete a session."""
|
||||
output = get_output_controller()
|
||||
manager = get_session_manager()
|
||||
|
||||
session = manager.get(session_id)
|
||||
|
||||
if not session:
|
||||
output.print_error(f"Session not found: {session_id}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not confirm:
|
||||
confirmed = typer.confirm(f"Delete session {session_id}?")
|
||||
if not confirmed:
|
||||
output.print_info("Cancelled")
|
||||
raise typer.Exit(0)
|
||||
|
||||
deleted = manager.delete(session_id)
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({"deleted": deleted, "session_id": session_id})
|
||||
else:
|
||||
if deleted:
|
||||
output.print_success(f"Deleted session: {session_id}")
|
||||
else:
|
||||
output.print_error(f"Failed to delete session: {session_id}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("export")
|
||||
def session_export(
|
||||
session_id: str = typer.Argument(..., help="Session ID to export"),
|
||||
format: str = typer.Option(
|
||||
"md",
|
||||
"--format",
|
||||
"-f",
|
||||
help="Export format: md or json",
|
||||
),
|
||||
output_file: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--output",
|
||||
"-o",
|
||||
help="Output file path",
|
||||
),
|
||||
):
|
||||
"""Export a session."""
|
||||
output = get_output_controller()
|
||||
manager = get_session_manager()
|
||||
|
||||
content = manager.export(session_id, format=format)
|
||||
|
||||
if content is None:
|
||||
output.print_error(f"Session not found: {session_id}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if output_file:
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
output.print_success(f"Exported to: {output_file}")
|
||||
else:
|
||||
print(content)
|
||||
|
||||
|
||||
@app.command("show")
|
||||
def session_show(
|
||||
session_id: str = typer.Argument(..., help="Session ID to show"),
|
||||
):
|
||||
"""Show session details."""
|
||||
output = get_output_controller()
|
||||
manager = get_session_manager()
|
||||
|
||||
session = manager.get(session_id)
|
||||
|
||||
if not session:
|
||||
output.print_error(f"Session not found: {session_id}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json(session.to_dict())
|
||||
return
|
||||
|
||||
output.print_panel(
|
||||
f"Session ID: {session.session_id}\n"
|
||||
f"Name: {session.name or '-'}\n"
|
||||
f"Run ID: {session.run_id}\n"
|
||||
f"Trace ID: {session.trace_id}\n"
|
||||
f"Created: {session.created_at.isoformat()}\n"
|
||||
f"Updated: {session.updated_at.isoformat()}\n"
|
||||
f"Status: {session.status}\n"
|
||||
f"Events: {session.event_count}\n"
|
||||
f"Workspace: {session.workspace or '-'}",
|
||||
title="Session Details"
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Traces command group for PraisonAI CLI.
|
||||
|
||||
Provides trace collection management:
|
||||
- traces enable: Enable trace collection
|
||||
- traces disable: Disable trace collection
|
||||
- traces status: Show current status
|
||||
- traces list: List recent traces
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from ..configuration.loader import get_config_loader
|
||||
from ..output.console import get_output_controller
|
||||
|
||||
app = typer.Typer(help="Trace collection management")
|
||||
|
||||
|
||||
@app.command("enable")
|
||||
def traces_enable(
|
||||
endpoint: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--endpoint",
|
||||
"-e",
|
||||
help="Trace endpoint URL",
|
||||
),
|
||||
sample_rate: float = typer.Option(
|
||||
1.0,
|
||||
"--sample-rate",
|
||||
"-r",
|
||||
help="Sample rate (0.0 to 1.0)",
|
||||
),
|
||||
):
|
||||
"""Enable trace collection."""
|
||||
output = get_output_controller()
|
||||
loader = get_config_loader()
|
||||
|
||||
loader.set("traces.enabled", True)
|
||||
if endpoint:
|
||||
loader.set("traces.endpoint", endpoint)
|
||||
if sample_rate != 1.0:
|
||||
loader.set("traces.sample_rate", sample_rate)
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({
|
||||
"traces_enabled": True,
|
||||
"endpoint": endpoint,
|
||||
"sample_rate": sample_rate,
|
||||
})
|
||||
else:
|
||||
output.print_success("Trace collection enabled")
|
||||
if endpoint:
|
||||
output.print(f" Endpoint: {endpoint}")
|
||||
output.print(f" Sample rate: {sample_rate}")
|
||||
|
||||
|
||||
@app.command("disable")
|
||||
def traces_disable():
|
||||
"""Disable trace collection."""
|
||||
output = get_output_controller()
|
||||
loader = get_config_loader()
|
||||
|
||||
loader.set("traces.enabled", False)
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({"traces_enabled": False})
|
||||
else:
|
||||
output.print_success("Trace collection disabled")
|
||||
|
||||
|
||||
@app.command("status")
|
||||
def traces_status():
|
||||
"""Show trace collection status."""
|
||||
output = get_output_controller()
|
||||
loader = get_config_loader()
|
||||
|
||||
enabled = loader.get("traces.enabled", False)
|
||||
endpoint = loader.get("traces.endpoint")
|
||||
sample_rate = loader.get("traces.sample_rate", 1.0)
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({
|
||||
"enabled": enabled,
|
||||
"endpoint": endpoint,
|
||||
"sample_rate": sample_rate,
|
||||
})
|
||||
else:
|
||||
status = "✅ Enabled" if enabled else "❌ Disabled"
|
||||
output.print_panel(
|
||||
f"Status: {status}\n"
|
||||
f"Endpoint: {endpoint or 'Not configured'}\n"
|
||||
f"Sample Rate: {sample_rate}",
|
||||
title="Trace Collection Status"
|
||||
)
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def traces_list(
|
||||
limit: int = typer.Option(
|
||||
20,
|
||||
"--limit",
|
||||
"-n",
|
||||
help="Maximum number of traces to show",
|
||||
),
|
||||
):
|
||||
"""List recent traces."""
|
||||
output = get_output_controller()
|
||||
|
||||
from ..configuration.paths import get_traces_dir
|
||||
|
||||
traces_dir = get_traces_dir()
|
||||
|
||||
if not traces_dir.exists():
|
||||
if output.is_json_mode:
|
||||
output.print_json({"traces": []})
|
||||
else:
|
||||
output.print_info("No traces found")
|
||||
return
|
||||
|
||||
traces = []
|
||||
for trace_file in sorted(traces_dir.iterdir(), reverse=True)[:limit]:
|
||||
if trace_file.is_file() and trace_file.suffix == ".json":
|
||||
traces.append({
|
||||
"name": trace_file.stem,
|
||||
"size": trace_file.stat().st_size,
|
||||
"modified": trace_file.stat().st_mtime,
|
||||
})
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({"traces": traces})
|
||||
else:
|
||||
if not traces:
|
||||
output.print_info("No traces found")
|
||||
return
|
||||
|
||||
headers = ["Name", "Size", "Modified"]
|
||||
rows = []
|
||||
for t in traces:
|
||||
from datetime import datetime
|
||||
modified = datetime.fromtimestamp(t["modified"]).strftime("%Y-%m-%d %H:%M")
|
||||
rows.append([t["name"], f"{t['size']} bytes", modified])
|
||||
|
||||
output.print_table(headers, rows, title="Recent Traces")
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Version command group for PraisonAI CLI.
|
||||
|
||||
Provides version information:
|
||||
- version show: Show version
|
||||
- version check: Check for updates
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
from ..output.console import get_output_controller
|
||||
|
||||
app = typer.Typer(help="Version information")
|
||||
|
||||
|
||||
@app.command("show")
|
||||
def version_show():
|
||||
"""Show version information."""
|
||||
output = get_output_controller()
|
||||
|
||||
from praisonai.version import __version__
|
||||
|
||||
# Try to get additional version info
|
||||
versions = {
|
||||
"praisonai": __version__,
|
||||
}
|
||||
|
||||
# Check praisonaiagents version
|
||||
try:
|
||||
import praisonaiagents
|
||||
versions["praisonaiagents"] = getattr(praisonaiagents, "__version__", "unknown")
|
||||
except ImportError:
|
||||
versions["praisonaiagents"] = "not installed"
|
||||
|
||||
# Check Python version
|
||||
import sys
|
||||
versions["python"] = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json(versions)
|
||||
return
|
||||
|
||||
output.print_panel(
|
||||
f"PraisonAI: {versions['praisonai']}\n"
|
||||
f"PraisonAI Agents: {versions['praisonaiagents']}\n"
|
||||
f"Python: {versions['python']}",
|
||||
title="Version Information"
|
||||
)
|
||||
|
||||
|
||||
@app.command("check")
|
||||
def version_check():
|
||||
"""Check for updates."""
|
||||
output = get_output_controller()
|
||||
|
||||
from praisonai.version import __version__
|
||||
|
||||
output.print_info("Checking for updates...")
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
url = "https://pypi.org/pypi/praisonai/json"
|
||||
with urllib.request.urlopen(url, timeout=5) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
latest = data["info"]["version"]
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({
|
||||
"current": __version__,
|
||||
"latest": latest,
|
||||
"update_available": latest != __version__,
|
||||
})
|
||||
return
|
||||
|
||||
if latest != __version__:
|
||||
output.print_warning(
|
||||
f"Update available: {__version__} → {latest}\n"
|
||||
f"Run: pip install --upgrade praisonai"
|
||||
)
|
||||
else:
|
||||
output.print_success(f"You are using the latest version ({__version__})")
|
||||
|
||||
except Exception as e:
|
||||
if output.is_json_mode:
|
||||
output.print_json({
|
||||
"current": __version__,
|
||||
"latest": None,
|
||||
"error": str(e),
|
||||
})
|
||||
else:
|
||||
output.print_error(f"Failed to check for updates: {e}")
|
||||
output.print(f"Current version: {__version__}")
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def version_callback(ctx: typer.Context):
|
||||
"""Show version (default behavior)."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
version_show()
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
PraisonAI CLI Configuration Module.
|
||||
|
||||
Provides configuration management with TOML support and precedence handling.
|
||||
"""
|
||||
|
||||
from .loader import ConfigLoader, get_config
|
||||
from .schema import ConfigSchema
|
||||
from .paths import get_config_paths, get_user_config_path, get_project_config_path
|
||||
|
||||
__all__ = [
|
||||
'ConfigLoader',
|
||||
'ConfigSchema',
|
||||
'get_config',
|
||||
'get_config_paths',
|
||||
'get_user_config_path',
|
||||
'get_project_config_path',
|
||||
]
|
||||
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
Configuration loader for PraisonAI CLI.
|
||||
|
||||
Handles loading, merging, and saving configuration with precedence.
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .paths import (
|
||||
get_config_paths,
|
||||
get_user_config_path,
|
||||
get_project_config_path,
|
||||
get_env_prefix,
|
||||
ensure_config_dirs,
|
||||
)
|
||||
from .schema import ConfigSchema, DEFAULT_CONFIG
|
||||
|
||||
|
||||
# Thread-safe config cache
|
||||
_config_lock = threading.Lock()
|
||||
_config_cache: Optional[ConfigSchema] = None
|
||||
|
||||
|
||||
def _load_toml(path: Path) -> Dict[str, Any]:
|
||||
"""Load a TOML file."""
|
||||
try:
|
||||
# Try tomllib (Python 3.11+) first
|
||||
import tomllib
|
||||
with open(path, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Fall back to tomli
|
||||
import tomli
|
||||
with open(path, "rb") as f:
|
||||
return tomli.load(f)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Last resort: basic TOML parsing
|
||||
return _basic_toml_parse(path)
|
||||
|
||||
|
||||
def _basic_toml_parse(path: Path) -> Dict[str, Any]:
|
||||
"""Basic TOML parser for simple configs."""
|
||||
result: Dict[str, Any] = {}
|
||||
current_section: Optional[str] = None
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
current_section = line[1:-1]
|
||||
if current_section not in result:
|
||||
result[current_section] = {}
|
||||
elif "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
|
||||
# Parse value
|
||||
if value.lower() == "true":
|
||||
parsed_value = True
|
||||
elif value.lower() == "false":
|
||||
parsed_value = False
|
||||
elif value.startswith('"') and value.endswith('"'):
|
||||
parsed_value = value[1:-1]
|
||||
elif value.startswith("'") and value.endswith("'"):
|
||||
parsed_value = value[1:-1]
|
||||
else:
|
||||
try:
|
||||
parsed_value = int(value)
|
||||
except ValueError:
|
||||
try:
|
||||
parsed_value = float(value)
|
||||
except ValueError:
|
||||
parsed_value = value
|
||||
|
||||
if current_section:
|
||||
result[current_section][key] = parsed_value
|
||||
else:
|
||||
result[key] = parsed_value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _save_toml(path: Path, data: Dict[str, Any]) -> None:
|
||||
"""Save data to a TOML file."""
|
||||
try:
|
||||
import tomli_w
|
||||
with open(path, "wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fall back to basic TOML writing
|
||||
_basic_toml_write(path, data)
|
||||
|
||||
|
||||
def _basic_toml_write(path: Path, data: Dict[str, Any]) -> None:
|
||||
"""Basic TOML writer."""
|
||||
lines = []
|
||||
|
||||
# Write top-level keys first
|
||||
for key, value in data.items():
|
||||
if not isinstance(value, dict):
|
||||
lines.append(f"{key} = {_format_toml_value(value)}")
|
||||
|
||||
# Write sections
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict):
|
||||
lines.append(f"\n[{key}]")
|
||||
for sub_key, sub_value in value.items():
|
||||
if not isinstance(sub_value, dict):
|
||||
lines.append(f"{sub_key} = {_format_toml_value(sub_value)}")
|
||||
else:
|
||||
# Nested section
|
||||
lines.append(f"\n[{key}.{sub_key}]")
|
||||
for nested_key, nested_value in sub_value.items():
|
||||
lines.append(f"{nested_key} = {_format_toml_value(nested_value)}")
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def _format_toml_value(value: Any) -> str:
|
||||
"""Format a value for TOML."""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
elif isinstance(value, str):
|
||||
return f'"{value}"'
|
||||
elif isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
elif isinstance(value, list):
|
||||
items = ", ".join(_format_toml_value(v) for v in value)
|
||||
return f"[{items}]"
|
||||
else:
|
||||
return f'"{value}"'
|
||||
|
||||
|
||||
def _merge_dicts(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Deep merge two dictionaries."""
|
||||
result = base.copy()
|
||||
for key, value in override.items():
|
||||
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||
result[key] = _merge_dicts(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _get_env_overrides() -> Dict[str, Any]:
|
||||
"""Get configuration overrides from environment variables."""
|
||||
prefix = get_env_prefix()
|
||||
overrides: Dict[str, Any] = {}
|
||||
|
||||
for key, value in os.environ.items():
|
||||
if not key.startswith(prefix):
|
||||
continue
|
||||
|
||||
# Convert PRAISONAI_OUTPUT_FORMAT to output.format
|
||||
config_key = key[len(prefix):].lower()
|
||||
parts = config_key.split("_")
|
||||
|
||||
# Parse value
|
||||
if value.lower() == "true":
|
||||
parsed_value = True
|
||||
elif value.lower() == "false":
|
||||
parsed_value = False
|
||||
else:
|
||||
try:
|
||||
parsed_value = int(value)
|
||||
except ValueError:
|
||||
try:
|
||||
parsed_value = float(value)
|
||||
except ValueError:
|
||||
parsed_value = value
|
||||
|
||||
# Build nested dict
|
||||
if len(parts) >= 2:
|
||||
section = parts[0]
|
||||
sub_key = "_".join(parts[1:])
|
||||
if section not in overrides:
|
||||
overrides[section] = {}
|
||||
overrides[section][sub_key] = parsed_value
|
||||
else:
|
||||
overrides[config_key] = parsed_value
|
||||
|
||||
return overrides
|
||||
|
||||
|
||||
def _get_dotted_value(data: Dict[str, Any], key: str) -> Any:
|
||||
"""Get a value using dotted key notation."""
|
||||
parts = key.split(".")
|
||||
current = data
|
||||
for part in parts:
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
def _set_dotted_value(data: Dict[str, Any], key: str, value: Any) -> None:
|
||||
"""Set a value using dotted key notation."""
|
||||
parts = key.split(".")
|
||||
current = data
|
||||
for part in parts[:-1]:
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
current[parts[-1]] = value
|
||||
|
||||
|
||||
class ConfigLoader:
|
||||
"""
|
||||
Configuration loader with precedence handling.
|
||||
|
||||
Precedence (highest to lowest):
|
||||
1. CLI flags (not handled here)
|
||||
2. Environment variables (PRAISONAI_*)
|
||||
3. Project config (.praison/config.toml)
|
||||
4. User config (~/.praison/config.toml)
|
||||
5. Defaults
|
||||
"""
|
||||
|
||||
def __init__(self, project_root: Optional[Path] = None):
|
||||
self.project_root = project_root or Path.cwd()
|
||||
self._config: Optional[ConfigSchema] = None
|
||||
self._raw_config: Dict[str, Any] = {}
|
||||
|
||||
def load(self, force_reload: bool = False) -> ConfigSchema:
|
||||
"""Load configuration with precedence."""
|
||||
if self._config is not None and not force_reload:
|
||||
return self._config
|
||||
|
||||
# Start with defaults
|
||||
merged = DEFAULT_CONFIG.to_dict()
|
||||
|
||||
# Load config files in reverse precedence order
|
||||
config_paths = get_config_paths(self.project_root)
|
||||
for path in reversed(config_paths):
|
||||
try:
|
||||
file_config = _load_toml(path)
|
||||
merged = _merge_dicts(merged, file_config)
|
||||
except Exception:
|
||||
pass # Skip invalid files
|
||||
|
||||
# Apply environment overrides
|
||||
env_overrides = _get_env_overrides()
|
||||
merged = _merge_dicts(merged, env_overrides)
|
||||
|
||||
self._raw_config = merged
|
||||
self._config = ConfigSchema.from_dict(merged)
|
||||
return self._config
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a configuration value using dotted key notation."""
|
||||
self.load()
|
||||
value = _get_dotted_value(self._raw_config, key)
|
||||
return value if value is not None else default
|
||||
|
||||
def set(self, key: str, value: Any, scope: str = "user") -> None:
|
||||
"""
|
||||
Set a configuration value.
|
||||
|
||||
Args:
|
||||
key: Dotted key notation (e.g., "output.format")
|
||||
value: Value to set
|
||||
scope: "user" or "project"
|
||||
"""
|
||||
if scope == "project":
|
||||
config_path = get_project_config_path(self.project_root)
|
||||
else:
|
||||
config_path = get_user_config_path()
|
||||
|
||||
# Load existing config or create empty
|
||||
if config_path.exists():
|
||||
try:
|
||||
existing = _load_toml(config_path)
|
||||
except Exception:
|
||||
existing = {}
|
||||
else:
|
||||
existing = {}
|
||||
|
||||
# Set the value
|
||||
_set_dotted_value(existing, key, value)
|
||||
|
||||
# Ensure directory exists
|
||||
ensure_config_dirs()
|
||||
if scope == "project":
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save
|
||||
_save_toml(config_path, existing)
|
||||
|
||||
# Reload
|
||||
self.load(force_reload=True)
|
||||
|
||||
def reset(self, scope: str = "user") -> None:
|
||||
"""Reset configuration to defaults."""
|
||||
if scope == "project":
|
||||
config_path = get_project_config_path(self.project_root)
|
||||
else:
|
||||
config_path = get_user_config_path()
|
||||
|
||||
if config_path.exists():
|
||||
config_path.unlink()
|
||||
|
||||
self.load(force_reload=True)
|
||||
|
||||
def list_all(self) -> Dict[str, Any]:
|
||||
"""List all configuration values."""
|
||||
self.load()
|
||||
return self._raw_config.copy()
|
||||
|
||||
|
||||
# Global config loader
|
||||
_global_loader: Optional[ConfigLoader] = None
|
||||
|
||||
|
||||
def get_config(project_root: Optional[Path] = None) -> ConfigSchema:
|
||||
"""Get the global configuration."""
|
||||
global _global_loader, _config_cache
|
||||
|
||||
with _config_lock:
|
||||
if _global_loader is None or project_root is not None:
|
||||
_global_loader = ConfigLoader(project_root)
|
||||
|
||||
if _config_cache is None:
|
||||
_config_cache = _global_loader.load()
|
||||
|
||||
return _config_cache
|
||||
|
||||
|
||||
def get_config_loader(project_root: Optional[Path] = None) -> ConfigLoader:
|
||||
"""Get the global config loader."""
|
||||
global _global_loader
|
||||
|
||||
with _config_lock:
|
||||
if _global_loader is None or project_root is not None:
|
||||
_global_loader = ConfigLoader(project_root)
|
||||
return _global_loader
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Configuration paths for PraisonAI CLI.
|
||||
|
||||
Defines standard locations for configuration files.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
def get_user_config_dir() -> Path:
|
||||
"""Get user configuration directory (~/.praison/)."""
|
||||
return Path.home() / ".praison"
|
||||
|
||||
|
||||
def get_user_config_path() -> Path:
|
||||
"""Get user configuration file path (~/.praison/config.toml)."""
|
||||
return get_user_config_dir() / "config.toml"
|
||||
|
||||
|
||||
def get_project_config_dir(project_root: Optional[Path] = None) -> Path:
|
||||
"""Get project configuration directory (.praison/)."""
|
||||
root = project_root or Path.cwd()
|
||||
return root / ".praison"
|
||||
|
||||
|
||||
def get_project_config_path(project_root: Optional[Path] = None) -> Path:
|
||||
"""Get project configuration file path (.praison/config.toml)."""
|
||||
return get_project_config_dir(project_root) / "config.toml"
|
||||
|
||||
|
||||
def get_sessions_dir() -> Path:
|
||||
"""Get sessions directory (~/.praison/sessions/)."""
|
||||
return get_user_config_dir() / "sessions"
|
||||
|
||||
|
||||
def get_traces_dir() -> Path:
|
||||
"""Get traces directory (~/.praison/traces/)."""
|
||||
return get_user_config_dir() / "traces"
|
||||
|
||||
|
||||
def get_logs_dir() -> Path:
|
||||
"""Get logs directory (~/.praison/logs/)."""
|
||||
return get_user_config_dir() / "logs"
|
||||
|
||||
|
||||
def get_cache_dir() -> Path:
|
||||
"""Get cache directory (~/.praison/cache/)."""
|
||||
return get_user_config_dir() / "cache"
|
||||
|
||||
|
||||
def get_config_paths(project_root: Optional[Path] = None) -> List[Path]:
|
||||
"""
|
||||
Get all configuration file paths in precedence order (highest first).
|
||||
|
||||
Precedence:
|
||||
1. Project config: .praison/config.toml
|
||||
2. User config: ~/.praison/config.toml
|
||||
|
||||
Returns:
|
||||
List of paths in precedence order
|
||||
"""
|
||||
paths = []
|
||||
|
||||
# Project config (highest precedence)
|
||||
project_config = get_project_config_path(project_root)
|
||||
if project_config.exists():
|
||||
paths.append(project_config)
|
||||
|
||||
# User config
|
||||
user_config = get_user_config_path()
|
||||
if user_config.exists():
|
||||
paths.append(user_config)
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def ensure_config_dirs() -> None:
|
||||
"""Ensure all configuration directories exist."""
|
||||
dirs = [
|
||||
get_user_config_dir(),
|
||||
get_sessions_dir(),
|
||||
get_traces_dir(),
|
||||
get_logs_dir(),
|
||||
get_cache_dir(),
|
||||
]
|
||||
for d in dirs:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def get_env_prefix() -> str:
|
||||
"""Get environment variable prefix."""
|
||||
return "PRAISONAI_"
|
||||
|
||||
|
||||
def env_to_config_key(env_var: str) -> Optional[str]:
|
||||
"""
|
||||
Convert environment variable name to config key.
|
||||
|
||||
Example: PRAISONAI_OUTPUT_FORMAT -> output.format
|
||||
"""
|
||||
prefix = get_env_prefix()
|
||||
if not env_var.startswith(prefix):
|
||||
return None
|
||||
|
||||
key = env_var[len(prefix):].lower()
|
||||
# Convert underscores to dots for nested keys
|
||||
# Single underscore = dot, double underscore = single underscore
|
||||
parts = key.split("__")
|
||||
result_parts = []
|
||||
for part in parts:
|
||||
result_parts.append(part.replace("_", "."))
|
||||
return "_".join(result_parts)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Configuration schema for PraisonAI CLI.
|
||||
|
||||
Defines the structure and defaults for configuration.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputConfig:
|
||||
"""Output configuration."""
|
||||
format: str = "text" # text, json, stream-json
|
||||
color: bool = True
|
||||
verbose: bool = False
|
||||
quiet: bool = False
|
||||
screen_reader: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class TracesConfig:
|
||||
"""Traces configuration."""
|
||||
enabled: bool = False
|
||||
endpoint: Optional[str] = None
|
||||
sample_rate: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPServerConfig:
|
||||
"""MCP server configuration."""
|
||||
command: str = ""
|
||||
args: List[str] = field(default_factory=list)
|
||||
env: Dict[str, str] = field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPConfig:
|
||||
"""MCP configuration."""
|
||||
servers: Dict[str, MCPServerConfig] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelConfig:
|
||||
"""Model configuration."""
|
||||
default: str = "gpt-4o-mini"
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 16000
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionConfig:
|
||||
"""Session configuration."""
|
||||
auto_save: bool = False
|
||||
history_limit: int = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigSchema:
|
||||
"""
|
||||
Complete configuration schema.
|
||||
|
||||
Represents all configurable options for PraisonAI CLI.
|
||||
"""
|
||||
output: OutputConfig = field(default_factory=OutputConfig)
|
||||
traces: TracesConfig = field(default_factory=TracesConfig)
|
||||
mcp: MCPConfig = field(default_factory=MCPConfig)
|
||||
model: ModelConfig = field(default_factory=ModelConfig)
|
||||
session: SessionConfig = field(default_factory=SessionConfig)
|
||||
|
||||
# Additional settings stored as dict for flexibility
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"output": {
|
||||
"format": self.output.format,
|
||||
"color": self.output.color,
|
||||
"verbose": self.output.verbose,
|
||||
"quiet": self.output.quiet,
|
||||
"screen_reader": self.output.screen_reader,
|
||||
},
|
||||
"traces": {
|
||||
"enabled": self.traces.enabled,
|
||||
"endpoint": self.traces.endpoint,
|
||||
"sample_rate": self.traces.sample_rate,
|
||||
},
|
||||
"mcp": {
|
||||
"servers": {
|
||||
name: {
|
||||
"command": server.command,
|
||||
"args": server.args,
|
||||
"env": server.env,
|
||||
"enabled": server.enabled,
|
||||
}
|
||||
for name, server in self.mcp.servers.items()
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"default": self.model.default,
|
||||
"temperature": self.model.temperature,
|
||||
"max_tokens": self.model.max_tokens,
|
||||
},
|
||||
"session": {
|
||||
"auto_save": self.session.auto_save,
|
||||
"history_limit": self.session.history_limit,
|
||||
},
|
||||
**self.extra,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ConfigSchema":
|
||||
"""Create from dictionary."""
|
||||
output_data = data.get("output", {})
|
||||
traces_data = data.get("traces", {})
|
||||
mcp_data = data.get("mcp", {})
|
||||
model_data = data.get("model", {})
|
||||
session_data = data.get("session", {})
|
||||
|
||||
# Extract known keys
|
||||
known_keys = {"output", "traces", "mcp", "model", "session"}
|
||||
extra = {k: v for k, v in data.items() if k not in known_keys}
|
||||
|
||||
# Parse MCP servers
|
||||
mcp_servers = {}
|
||||
for name, server_data in mcp_data.get("servers", {}).items():
|
||||
mcp_servers[name] = MCPServerConfig(
|
||||
command=server_data.get("command", ""),
|
||||
args=server_data.get("args", []),
|
||||
env=server_data.get("env", {}),
|
||||
enabled=server_data.get("enabled", True),
|
||||
)
|
||||
|
||||
return cls(
|
||||
output=OutputConfig(
|
||||
format=output_data.get("format", "text"),
|
||||
color=output_data.get("color", True),
|
||||
verbose=output_data.get("verbose", False),
|
||||
quiet=output_data.get("quiet", False),
|
||||
screen_reader=output_data.get("screen_reader", False),
|
||||
),
|
||||
traces=TracesConfig(
|
||||
enabled=traces_data.get("enabled", False),
|
||||
endpoint=traces_data.get("endpoint"),
|
||||
sample_rate=traces_data.get("sample_rate", 1.0),
|
||||
),
|
||||
mcp=MCPConfig(servers=mcp_servers),
|
||||
model=ModelConfig(
|
||||
default=model_data.get("default", "gpt-4o-mini"),
|
||||
temperature=model_data.get("temperature", 0.7),
|
||||
max_tokens=model_data.get("max_tokens", 16000),
|
||||
),
|
||||
session=SessionConfig(
|
||||
auto_save=session_data.get("auto_save", False),
|
||||
history_limit=session_data.get("history_limit", 10),
|
||||
),
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
# Default configuration
|
||||
DEFAULT_CONFIG = ConfigSchema()
|
||||
@@ -96,6 +96,12 @@ __all__ = [
|
||||
'PerformanceHandler',
|
||||
# Lite agent (BYO-LLM)
|
||||
'LiteHandler',
|
||||
# Agent-centric tools (LSP/ACP powered)
|
||||
'create_agent_centric_tools',
|
||||
'InteractiveRuntime',
|
||||
'RuntimeConfig',
|
||||
'CodeIntelligenceRouter',
|
||||
'ActionOrchestrator',
|
||||
]
|
||||
|
||||
def __getattr__(name):
|
||||
@@ -225,4 +231,20 @@ def __getattr__(name):
|
||||
elif name == 'LiteHandler':
|
||||
from .lite import LiteHandler
|
||||
return LiteHandler
|
||||
# Agent-centric tools (LSP/ACP powered)
|
||||
elif name == 'create_agent_centric_tools':
|
||||
from .agent_tools import create_agent_centric_tools
|
||||
return create_agent_centric_tools
|
||||
elif name == 'InteractiveRuntime':
|
||||
from .interactive_runtime import InteractiveRuntime
|
||||
return InteractiveRuntime
|
||||
elif name == 'RuntimeConfig':
|
||||
from .interactive_runtime import RuntimeConfig
|
||||
return RuntimeConfig
|
||||
elif name == 'CodeIntelligenceRouter':
|
||||
from .code_intelligence import CodeIntelligenceRouter
|
||||
return CodeIntelligenceRouter
|
||||
elif name == 'ActionOrchestrator':
|
||||
from .action_orchestrator import ActionOrchestrator
|
||||
return ActionOrchestrator
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
ACP CLI handler for PraisonAI.
|
||||
|
||||
Provides the `praisonai acp` command for IDE/editor integration.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ACPHandler:
|
||||
"""Handler for ACP CLI commands."""
|
||||
|
||||
@staticmethod
|
||||
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
"""Add ACP-specific arguments to parser."""
|
||||
parser.add_argument(
|
||||
"-w", "--workspace",
|
||||
type=str,
|
||||
default=".",
|
||||
help="Workspace root directory (default: current directory)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-a", "--agent",
|
||||
type=str,
|
||||
default="default",
|
||||
help="Agent name or configuration file"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--agents",
|
||||
type=str,
|
||||
help="Multi-agent configuration YAML file"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--router",
|
||||
action="store_true",
|
||||
help="Enable router agent for task delegation"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-m", "--model",
|
||||
type=str,
|
||||
help="LLM model to use"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-r", "--resume",
|
||||
type=str,
|
||||
nargs="?",
|
||||
const="__last__",
|
||||
help="Resume session by ID (or --resume --last for most recent)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--last",
|
||||
action="store_true",
|
||||
help="Resume the last session (use with --resume)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--approve",
|
||||
type=str,
|
||||
choices=["manual", "auto", "scoped"],
|
||||
default="manual",
|
||||
help="Approval mode for actions (default: manual)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--read-only",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="Read-only mode (default: enabled)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-write",
|
||||
action="store_true",
|
||||
help="Allow file write operations"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-shell",
|
||||
action="store_true",
|
||||
help="Allow shell command execution"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-network",
|
||||
action="store_true",
|
||||
help="Allow network requests"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Enable debug logging to stderr"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--profile",
|
||||
type=str,
|
||||
help="Use named profile from config"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def handle(args: argparse.Namespace) -> int:
|
||||
"""
|
||||
Handle ACP command execution.
|
||||
|
||||
Returns:
|
||||
Exit code (0 for success, non-zero for error)
|
||||
"""
|
||||
# Check if ACP SDK is available
|
||||
try:
|
||||
import importlib.util
|
||||
if importlib.util.find_spec("acp") is None:
|
||||
print(
|
||||
"Error: agent-client-protocol package not installed.\n"
|
||||
"Install with: pip install praisonai[acp]\n"
|
||||
"Or: pip install agent-client-protocol",
|
||||
file=sys.stderr
|
||||
)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"Error checking ACP availability: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Import ACP module
|
||||
try:
|
||||
from praisonai.acp import serve
|
||||
except ImportError as e:
|
||||
print(f"Error importing ACP module: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Determine resume session
|
||||
resume_session: Optional[str] = None
|
||||
resume_last = False
|
||||
|
||||
if args.resume:
|
||||
if args.resume == "__last__" or args.last:
|
||||
resume_last = True
|
||||
else:
|
||||
resume_session = args.resume
|
||||
elif args.last:
|
||||
resume_last = True
|
||||
|
||||
# Determine read-only mode
|
||||
read_only = args.read_only and not args.allow_write
|
||||
|
||||
try:
|
||||
# Start ACP server
|
||||
serve(
|
||||
workspace=args.workspace,
|
||||
agent=args.agent,
|
||||
model=args.model,
|
||||
resume=resume_session,
|
||||
resume_last=resume_last,
|
||||
debug=args.debug,
|
||||
read_only=read_only,
|
||||
allow_write=args.allow_write,
|
||||
allow_shell=args.allow_shell,
|
||||
approval_mode=args.approve,
|
||||
)
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
logger.info("ACP server stopped by user")
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.exception(f"ACP server error: {e}")
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def setup_acp_subparser(subparsers) -> None:
|
||||
"""Set up the ACP subcommand parser."""
|
||||
acp_parser = subparsers.add_parser(
|
||||
"acp",
|
||||
help="Start ACP server for IDE/editor integration",
|
||||
description=(
|
||||
"Start the Agent Client Protocol (ACP) server.\n\n"
|
||||
"This allows IDEs and editors (Zed, JetBrains, VSCode, Toad) "
|
||||
"to connect to PraisonAI agents via JSON-RPC over stdio."
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
praisonai acp # Start with defaults
|
||||
praisonai acp --debug # Enable debug logging
|
||||
praisonai acp --resume --last # Resume last session
|
||||
praisonai acp --allow-write # Enable file writes
|
||||
praisonai acp -m gpt-4o # Use specific model
|
||||
|
||||
Editor Configuration:
|
||||
|
||||
Zed (~/.config/zed/settings.json):
|
||||
{
|
||||
"agent_servers": {
|
||||
"PraisonAI": {
|
||||
"command": "praisonai",
|
||||
"args": ["acp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JetBrains (~/.jetbrains/acp.json):
|
||||
{
|
||||
"agent_servers": {
|
||||
"PraisonAI": {
|
||||
"command": "praisonai",
|
||||
"args": ["acp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Toad:
|
||||
toad acp "praisonai acp"
|
||||
"""
|
||||
)
|
||||
ACPHandler.add_arguments(acp_parser)
|
||||
acp_parser.set_defaults(func=lambda args: ACPHandler.handle(args))
|
||||
|
||||
|
||||
def run_acp_command(args: Optional[list] = None) -> int:
|
||||
"""
|
||||
Run ACP command directly.
|
||||
|
||||
This can be called from the main CLI or as a standalone entry point.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="praisonai acp",
|
||||
description="Start ACP server for IDE/editor integration"
|
||||
)
|
||||
ACPHandler.add_arguments(parser)
|
||||
|
||||
parsed_args = parser.parse_args(args)
|
||||
return ACPHandler.handle(parsed_args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run_acp_command())
|
||||
@@ -0,0 +1,546 @@
|
||||
"""
|
||||
Action Orchestrator for PraisonAI.
|
||||
|
||||
Orchestrates code modifications through ACP with plan → approval → apply → verify steps.
|
||||
Enforces read-only mode when ACP is unavailable.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .interactive_runtime import InteractiveRuntime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ActionType(Enum):
|
||||
"""Types of actions that can be orchestrated."""
|
||||
FILE_CREATE = "file_create"
|
||||
FILE_EDIT = "file_edit"
|
||||
FILE_DELETE = "file_delete"
|
||||
FILE_RENAME = "file_rename"
|
||||
SHELL_COMMAND = "shell_command"
|
||||
REFACTOR = "refactor"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class ActionStatus(Enum):
|
||||
"""Status of an action."""
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
APPLIED = "applied"
|
||||
FAILED = "failed"
|
||||
VERIFIED = "verified"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionStep:
|
||||
"""A single step in an action plan."""
|
||||
id: str
|
||||
action_type: ActionType
|
||||
description: str
|
||||
target: str # File path or command
|
||||
params: Dict[str, Any] = field(default_factory=dict)
|
||||
status: ActionStatus = ActionStatus.PENDING
|
||||
result: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionPlan:
|
||||
"""A plan of actions to execute."""
|
||||
id: str
|
||||
prompt: str
|
||||
steps: List[ActionStep] = field(default_factory=list)
|
||||
created_at: float = field(default_factory=time.time)
|
||||
status: ActionStatus = ActionStatus.PENDING
|
||||
approval_mode: str = "manual"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"prompt": self.prompt,
|
||||
"steps": [
|
||||
{
|
||||
"id": s.id,
|
||||
"action_type": s.action_type.value,
|
||||
"description": s.description,
|
||||
"target": s.target,
|
||||
"params": s.params,
|
||||
"status": s.status.value,
|
||||
"result": s.result,
|
||||
"error": s.error
|
||||
}
|
||||
for s in self.steps
|
||||
],
|
||||
"created_at": self.created_at,
|
||||
"status": self.status.value,
|
||||
"approval_mode": self.approval_mode
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionResult:
|
||||
"""Result of action orchestration."""
|
||||
success: bool
|
||||
plan: Optional[ActionPlan] = None
|
||||
applied_steps: int = 0
|
||||
failed_steps: int = 0
|
||||
error: Optional[str] = None
|
||||
read_only_blocked: bool = False
|
||||
diff_summary: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"success": self.success,
|
||||
"plan": self.plan.to_dict() if self.plan else None,
|
||||
"applied_steps": self.applied_steps,
|
||||
"failed_steps": self.failed_steps,
|
||||
"error": self.error,
|
||||
"read_only_blocked": self.read_only_blocked,
|
||||
"diff_summary": self.diff_summary
|
||||
}
|
||||
|
||||
|
||||
class ActionOrchestrator:
|
||||
"""
|
||||
Orchestrates code modifications through ACP.
|
||||
|
||||
All edits must go through:
|
||||
1. Plan creation
|
||||
2. Approval (manual, auto, or scoped)
|
||||
3. Application
|
||||
4. Verification
|
||||
|
||||
If ACP is unavailable, runtime is read-only and edits are blocked.
|
||||
"""
|
||||
|
||||
def __init__(self, runtime: "InteractiveRuntime"):
|
||||
"""Initialize with runtime reference."""
|
||||
self.runtime = runtime
|
||||
self._plan_counter = 0
|
||||
|
||||
def _generate_plan_id(self) -> str:
|
||||
"""Generate a unique plan ID."""
|
||||
self._plan_counter += 1
|
||||
return f"plan_{int(time.time())}_{self._plan_counter}"
|
||||
|
||||
def _generate_step_id(self, plan_id: str, index: int) -> str:
|
||||
"""Generate a unique step ID."""
|
||||
return f"{plan_id}_step_{index}"
|
||||
|
||||
async def create_plan(self, prompt: str) -> ActionResult:
|
||||
"""
|
||||
Create an action plan for a modification request.
|
||||
|
||||
Args:
|
||||
prompt: The user's modification request
|
||||
|
||||
Returns:
|
||||
ActionResult with the plan
|
||||
"""
|
||||
if self.runtime.read_only:
|
||||
return ActionResult(
|
||||
success=False,
|
||||
error="Runtime is in read-only mode. ACP is required for modifications.",
|
||||
read_only_blocked=True
|
||||
)
|
||||
|
||||
plan_id = self._generate_plan_id()
|
||||
|
||||
# Always use fallback plan creation (ACP session tracks but doesn't create plans)
|
||||
# In production, this would integrate with an LLM for intelligent planning
|
||||
return await self._create_fallback_plan(prompt, plan_id)
|
||||
|
||||
async def _create_fallback_plan(self, prompt: str, plan_id: str) -> ActionResult:
|
||||
"""Create a fallback plan when ACP is not available for planning."""
|
||||
# This is a simplified fallback - in production, this would use LLM
|
||||
steps = []
|
||||
|
||||
# Analyze prompt for common patterns
|
||||
prompt_lower = prompt.lower()
|
||||
|
||||
if "create" in prompt_lower or "new file" in prompt_lower:
|
||||
# Extract file path if mentioned
|
||||
import re
|
||||
file_match = re.search(r'(?:create|new)\s+(?:a\s+)?(?:file\s+)?(?:called\s+)?([^\s]+\.\w+)', prompt_lower)
|
||||
target = file_match.group(1) if file_match else "new_file.py"
|
||||
|
||||
# Generate default content based on file type
|
||||
if target.endswith('.py'):
|
||||
content = f'"""\n{target} - Auto-generated file\n"""\n\ndef main():\n pass\n\n\nif __name__ == "__main__":\n main()\n'
|
||||
else:
|
||||
content = f"// {target} - Auto-generated file\n"
|
||||
|
||||
steps.append(ActionStep(
|
||||
id=self._generate_step_id(plan_id, 0),
|
||||
action_type=ActionType.FILE_CREATE,
|
||||
description=f"Create file: {target}",
|
||||
target=target,
|
||||
params={"content": content},
|
||||
status=ActionStatus.APPROVED # Auto-approve for fallback
|
||||
))
|
||||
|
||||
elif "edit" in prompt_lower or "modify" in prompt_lower or "change" in prompt_lower:
|
||||
file_match = re.search(r'(?:edit|modify|change)\s+([^\s]+\.\w+)', prompt_lower)
|
||||
target = file_match.group(1) if file_match else ""
|
||||
|
||||
if target:
|
||||
steps.append(ActionStep(
|
||||
id=self._generate_step_id(plan_id, 0),
|
||||
action_type=ActionType.FILE_EDIT,
|
||||
description=f"Edit file: {target}",
|
||||
target=target,
|
||||
params={"changes": prompt}
|
||||
))
|
||||
|
||||
elif "delete" in prompt_lower or "remove" in prompt_lower:
|
||||
file_match = re.search(r'(?:delete|remove)\s+([^\s]+\.\w+)', prompt_lower)
|
||||
target = file_match.group(1) if file_match else ""
|
||||
|
||||
if target:
|
||||
steps.append(ActionStep(
|
||||
id=self._generate_step_id(plan_id, 0),
|
||||
action_type=ActionType.FILE_DELETE,
|
||||
description=f"Delete file: {target}",
|
||||
target=target
|
||||
))
|
||||
|
||||
elif "rename" in prompt_lower:
|
||||
steps.append(ActionStep(
|
||||
id=self._generate_step_id(plan_id, 0),
|
||||
action_type=ActionType.REFACTOR,
|
||||
description="Rename operation",
|
||||
target="",
|
||||
params={"prompt": prompt}
|
||||
))
|
||||
|
||||
elif "run" in prompt_lower or "execute" in prompt_lower:
|
||||
cmd_match = re.search(r'(?:run|execute)\s+[`"\']?([^`"\']+)[`"\']?', prompt_lower)
|
||||
command = cmd_match.group(1) if cmd_match else ""
|
||||
|
||||
if command:
|
||||
steps.append(ActionStep(
|
||||
id=self._generate_step_id(plan_id, 0),
|
||||
action_type=ActionType.SHELL_COMMAND,
|
||||
description=f"Execute: {command}",
|
||||
target=command
|
||||
))
|
||||
|
||||
if not steps:
|
||||
return ActionResult(
|
||||
success=False,
|
||||
error="Could not determine action from prompt. Please be more specific."
|
||||
)
|
||||
|
||||
plan = ActionPlan(
|
||||
id=plan_id,
|
||||
prompt=prompt,
|
||||
steps=steps,
|
||||
approval_mode=self.runtime.config.approval_mode
|
||||
)
|
||||
|
||||
return ActionResult(
|
||||
success=True,
|
||||
plan=plan
|
||||
)
|
||||
|
||||
def _classify_action_type(self, step_data: Dict[str, Any]) -> ActionType:
|
||||
"""Classify the action type from step data."""
|
||||
action = step_data.get("action", "").lower()
|
||||
|
||||
if "create" in action:
|
||||
return ActionType.FILE_CREATE
|
||||
elif "edit" in action or "modify" in action:
|
||||
return ActionType.FILE_EDIT
|
||||
elif "delete" in action or "remove" in action:
|
||||
return ActionType.FILE_DELETE
|
||||
elif "rename" in action:
|
||||
return ActionType.FILE_RENAME
|
||||
elif "shell" in action or "command" in action or "run" in action:
|
||||
return ActionType.SHELL_COMMAND
|
||||
elif "refactor" in action:
|
||||
return ActionType.REFACTOR
|
||||
|
||||
return ActionType.UNKNOWN
|
||||
|
||||
async def approve_plan(self, plan: ActionPlan, auto: bool = False) -> bool:
|
||||
"""
|
||||
Request approval for a plan.
|
||||
|
||||
Args:
|
||||
plan: The plan to approve
|
||||
auto: Whether to auto-approve
|
||||
|
||||
Returns:
|
||||
True if approved, False if rejected
|
||||
"""
|
||||
if self.runtime.read_only:
|
||||
logger.warning("Cannot approve plan in read-only mode")
|
||||
return False
|
||||
|
||||
approval_mode = self.runtime.config.approval_mode
|
||||
|
||||
if approval_mode == "auto" or auto:
|
||||
plan.status = ActionStatus.APPROVED
|
||||
for step in plan.steps:
|
||||
step.status = ActionStatus.APPROVED
|
||||
return True
|
||||
|
||||
elif approval_mode == "scoped":
|
||||
# Auto-approve safe actions, require manual for dangerous ones
|
||||
dangerous_types = {ActionType.FILE_DELETE, ActionType.SHELL_COMMAND}
|
||||
|
||||
for step in plan.steps:
|
||||
if step.action_type in dangerous_types:
|
||||
step.status = ActionStatus.PENDING # Needs manual approval
|
||||
else:
|
||||
step.status = ActionStatus.APPROVED
|
||||
|
||||
# Plan is approved if all steps are approved
|
||||
if all(s.status == ActionStatus.APPROVED for s in plan.steps):
|
||||
plan.status = ActionStatus.APPROVED
|
||||
return True
|
||||
|
||||
return False # Some steps need manual approval
|
||||
|
||||
else: # manual
|
||||
# In manual mode, we return False to indicate approval is needed
|
||||
return False
|
||||
|
||||
async def apply_plan(self, plan: ActionPlan, force: bool = False) -> ActionResult:
|
||||
"""
|
||||
Apply an approved plan.
|
||||
|
||||
Args:
|
||||
plan: The plan to apply
|
||||
force: Force apply even if not fully approved
|
||||
|
||||
Returns:
|
||||
ActionResult with application status
|
||||
"""
|
||||
if self.runtime.read_only:
|
||||
return ActionResult(
|
||||
success=False,
|
||||
plan=plan,
|
||||
error="Runtime is in read-only mode",
|
||||
read_only_blocked=True
|
||||
)
|
||||
|
||||
if plan.status != ActionStatus.APPROVED and not force:
|
||||
return ActionResult(
|
||||
success=False,
|
||||
plan=plan,
|
||||
error="Plan is not approved"
|
||||
)
|
||||
|
||||
applied = 0
|
||||
failed = 0
|
||||
diff_parts = []
|
||||
|
||||
# Track in ACP session if available (but always do manual application)
|
||||
if self.runtime.acp_ready:
|
||||
try:
|
||||
auto_approve = self.runtime.config.approval_mode == "auto"
|
||||
await self.runtime.acp_apply_plan(
|
||||
plan.to_dict(),
|
||||
auto_approve=auto_approve
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"ACP tracking failed: {e}")
|
||||
|
||||
# Manual application (always execute)
|
||||
for step in plan.steps:
|
||||
if step.status not in [ActionStatus.APPROVED, ActionStatus.PENDING]:
|
||||
continue
|
||||
|
||||
try:
|
||||
result = await self._apply_step(step)
|
||||
if result:
|
||||
step.status = ActionStatus.APPLIED
|
||||
step.result = result
|
||||
applied += 1
|
||||
if isinstance(result, dict) and "diff" in result:
|
||||
diff_parts.append(result["diff"])
|
||||
else:
|
||||
step.status = ActionStatus.FAILED
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
step.status = ActionStatus.FAILED
|
||||
step.error = str(e)
|
||||
failed += 1
|
||||
|
||||
plan.status = ActionStatus.APPLIED if failed == 0 else ActionStatus.FAILED
|
||||
|
||||
return ActionResult(
|
||||
success=failed == 0,
|
||||
plan=plan,
|
||||
applied_steps=applied,
|
||||
failed_steps=failed,
|
||||
diff_summary="\n".join(diff_parts) if diff_parts else None
|
||||
)
|
||||
|
||||
async def _apply_step(self, step: ActionStep) -> Optional[Dict[str, Any]]:
|
||||
"""Apply a single step."""
|
||||
workspace = Path(self.runtime.config.workspace)
|
||||
|
||||
if step.action_type == ActionType.FILE_CREATE:
|
||||
target = workspace / step.target
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = step.params.get("content", "")
|
||||
target.write_text(content)
|
||||
return {"created": str(target), "diff": f"+++ {target}\n+ (new file)"}
|
||||
|
||||
elif step.action_type == ActionType.FILE_EDIT:
|
||||
target = workspace / step.target
|
||||
if not target.exists():
|
||||
return None
|
||||
|
||||
old_content = target.read_text()
|
||||
new_content = step.params.get("new_content", old_content)
|
||||
target.write_text(new_content)
|
||||
|
||||
return {
|
||||
"edited": str(target),
|
||||
"diff": f"--- {target}\n+++ {target}\n(content changed)"
|
||||
}
|
||||
|
||||
elif step.action_type == ActionType.FILE_DELETE:
|
||||
target = workspace / step.target
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
return {"deleted": str(target), "diff": f"--- {target}\n- (deleted)"}
|
||||
return None
|
||||
|
||||
elif step.action_type == ActionType.FILE_RENAME:
|
||||
source = workspace / step.params.get("source", "")
|
||||
dest = workspace / step.params.get("dest", "")
|
||||
if source.exists():
|
||||
source.rename(dest)
|
||||
return {"renamed": f"{source} -> {dest}"}
|
||||
return None
|
||||
|
||||
elif step.action_type == ActionType.SHELL_COMMAND:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
step.target,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(workspace),
|
||||
timeout=30
|
||||
)
|
||||
return {
|
||||
"command": step.target,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"returncode": result.returncode
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
async def verify_plan(self, plan: ActionPlan) -> ActionResult:
|
||||
"""
|
||||
Verify that a plan was applied correctly.
|
||||
|
||||
Args:
|
||||
plan: The plan to verify
|
||||
|
||||
Returns:
|
||||
ActionResult with verification status
|
||||
"""
|
||||
if plan.status != ActionStatus.APPLIED:
|
||||
return ActionResult(
|
||||
success=False,
|
||||
plan=plan,
|
||||
error="Plan was not applied"
|
||||
)
|
||||
|
||||
workspace = Path(self.runtime.config.workspace)
|
||||
verified = 0
|
||||
failed = 0
|
||||
|
||||
for step in plan.steps:
|
||||
if step.status != ActionStatus.APPLIED:
|
||||
continue
|
||||
|
||||
try:
|
||||
if step.action_type == ActionType.FILE_CREATE:
|
||||
target = workspace / step.target
|
||||
if target.exists():
|
||||
step.status = ActionStatus.VERIFIED
|
||||
verified += 1
|
||||
else:
|
||||
step.status = ActionStatus.FAILED
|
||||
step.error = "File was not created"
|
||||
failed += 1
|
||||
|
||||
elif step.action_type == ActionType.FILE_DELETE:
|
||||
target = workspace / step.target
|
||||
if not target.exists():
|
||||
step.status = ActionStatus.VERIFIED
|
||||
verified += 1
|
||||
else:
|
||||
step.status = ActionStatus.FAILED
|
||||
step.error = "File was not deleted"
|
||||
failed += 1
|
||||
|
||||
else:
|
||||
# For other types, assume verified if applied
|
||||
step.status = ActionStatus.VERIFIED
|
||||
verified += 1
|
||||
|
||||
except Exception as e:
|
||||
step.status = ActionStatus.FAILED
|
||||
step.error = str(e)
|
||||
failed += 1
|
||||
|
||||
plan.status = ActionStatus.VERIFIED if failed == 0 else ActionStatus.FAILED
|
||||
|
||||
return ActionResult(
|
||||
success=failed == 0,
|
||||
plan=plan,
|
||||
applied_steps=verified,
|
||||
failed_steps=failed
|
||||
)
|
||||
|
||||
async def execute(self, prompt: str, auto_approve: bool = False) -> ActionResult:
|
||||
"""
|
||||
Execute a full plan → approve → apply → verify cycle.
|
||||
|
||||
Args:
|
||||
prompt: The modification request
|
||||
auto_approve: Whether to auto-approve
|
||||
|
||||
Returns:
|
||||
ActionResult with final status
|
||||
"""
|
||||
# Create plan
|
||||
result = await self.create_plan(prompt)
|
||||
if not result.success:
|
||||
return result
|
||||
|
||||
plan = result.plan
|
||||
|
||||
# Approve
|
||||
approved = await self.approve_plan(plan, auto=auto_approve)
|
||||
if not approved and self.runtime.config.approval_mode == "manual":
|
||||
return ActionResult(
|
||||
success=False,
|
||||
plan=plan,
|
||||
error="Plan requires manual approval"
|
||||
)
|
||||
|
||||
# Apply
|
||||
result = await self.apply_plan(plan)
|
||||
if not result.success:
|
||||
return result
|
||||
|
||||
# Verify
|
||||
return await self.verify_plan(plan)
|
||||
@@ -0,0 +1,474 @@
|
||||
"""
|
||||
Agent-Centric Tools for PraisonAI Interactive Mode.
|
||||
|
||||
These tools route file operations and code intelligence through LSP/ACP,
|
||||
making the Agent the central orchestrator for all actions.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Callable, Dict, List, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .interactive_runtime import InteractiveRuntime
|
||||
from .code_intelligence import CodeIntelligenceRouter
|
||||
from .action_orchestrator import ActionOrchestrator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_agent_centric_tools(
|
||||
runtime: "InteractiveRuntime",
|
||||
router: "CodeIntelligenceRouter" = None,
|
||||
orchestrator: "ActionOrchestrator" = None
|
||||
) -> List[Callable]:
|
||||
"""
|
||||
Create tools that route through LSP/ACP for agent-centric architecture.
|
||||
|
||||
Args:
|
||||
runtime: The InteractiveRuntime instance
|
||||
router: Optional CodeIntelligenceRouter (created if not provided)
|
||||
orchestrator: Optional ActionOrchestrator (created if not provided)
|
||||
|
||||
Returns:
|
||||
List of tool functions for the Agent
|
||||
"""
|
||||
from .code_intelligence import CodeIntelligenceRouter
|
||||
from .action_orchestrator import ActionOrchestrator
|
||||
|
||||
if router is None:
|
||||
router = CodeIntelligenceRouter(runtime)
|
||||
if orchestrator is None:
|
||||
orchestrator = ActionOrchestrator(runtime)
|
||||
|
||||
# Helper to run async functions synchronously
|
||||
def run_async(coro):
|
||||
"""Run async coroutine synchronously."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Create a new loop in a thread
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(asyncio.run, coro)
|
||||
return future.result(timeout=60)
|
||||
else:
|
||||
return loop.run_until_complete(coro)
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
|
||||
# =========================================================================
|
||||
# ACP-Powered File Tools
|
||||
# =========================================================================
|
||||
|
||||
def acp_create_file(filepath: str, content: str) -> str:
|
||||
"""
|
||||
Create a file through ACP with plan/approve/apply/verify flow.
|
||||
|
||||
This tool routes file creation through the ActionOrchestrator,
|
||||
ensuring proper tracking, approval, and verification.
|
||||
|
||||
Args:
|
||||
filepath: Path to the file to create (relative to workspace)
|
||||
content: Content to write to the file
|
||||
|
||||
Returns:
|
||||
JSON string with result including plan status and verification
|
||||
"""
|
||||
async def _create():
|
||||
# Build a detailed prompt for the orchestrator
|
||||
prompt = f"create file {filepath}"
|
||||
|
||||
# Create plan
|
||||
result = await orchestrator.create_plan(prompt)
|
||||
if not result.success:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": result.error,
|
||||
"read_only": result.read_only_blocked
|
||||
})
|
||||
|
||||
plan = result.plan
|
||||
|
||||
# Update the plan step with actual content
|
||||
if plan.steps:
|
||||
plan.steps[0].params["content"] = content
|
||||
|
||||
# Approve based on runtime config
|
||||
auto_approve = runtime.config.approval_mode == "auto"
|
||||
approved = await orchestrator.approve_plan(plan, auto=auto_approve)
|
||||
|
||||
if not approved and runtime.config.approval_mode == "manual":
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Manual approval required",
|
||||
"plan": plan.to_dict(),
|
||||
"requires_approval": True
|
||||
})
|
||||
|
||||
# Apply the plan
|
||||
result = await orchestrator.apply_plan(plan, force=auto_approve)
|
||||
if not result.success:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": result.error,
|
||||
"plan": plan.to_dict()
|
||||
})
|
||||
|
||||
# Verify
|
||||
result = await orchestrator.verify_plan(plan)
|
||||
|
||||
return json.dumps({
|
||||
"success": result.success,
|
||||
"file_created": filepath,
|
||||
"plan_id": plan.id,
|
||||
"status": plan.status.value,
|
||||
"verified": result.success
|
||||
})
|
||||
|
||||
return run_async(_create())
|
||||
|
||||
def acp_edit_file(filepath: str, new_content: str) -> str:
|
||||
"""
|
||||
Edit a file through ACP with plan/approve/apply/verify flow.
|
||||
|
||||
Args:
|
||||
filepath: Path to the file to edit (relative to workspace)
|
||||
new_content: New content for the file
|
||||
|
||||
Returns:
|
||||
JSON string with result including plan status
|
||||
"""
|
||||
async def _edit():
|
||||
prompt = f"edit file {filepath}"
|
||||
|
||||
result = await orchestrator.create_plan(prompt)
|
||||
if not result.success:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": result.error,
|
||||
"read_only": result.read_only_blocked
|
||||
})
|
||||
|
||||
plan = result.plan
|
||||
|
||||
# Update step with new content
|
||||
if plan.steps:
|
||||
plan.steps[0].params["new_content"] = new_content
|
||||
|
||||
auto_approve = runtime.config.approval_mode == "auto"
|
||||
approved = await orchestrator.approve_plan(plan, auto=auto_approve)
|
||||
|
||||
if not approved and runtime.config.approval_mode == "manual":
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Manual approval required",
|
||||
"requires_approval": True
|
||||
})
|
||||
|
||||
result = await orchestrator.apply_plan(plan, force=auto_approve)
|
||||
|
||||
return json.dumps({
|
||||
"success": result.success,
|
||||
"file_edited": filepath,
|
||||
"plan_id": plan.id,
|
||||
"error": result.error
|
||||
})
|
||||
|
||||
return run_async(_edit())
|
||||
|
||||
def acp_delete_file(filepath: str) -> str:
|
||||
"""
|
||||
Delete a file through ACP with plan/approve/apply/verify flow.
|
||||
|
||||
Args:
|
||||
filepath: Path to the file to delete
|
||||
|
||||
Returns:
|
||||
JSON string with result
|
||||
"""
|
||||
async def _delete():
|
||||
prompt = f"delete file {filepath}"
|
||||
|
||||
result = await orchestrator.create_plan(prompt)
|
||||
if not result.success:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": result.error
|
||||
})
|
||||
|
||||
plan = result.plan
|
||||
|
||||
# Delete requires explicit approval even in auto mode
|
||||
auto_approve = runtime.config.approval_mode == "auto"
|
||||
approved = await orchestrator.approve_plan(plan, auto=auto_approve)
|
||||
|
||||
if not approved:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Delete requires approval",
|
||||
"requires_approval": True
|
||||
})
|
||||
|
||||
result = await orchestrator.apply_plan(plan)
|
||||
result = await orchestrator.verify_plan(plan)
|
||||
|
||||
return json.dumps({
|
||||
"success": result.success,
|
||||
"file_deleted": filepath,
|
||||
"verified": result.success
|
||||
})
|
||||
|
||||
return run_async(_delete())
|
||||
|
||||
def acp_execute_command(command: str, cwd: str = None) -> str:
|
||||
"""
|
||||
Execute a shell command through ACP with tracking.
|
||||
|
||||
Args:
|
||||
command: The command to execute
|
||||
cwd: Working directory (optional)
|
||||
|
||||
Returns:
|
||||
JSON string with command output
|
||||
"""
|
||||
async def _execute():
|
||||
prompt = f"run command: {command}"
|
||||
|
||||
result = await orchestrator.create_plan(prompt)
|
||||
if not result.success:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": result.error
|
||||
})
|
||||
|
||||
plan = result.plan
|
||||
|
||||
# Commands require approval
|
||||
auto_approve = runtime.config.approval_mode == "auto"
|
||||
approved = await orchestrator.approve_plan(plan, auto=auto_approve)
|
||||
|
||||
if not approved:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Command requires approval",
|
||||
"requires_approval": True
|
||||
})
|
||||
|
||||
result = await orchestrator.apply_plan(plan)
|
||||
|
||||
# Extract command result
|
||||
if plan.steps and plan.steps[0].result:
|
||||
cmd_result = plan.steps[0].result
|
||||
return json.dumps({
|
||||
"success": result.success,
|
||||
"command": command,
|
||||
"stdout": cmd_result.get("stdout", ""),
|
||||
"stderr": cmd_result.get("stderr", ""),
|
||||
"returncode": cmd_result.get("returncode", -1)
|
||||
})
|
||||
|
||||
return json.dumps({
|
||||
"success": result.success,
|
||||
"error": result.error
|
||||
})
|
||||
|
||||
return run_async(_execute())
|
||||
|
||||
# =========================================================================
|
||||
# LSP-Powered Code Intelligence Tools
|
||||
# =========================================================================
|
||||
|
||||
def lsp_list_symbols(file_path: str) -> str:
|
||||
"""
|
||||
List all symbols (functions, classes, methods) in a file using LSP.
|
||||
|
||||
Falls back to regex-based extraction if LSP is unavailable.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to analyze
|
||||
|
||||
Returns:
|
||||
JSON string with list of symbols and their locations
|
||||
"""
|
||||
async def _list():
|
||||
result = await router.handle_query(
|
||||
f"list all functions and classes in {file_path}",
|
||||
file_path=file_path
|
||||
)
|
||||
return json.dumps(result.to_dict())
|
||||
|
||||
return run_async(_list())
|
||||
|
||||
def lsp_find_definition(symbol: str, file_path: str = None) -> str:
|
||||
"""
|
||||
Find where a symbol is defined using LSP.
|
||||
|
||||
Args:
|
||||
symbol: The symbol name to find
|
||||
file_path: Optional file path for context
|
||||
|
||||
Returns:
|
||||
JSON string with definition location(s)
|
||||
"""
|
||||
async def _find():
|
||||
query = f"where is {symbol} defined"
|
||||
if file_path:
|
||||
query += f" in {file_path}"
|
||||
|
||||
result = await router.handle_query(query, file_path=file_path)
|
||||
return json.dumps(result.to_dict())
|
||||
|
||||
return run_async(_find())
|
||||
|
||||
def lsp_find_references(symbol: str, file_path: str = None) -> str:
|
||||
"""
|
||||
Find all references to a symbol using LSP.
|
||||
|
||||
Args:
|
||||
symbol: The symbol name to find references for
|
||||
file_path: Optional file path for context
|
||||
|
||||
Returns:
|
||||
JSON string with reference locations
|
||||
"""
|
||||
async def _find():
|
||||
query = f"find all references to {symbol}"
|
||||
if file_path:
|
||||
query += f" in {file_path}"
|
||||
|
||||
result = await router.handle_query(query, file_path=file_path)
|
||||
return json.dumps(result.to_dict())
|
||||
|
||||
return run_async(_find())
|
||||
|
||||
def lsp_get_diagnostics(file_path: str = None) -> str:
|
||||
"""
|
||||
Get diagnostics (errors, warnings) for a file using LSP.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file (optional, gets all if not specified)
|
||||
|
||||
Returns:
|
||||
JSON string with diagnostic information
|
||||
"""
|
||||
async def _get():
|
||||
query = "show all errors and warnings"
|
||||
if file_path:
|
||||
query += f" in {file_path}"
|
||||
|
||||
result = await router.handle_query(query, file_path=file_path)
|
||||
return json.dumps(result.to_dict())
|
||||
|
||||
return run_async(_get())
|
||||
|
||||
# =========================================================================
|
||||
# Basic File Tools (for read-only operations)
|
||||
# =========================================================================
|
||||
|
||||
def read_file(filepath: str) -> str:
|
||||
"""
|
||||
Read content from a file.
|
||||
|
||||
This is a read-only operation that doesn't require ACP.
|
||||
|
||||
Args:
|
||||
filepath: Path to the file to read
|
||||
|
||||
Returns:
|
||||
File content or error message
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
path = Path(filepath)
|
||||
if not path.is_absolute():
|
||||
path = Path(runtime.config.workspace) / filepath
|
||||
|
||||
if not path.exists():
|
||||
return json.dumps({"error": f"File not found: {filepath}"})
|
||||
|
||||
content = path.read_text()
|
||||
|
||||
# Track in trace if enabled
|
||||
if runtime._trace:
|
||||
runtime._trace.add_entry(
|
||||
category="file",
|
||||
action="read",
|
||||
params={"file": str(path)},
|
||||
result={"size": len(content)}
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
def list_files(directory: str = ".", pattern: str = "*") -> str:
|
||||
"""
|
||||
List files in a directory.
|
||||
|
||||
Args:
|
||||
directory: Directory to list (relative to workspace)
|
||||
pattern: Glob pattern to filter files
|
||||
|
||||
Returns:
|
||||
JSON string with list of files
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
path = Path(directory)
|
||||
if not path.is_absolute():
|
||||
path = Path(runtime.config.workspace) / directory
|
||||
|
||||
if not path.exists():
|
||||
return json.dumps({"error": f"Directory not found: {directory}"})
|
||||
|
||||
files = []
|
||||
for f in path.glob(pattern):
|
||||
files.append({
|
||||
"name": f.name,
|
||||
"path": str(f.relative_to(runtime.config.workspace)),
|
||||
"is_dir": f.is_dir(),
|
||||
"size": f.stat().st_size if f.is_file() else None
|
||||
})
|
||||
|
||||
return json.dumps({"files": files, "count": len(files)})
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
# Return all tools
|
||||
return [
|
||||
# ACP-powered file tools
|
||||
acp_create_file,
|
||||
acp_edit_file,
|
||||
acp_delete_file,
|
||||
acp_execute_command,
|
||||
# LSP-powered code intelligence
|
||||
lsp_list_symbols,
|
||||
lsp_find_definition,
|
||||
lsp_find_references,
|
||||
lsp_get_diagnostics,
|
||||
# Basic read-only tools
|
||||
read_file,
|
||||
list_files,
|
||||
]
|
||||
|
||||
|
||||
def get_tool_descriptions() -> Dict[str, str]:
|
||||
"""Get descriptions of all agent-centric tools."""
|
||||
return {
|
||||
"acp_create_file": "Create a file through ACP with plan/approve/apply/verify",
|
||||
"acp_edit_file": "Edit a file through ACP with tracking",
|
||||
"acp_delete_file": "Delete a file through ACP (requires approval)",
|
||||
"acp_execute_command": "Execute a shell command through ACP",
|
||||
"lsp_list_symbols": "List symbols in a file using LSP",
|
||||
"lsp_find_definition": "Find where a symbol is defined",
|
||||
"lsp_find_references": "Find all references to a symbol",
|
||||
"lsp_get_diagnostics": "Get errors and warnings for a file",
|
||||
"read_file": "Read content from a file (read-only)",
|
||||
"list_files": "List files in a directory",
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
"""
|
||||
Code Intelligence Router for PraisonAI.
|
||||
|
||||
Routes code-related queries to LSP when available, with fallback to grep/file search.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .interactive_runtime import InteractiveRuntime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CodeIntent(Enum):
|
||||
"""Classification of code-related intents."""
|
||||
LIST_SYMBOLS = "list_symbols"
|
||||
GO_TO_DEFINITION = "go_to_definition"
|
||||
FIND_REFERENCES = "find_references"
|
||||
GET_DIAGNOSTICS = "get_diagnostics"
|
||||
EXPLAIN_CODE = "explain_code"
|
||||
SEARCH_CODE = "search_code"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodeQueryResult:
|
||||
"""Result of a code intelligence query."""
|
||||
intent: CodeIntent
|
||||
success: bool
|
||||
lsp_used: bool
|
||||
data: Any = None
|
||||
citations: List[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
fallback_used: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
if self.citations is None:
|
||||
self.citations = []
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"intent": self.intent.value,
|
||||
"success": self.success,
|
||||
"lsp_used": self.lsp_used,
|
||||
"data": self.data,
|
||||
"citations": self.citations,
|
||||
"error": self.error,
|
||||
"fallback_used": self.fallback_used
|
||||
}
|
||||
|
||||
|
||||
class CodeIntelligenceRouter:
|
||||
"""
|
||||
Routes code intelligence queries to LSP or fallback mechanisms.
|
||||
|
||||
LSP-first approach:
|
||||
- If LSP is available, use it for semantic queries
|
||||
- If LSP fails or unavailable, fall back to grep/file search
|
||||
- Always provide citations with file:line references
|
||||
"""
|
||||
|
||||
# Patterns for intent classification
|
||||
INTENT_PATTERNS = {
|
||||
CodeIntent.LIST_SYMBOLS: [
|
||||
r"list\s+(all\s+)?(functions?|classes?|methods?|symbols?)",
|
||||
r"what\s+(functions?|classes?|methods?)\s+(are|exist)",
|
||||
r"show\s+(me\s+)?(all\s+)?(functions?|classes?|symbols?)",
|
||||
r"find\s+(all\s+)?(functions?|classes?|methods?)",
|
||||
],
|
||||
CodeIntent.GO_TO_DEFINITION: [
|
||||
r"(go\s+to|find|show|where\s+is)\s+(the\s+)?definition",
|
||||
r"where\s+is\s+(\w+)\s+defined",
|
||||
r"definition\s+of\s+(\w+)",
|
||||
r"(\w+)\s+is\s+defined\s+where",
|
||||
],
|
||||
CodeIntent.FIND_REFERENCES: [
|
||||
r"(find|show|list)\s+(all\s+)?references",
|
||||
r"where\s+is\s+(\w+)\s+used",
|
||||
r"usages?\s+of\s+(\w+)",
|
||||
r"who\s+(calls?|uses?)\s+(\w+)",
|
||||
],
|
||||
CodeIntent.GET_DIAGNOSTICS: [
|
||||
r"(show|list|get)\s+(all\s+)?(errors?|warnings?|diagnostics?|problems?)",
|
||||
r"what('s|\s+is)\s+wrong",
|
||||
r"any\s+(errors?|issues?|problems?)",
|
||||
r"check\s+(for\s+)?(errors?|issues?)",
|
||||
],
|
||||
CodeIntent.EXPLAIN_CODE: [
|
||||
r"explain\s+(this|the)\s+code",
|
||||
r"what\s+does\s+(this|the)\s+code\s+do",
|
||||
r"how\s+does\s+(\w+)\s+work",
|
||||
],
|
||||
CodeIntent.SEARCH_CODE: [
|
||||
r"search\s+(for\s+)?",
|
||||
r"find\s+(the\s+)?string",
|
||||
r"grep\s+",
|
||||
],
|
||||
}
|
||||
|
||||
def __init__(self, runtime: "InteractiveRuntime"):
|
||||
"""Initialize with runtime reference."""
|
||||
self.runtime = runtime
|
||||
|
||||
def classify_intent(self, query: str) -> CodeIntent:
|
||||
"""Classify the intent of a code query."""
|
||||
query_lower = query.lower()
|
||||
|
||||
for intent, patterns in self.INTENT_PATTERNS.items():
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, query_lower):
|
||||
return intent
|
||||
|
||||
return CodeIntent.UNKNOWN
|
||||
|
||||
async def handle_query(self, query: str, file_path: str = None) -> CodeQueryResult:
|
||||
"""
|
||||
Handle a code intelligence query.
|
||||
|
||||
Args:
|
||||
query: The user's query
|
||||
file_path: Optional file path context
|
||||
|
||||
Returns:
|
||||
CodeQueryResult with data and citations
|
||||
"""
|
||||
intent = self.classify_intent(query)
|
||||
|
||||
if intent == CodeIntent.LIST_SYMBOLS:
|
||||
return await self._handle_list_symbols(query, file_path)
|
||||
elif intent == CodeIntent.GO_TO_DEFINITION:
|
||||
return await self._handle_go_to_definition(query, file_path)
|
||||
elif intent == CodeIntent.FIND_REFERENCES:
|
||||
return await self._handle_find_references(query, file_path)
|
||||
elif intent == CodeIntent.GET_DIAGNOSTICS:
|
||||
return await self._handle_get_diagnostics(query, file_path)
|
||||
elif intent == CodeIntent.SEARCH_CODE:
|
||||
return await self._handle_search_code(query, file_path)
|
||||
else:
|
||||
return CodeQueryResult(
|
||||
intent=intent,
|
||||
success=False,
|
||||
lsp_used=False,
|
||||
error="Could not determine code query intent"
|
||||
)
|
||||
|
||||
async def _handle_list_symbols(self, query: str, file_path: str = None) -> CodeQueryResult:
|
||||
"""Handle list symbols query."""
|
||||
if not file_path:
|
||||
# Try to find a file from the query
|
||||
file_path = self._extract_file_from_query(query)
|
||||
|
||||
if not file_path:
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.LIST_SYMBOLS,
|
||||
success=False,
|
||||
lsp_used=False,
|
||||
error="No file specified. Please specify a file path."
|
||||
)
|
||||
|
||||
# Try LSP first
|
||||
if self.runtime.lsp_ready:
|
||||
try:
|
||||
symbols = await self.runtime.lsp_get_symbols(file_path)
|
||||
if symbols:
|
||||
citations = [
|
||||
{"file": file_path, "type": "symbols", "count": len(symbols)}
|
||||
]
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.LIST_SYMBOLS,
|
||||
success=True,
|
||||
lsp_used=True,
|
||||
data=symbols,
|
||||
citations=citations
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"LSP symbols failed: {e}")
|
||||
|
||||
# Fallback to regex-based symbol extraction
|
||||
return await self._fallback_list_symbols(file_path)
|
||||
|
||||
async def _fallback_list_symbols(self, file_path: str) -> CodeQueryResult:
|
||||
"""Fallback symbol listing using regex."""
|
||||
try:
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
path = Path(self.runtime.config.workspace) / file_path
|
||||
|
||||
if not path.exists():
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.LIST_SYMBOLS,
|
||||
success=False,
|
||||
lsp_used=False,
|
||||
fallback_used=True,
|
||||
error=f"File not found: {file_path}"
|
||||
)
|
||||
|
||||
content = path.read_text()
|
||||
symbols = []
|
||||
|
||||
# Python patterns
|
||||
if path.suffix == ".py":
|
||||
# Functions
|
||||
for match in re.finditer(r'^(async\s+)?def\s+(\w+)\s*\(', content, re.MULTILINE):
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
symbols.append({
|
||||
"name": match.group(2),
|
||||
"kind": "function",
|
||||
"line": line_num
|
||||
})
|
||||
# Classes
|
||||
for match in re.finditer(r'^class\s+(\w+)\s*[:\(]', content, re.MULTILINE):
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
symbols.append({
|
||||
"name": match.group(1),
|
||||
"kind": "class",
|
||||
"line": line_num
|
||||
})
|
||||
|
||||
# JavaScript/TypeScript patterns
|
||||
elif path.suffix in [".js", ".ts", ".jsx", ".tsx"]:
|
||||
# Functions
|
||||
for match in re.finditer(r'(?:async\s+)?function\s+(\w+)\s*\(', content):
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
symbols.append({
|
||||
"name": match.group(1),
|
||||
"kind": "function",
|
||||
"line": line_num
|
||||
})
|
||||
# Arrow functions assigned to const/let/var
|
||||
for match in re.finditer(r'(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(', content):
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
symbols.append({
|
||||
"name": match.group(1),
|
||||
"kind": "function",
|
||||
"line": line_num
|
||||
})
|
||||
# Classes
|
||||
for match in re.finditer(r'class\s+(\w+)\s*(?:extends|implements|{)', content):
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
symbols.append({
|
||||
"name": match.group(1),
|
||||
"kind": "class",
|
||||
"line": line_num
|
||||
})
|
||||
|
||||
citations = [
|
||||
{"file": str(path), "type": "symbols", "count": len(symbols)}
|
||||
]
|
||||
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.LIST_SYMBOLS,
|
||||
success=True,
|
||||
lsp_used=False,
|
||||
fallback_used=True,
|
||||
data=symbols,
|
||||
citations=citations
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.LIST_SYMBOLS,
|
||||
success=False,
|
||||
lsp_used=False,
|
||||
fallback_used=True,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def _handle_go_to_definition(self, query: str, file_path: str = None) -> CodeQueryResult:
|
||||
"""Handle go to definition query."""
|
||||
# Extract symbol name from query
|
||||
symbol = self._extract_symbol_from_query(query)
|
||||
|
||||
if not symbol:
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.GO_TO_DEFINITION,
|
||||
success=False,
|
||||
lsp_used=False,
|
||||
error="Could not identify symbol to find definition for"
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
file_path = self._extract_file_from_query(query)
|
||||
|
||||
# Try LSP first
|
||||
if self.runtime.lsp_ready and file_path:
|
||||
try:
|
||||
# Need to find the symbol position first
|
||||
line, col = await self._find_symbol_position(file_path, symbol)
|
||||
if line is not None:
|
||||
definitions = await self.runtime.lsp_get_definition(file_path, line, col)
|
||||
if definitions:
|
||||
citations = [
|
||||
{"file": d.get("uri", "").replace("file://", ""),
|
||||
"line": d.get("range", {}).get("start", {}).get("line", 0) + 1,
|
||||
"type": "definition"}
|
||||
for d in definitions
|
||||
]
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.GO_TO_DEFINITION,
|
||||
success=True,
|
||||
lsp_used=True,
|
||||
data={"symbol": symbol, "definitions": definitions},
|
||||
citations=citations
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"LSP definition failed: {e}")
|
||||
|
||||
# Fallback to grep
|
||||
return await self._fallback_find_definition(symbol)
|
||||
|
||||
async def _fallback_find_definition(self, symbol: str) -> CodeQueryResult:
|
||||
"""Fallback definition finding using grep."""
|
||||
try:
|
||||
import subprocess
|
||||
workspace = self.runtime.config.workspace
|
||||
|
||||
# Search for definition patterns
|
||||
patterns = [
|
||||
f"def {symbol}\\s*\\(", # Python function
|
||||
f"class {symbol}\\s*[:\\(]", # Python class
|
||||
f"function {symbol}\\s*\\(", # JS function
|
||||
f"const {symbol}\\s*=", # JS const
|
||||
f"let {symbol}\\s*=", # JS let
|
||||
]
|
||||
|
||||
results = []
|
||||
for pattern in patterns:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["grep", "-rn", "-E", pattern, workspace],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.stdout:
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if line:
|
||||
parts = line.split(':', 2)
|
||||
if len(parts) >= 2:
|
||||
results.append({
|
||||
"file": parts[0],
|
||||
"line": int(parts[1]),
|
||||
"content": parts[2] if len(parts) > 2 else ""
|
||||
})
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
|
||||
if results:
|
||||
citations = [
|
||||
{"file": r["file"], "line": r["line"], "type": "definition"}
|
||||
for r in results
|
||||
]
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.GO_TO_DEFINITION,
|
||||
success=True,
|
||||
lsp_used=False,
|
||||
fallback_used=True,
|
||||
data={"symbol": symbol, "definitions": results},
|
||||
citations=citations
|
||||
)
|
||||
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.GO_TO_DEFINITION,
|
||||
success=False,
|
||||
lsp_used=False,
|
||||
fallback_used=True,
|
||||
error=f"Definition for '{symbol}' not found"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.GO_TO_DEFINITION,
|
||||
success=False,
|
||||
lsp_used=False,
|
||||
fallback_used=True,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def _handle_find_references(self, query: str, file_path: str = None) -> CodeQueryResult:
|
||||
"""Handle find references query."""
|
||||
symbol = self._extract_symbol_from_query(query)
|
||||
|
||||
if not symbol:
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.FIND_REFERENCES,
|
||||
success=False,
|
||||
lsp_used=False,
|
||||
error="Could not identify symbol to find references for"
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
file_path = self._extract_file_from_query(query)
|
||||
|
||||
# Try LSP first
|
||||
if self.runtime.lsp_ready and file_path:
|
||||
try:
|
||||
line, col = await self._find_symbol_position(file_path, symbol)
|
||||
if line is not None:
|
||||
references = await self.runtime.lsp_get_references(file_path, line, col)
|
||||
if references:
|
||||
citations = [
|
||||
{"file": r.get("uri", "").replace("file://", ""),
|
||||
"line": r.get("range", {}).get("start", {}).get("line", 0) + 1,
|
||||
"type": "reference"}
|
||||
for r in references
|
||||
]
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.FIND_REFERENCES,
|
||||
success=True,
|
||||
lsp_used=True,
|
||||
data={"symbol": symbol, "references": references},
|
||||
citations=citations
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"LSP references failed: {e}")
|
||||
|
||||
# Fallback to grep
|
||||
return await self._fallback_find_references(symbol)
|
||||
|
||||
async def _fallback_find_references(self, symbol: str) -> CodeQueryResult:
|
||||
"""Fallback reference finding using grep."""
|
||||
try:
|
||||
import subprocess
|
||||
workspace = self.runtime.config.workspace
|
||||
|
||||
result = subprocess.run(
|
||||
["grep", "-rn", "-w", symbol, workspace],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
results = []
|
||||
if result.stdout:
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if line:
|
||||
parts = line.split(':', 2)
|
||||
if len(parts) >= 2:
|
||||
results.append({
|
||||
"file": parts[0],
|
||||
"line": int(parts[1]),
|
||||
"content": parts[2] if len(parts) > 2 else ""
|
||||
})
|
||||
|
||||
if results:
|
||||
citations = [
|
||||
{"file": r["file"], "line": r["line"], "type": "reference"}
|
||||
for r in results
|
||||
]
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.FIND_REFERENCES,
|
||||
success=True,
|
||||
lsp_used=False,
|
||||
fallback_used=True,
|
||||
data={"symbol": symbol, "references": results},
|
||||
citations=citations
|
||||
)
|
||||
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.FIND_REFERENCES,
|
||||
success=False,
|
||||
lsp_used=False,
|
||||
fallback_used=True,
|
||||
error=f"No references found for '{symbol}'"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.FIND_REFERENCES,
|
||||
success=False,
|
||||
lsp_used=False,
|
||||
fallback_used=True,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def _handle_get_diagnostics(self, query: str, file_path: str = None) -> CodeQueryResult:
|
||||
"""Handle get diagnostics query."""
|
||||
if not file_path:
|
||||
file_path = self._extract_file_from_query(query)
|
||||
|
||||
# Try LSP
|
||||
if self.runtime.lsp_ready:
|
||||
try:
|
||||
diagnostics = await self.runtime.lsp_get_diagnostics(file_path)
|
||||
citations = []
|
||||
if diagnostics:
|
||||
for d in diagnostics:
|
||||
if isinstance(d, dict):
|
||||
citations.append({
|
||||
"file": file_path or "workspace",
|
||||
"line": d.get("range", {}).get("start", {}).get("line", 0) + 1,
|
||||
"type": "diagnostic",
|
||||
"severity": d.get("severity", "unknown")
|
||||
})
|
||||
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.GET_DIAGNOSTICS,
|
||||
success=True,
|
||||
lsp_used=True,
|
||||
data=diagnostics,
|
||||
citations=citations
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"LSP diagnostics failed: {e}")
|
||||
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.GET_DIAGNOSTICS,
|
||||
success=False,
|
||||
lsp_used=False,
|
||||
error="LSP not available for diagnostics"
|
||||
)
|
||||
|
||||
async def _handle_search_code(self, query: str, file_path: str = None) -> CodeQueryResult:
|
||||
"""Handle code search query."""
|
||||
# Extract search term
|
||||
search_term = self._extract_search_term(query)
|
||||
|
||||
if not search_term:
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.SEARCH_CODE,
|
||||
success=False,
|
||||
lsp_used=False,
|
||||
error="Could not identify search term"
|
||||
)
|
||||
|
||||
try:
|
||||
import subprocess
|
||||
workspace = self.runtime.config.workspace
|
||||
|
||||
cmd = ["grep", "-rn", search_term]
|
||||
if file_path:
|
||||
cmd.append(file_path)
|
||||
else:
|
||||
cmd.append(workspace)
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
results = []
|
||||
if result.stdout:
|
||||
for line in result.stdout.strip().split('\n')[:50]: # Limit results
|
||||
if line:
|
||||
parts = line.split(':', 2)
|
||||
if len(parts) >= 2:
|
||||
results.append({
|
||||
"file": parts[0],
|
||||
"line": int(parts[1]),
|
||||
"content": parts[2] if len(parts) > 2 else ""
|
||||
})
|
||||
|
||||
citations = [
|
||||
{"file": r["file"], "line": r["line"], "type": "search_result"}
|
||||
for r in results
|
||||
]
|
||||
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.SEARCH_CODE,
|
||||
success=True,
|
||||
lsp_used=False,
|
||||
data={"search_term": search_term, "results": results},
|
||||
citations=citations
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return CodeQueryResult(
|
||||
intent=CodeIntent.SEARCH_CODE,
|
||||
success=False,
|
||||
lsp_used=False,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
def _extract_file_from_query(self, query: str) -> Optional[str]:
|
||||
"""Extract file path from query."""
|
||||
# Look for file patterns
|
||||
patterns = [
|
||||
r'(?:in|from|file)\s+["\']?([^\s"\']+\.\w+)["\']?',
|
||||
r'([^\s]+\.(?:py|js|ts|tsx|jsx|go|rs|java|cpp|c|h))',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, query, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
def _extract_symbol_from_query(self, query: str) -> Optional[str]:
|
||||
"""Extract symbol name from query."""
|
||||
# Common patterns for symbol extraction
|
||||
patterns = [
|
||||
r'(?:definition\s+of|where\s+is|find|show)\s+["\']?(\w+)["\']?',
|
||||
r'["\'](\w+)["\']',
|
||||
r'`(\w+)`',
|
||||
r'(\w+)\s+(?:is\s+)?(?:defined|used)',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, query, re.IGNORECASE)
|
||||
if match:
|
||||
symbol = match.group(1)
|
||||
# Filter out common words
|
||||
if symbol.lower() not in ['the', 'a', 'an', 'is', 'are', 'in', 'of', 'to', 'for']:
|
||||
return symbol
|
||||
|
||||
return None
|
||||
|
||||
def _extract_search_term(self, query: str) -> Optional[str]:
|
||||
"""Extract search term from query."""
|
||||
patterns = [
|
||||
r'search\s+(?:for\s+)?["\']([^"\']+)["\']',
|
||||
r'find\s+["\']([^"\']+)["\']',
|
||||
r'grep\s+["\']?([^\s"\']+)["\']?',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, query, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
async def _find_symbol_position(self, file_path: str, symbol: str) -> tuple:
|
||||
"""Find the position of a symbol in a file."""
|
||||
try:
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
path = Path(self.runtime.config.workspace) / file_path
|
||||
|
||||
if not path.exists():
|
||||
return None, None
|
||||
|
||||
content = path.read_text()
|
||||
lines = content.split('\n')
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
col = line.find(symbol)
|
||||
if col >= 0:
|
||||
return i, col
|
||||
|
||||
return None, None
|
||||
except Exception:
|
||||
return None, None
|
||||
@@ -0,0 +1,810 @@
|
||||
"""
|
||||
Debug CLI handler for PraisonAI.
|
||||
|
||||
Provides non-interactive debug commands for testing interactive flows:
|
||||
- debug interactive: Run single interactive turn
|
||||
- debug lsp: Direct LSP probes
|
||||
- debug acp: Direct ACP probes
|
||||
- debug trace: Trace record/replay/diff
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DebugHandler:
|
||||
"""Handler for debug CLI commands."""
|
||||
|
||||
@staticmethod
|
||||
def setup_subparser(subparsers) -> None:
|
||||
"""Set up the debug subcommand parser."""
|
||||
debug_parser = subparsers.add_parser(
|
||||
"debug",
|
||||
help="Debug and test interactive flows",
|
||||
description="Debug commands for testing interactive coding assistant flows"
|
||||
)
|
||||
|
||||
debug_subparsers = debug_parser.add_subparsers(dest="debug_command", help="Debug subcommands")
|
||||
|
||||
# debug interactive
|
||||
interactive_parser = debug_subparsers.add_parser(
|
||||
"interactive",
|
||||
help="Run single interactive turn non-interactively",
|
||||
description="Execute one interactive turn with full LSP/ACP pipeline"
|
||||
)
|
||||
interactive_parser.add_argument(
|
||||
"-p", "--prompt",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Prompt to execute"
|
||||
)
|
||||
interactive_parser.add_argument(
|
||||
"--lsp",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="Enable LSP (default: enabled)"
|
||||
)
|
||||
interactive_parser.add_argument(
|
||||
"--no-lsp",
|
||||
action="store_true",
|
||||
help="Disable LSP"
|
||||
)
|
||||
interactive_parser.add_argument(
|
||||
"--acp",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="Enable ACP (default: enabled)"
|
||||
)
|
||||
interactive_parser.add_argument(
|
||||
"--no-acp",
|
||||
action="store_true",
|
||||
help="Disable ACP"
|
||||
)
|
||||
interactive_parser.add_argument(
|
||||
"--approval",
|
||||
type=str,
|
||||
choices=["manual", "auto", "scoped"],
|
||||
default="auto",
|
||||
help="Approval mode (default: auto for debug)"
|
||||
)
|
||||
interactive_parser.add_argument(
|
||||
"--workspace", "-w",
|
||||
type=str,
|
||||
default=".",
|
||||
help="Workspace root directory"
|
||||
)
|
||||
interactive_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output JSON trace"
|
||||
)
|
||||
interactive_parser.add_argument(
|
||||
"--trace-file",
|
||||
type=str,
|
||||
help="Save trace to file"
|
||||
)
|
||||
interactive_parser.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=60.0,
|
||||
help="Timeout in seconds"
|
||||
)
|
||||
interactive_parser.add_argument(
|
||||
"--model",
|
||||
type=str,
|
||||
help="LLM model to use"
|
||||
)
|
||||
interactive_parser.set_defaults(func=DebugHandler.handle_interactive)
|
||||
|
||||
# debug lsp
|
||||
lsp_parser = debug_subparsers.add_parser(
|
||||
"lsp",
|
||||
help="Direct LSP probes",
|
||||
description="Probe LSP server directly"
|
||||
)
|
||||
lsp_subparsers = lsp_parser.add_subparsers(dest="lsp_command", help="LSP subcommands")
|
||||
|
||||
# debug lsp status
|
||||
lsp_status = lsp_subparsers.add_parser("status", help="Show LSP status")
|
||||
lsp_status.add_argument("--workspace", "-w", type=str, default=".")
|
||||
lsp_status.add_argument("--json", action="store_true")
|
||||
lsp_status.set_defaults(func=DebugHandler.handle_lsp_status)
|
||||
|
||||
# debug lsp symbols
|
||||
lsp_symbols = lsp_subparsers.add_parser("symbols", help="List symbols in file")
|
||||
lsp_symbols.add_argument("file", type=str, help="File path")
|
||||
lsp_symbols.add_argument("--workspace", "-w", type=str, default=".")
|
||||
lsp_symbols.add_argument("--json", action="store_true")
|
||||
lsp_symbols.set_defaults(func=DebugHandler.handle_lsp_symbols)
|
||||
|
||||
# debug lsp definition
|
||||
lsp_def = lsp_subparsers.add_parser("definition", help="Get definition location")
|
||||
lsp_def.add_argument("location", type=str, help="file:line:col")
|
||||
lsp_def.add_argument("--workspace", "-w", type=str, default=".")
|
||||
lsp_def.add_argument("--json", action="store_true")
|
||||
lsp_def.set_defaults(func=DebugHandler.handle_lsp_definition)
|
||||
|
||||
# debug lsp references
|
||||
lsp_refs = lsp_subparsers.add_parser("references", help="Get references")
|
||||
lsp_refs.add_argument("location", type=str, help="file:line:col")
|
||||
lsp_refs.add_argument("--workspace", "-w", type=str, default=".")
|
||||
lsp_refs.add_argument("--json", action="store_true")
|
||||
lsp_refs.set_defaults(func=DebugHandler.handle_lsp_references)
|
||||
|
||||
# debug lsp diagnostics
|
||||
lsp_diag = lsp_subparsers.add_parser("diagnostics", help="Get diagnostics")
|
||||
lsp_diag.add_argument("file", type=str, nargs="?", help="File path (optional)")
|
||||
lsp_diag.add_argument("--all", action="store_true", help="Get all diagnostics")
|
||||
lsp_diag.add_argument("--workspace", "-w", type=str, default=".")
|
||||
lsp_diag.add_argument("--json", action="store_true")
|
||||
lsp_diag.set_defaults(func=DebugHandler.handle_lsp_diagnostics)
|
||||
|
||||
# debug acp
|
||||
acp_parser = debug_subparsers.add_parser(
|
||||
"acp",
|
||||
help="Direct ACP probes",
|
||||
description="Probe ACP server directly"
|
||||
)
|
||||
acp_subparsers = acp_parser.add_subparsers(dest="acp_command", help="ACP subcommands")
|
||||
|
||||
# debug acp status
|
||||
acp_status = acp_subparsers.add_parser("status", help="Show ACP status")
|
||||
acp_status.add_argument("--workspace", "-w", type=str, default=".")
|
||||
acp_status.add_argument("--json", action="store_true")
|
||||
acp_status.set_defaults(func=DebugHandler.handle_acp_status)
|
||||
|
||||
# debug acp plan
|
||||
acp_plan = acp_subparsers.add_parser("plan", help="Create action plan")
|
||||
acp_plan.add_argument("-p", "--prompt", type=str, required=True)
|
||||
acp_plan.add_argument("--workspace", "-w", type=str, default=".")
|
||||
acp_plan.add_argument("--json", action="store_true")
|
||||
acp_plan.set_defaults(func=DebugHandler.handle_acp_plan)
|
||||
|
||||
# debug acp apply
|
||||
acp_apply = acp_subparsers.add_parser("apply", help="Apply action plan")
|
||||
acp_apply.add_argument("-p", "--prompt", type=str, required=True)
|
||||
acp_apply.add_argument("--approval", type=str, choices=["manual", "auto", "scoped"], default="auto")
|
||||
acp_apply.add_argument("--workspace", "-w", type=str, default=".")
|
||||
acp_apply.add_argument("--json", action="store_true")
|
||||
acp_apply.set_defaults(func=DebugHandler.handle_acp_apply)
|
||||
|
||||
# debug trace
|
||||
trace_parser = debug_subparsers.add_parser(
|
||||
"trace",
|
||||
help="Trace record/replay/diff",
|
||||
description="Manage trace recordings"
|
||||
)
|
||||
trace_subparsers = trace_parser.add_subparsers(dest="trace_command", help="Trace subcommands")
|
||||
|
||||
# debug trace record
|
||||
trace_record = trace_subparsers.add_parser("record", help="Record session trace")
|
||||
trace_record.add_argument("-o", "--output", type=str, required=True, help="Output file")
|
||||
trace_record.set_defaults(func=DebugHandler.handle_trace_record)
|
||||
|
||||
# debug trace replay
|
||||
trace_replay = trace_subparsers.add_parser("replay", help="Replay recorded trace")
|
||||
trace_replay.add_argument("file", type=str, help="Trace file to replay")
|
||||
trace_replay.add_argument("--json", action="store_true")
|
||||
trace_replay.set_defaults(func=DebugHandler.handle_trace_replay)
|
||||
|
||||
# debug trace diff
|
||||
trace_diff = trace_subparsers.add_parser("diff", help="Compare two traces")
|
||||
trace_diff.add_argument("file1", type=str, help="First trace file")
|
||||
trace_diff.add_argument("file2", type=str, help="Second trace file")
|
||||
trace_diff.add_argument("--json", action="store_true")
|
||||
trace_diff.set_defaults(func=DebugHandler.handle_trace_diff)
|
||||
|
||||
@staticmethod
|
||||
def handle_interactive(args: argparse.Namespace) -> int:
|
||||
"""Handle debug interactive command."""
|
||||
return asyncio.run(DebugHandler._run_interactive(args))
|
||||
|
||||
@staticmethod
|
||||
async def _run_interactive(args: argparse.Namespace) -> int:
|
||||
"""Run interactive turn asynchronously."""
|
||||
from .interactive_runtime import create_runtime
|
||||
from .code_intelligence import CodeIntelligenceRouter
|
||||
from .action_orchestrator import ActionOrchestrator
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Create runtime
|
||||
runtime = create_runtime(
|
||||
workspace=args.workspace,
|
||||
lsp=args.lsp and not args.no_lsp,
|
||||
acp=args.acp and not args.no_acp,
|
||||
approval=args.approval,
|
||||
trace=True,
|
||||
trace_file=args.trace_file,
|
||||
json_output=args.json,
|
||||
timeout=args.timeout,
|
||||
model=getattr(args, 'model', None)
|
||||
)
|
||||
|
||||
result = {
|
||||
"prompt": args.prompt,
|
||||
"workspace": args.workspace,
|
||||
"start_time": start_time
|
||||
}
|
||||
|
||||
try:
|
||||
# Start runtime
|
||||
status = await runtime.start()
|
||||
result["runtime_status"] = status
|
||||
|
||||
if not status.get("started"):
|
||||
result["error"] = "Failed to start runtime"
|
||||
DebugHandler._output_result(result, args.json)
|
||||
return 1
|
||||
|
||||
# Classify intent
|
||||
code_router = CodeIntelligenceRouter(runtime)
|
||||
action_orchestrator = ActionOrchestrator(runtime)
|
||||
|
||||
prompt = args.prompt.lower()
|
||||
|
||||
# Determine if this is a code query or action
|
||||
is_code_query = any(kw in prompt for kw in [
|
||||
"list", "show", "find", "where", "definition", "reference",
|
||||
"symbol", "function", "class", "diagnostic", "error"
|
||||
])
|
||||
|
||||
is_action = any(kw in prompt for kw in [
|
||||
"create", "edit", "modify", "delete", "rename", "add", "remove",
|
||||
"change", "update", "refactor"
|
||||
])
|
||||
|
||||
if is_code_query:
|
||||
# Route to code intelligence
|
||||
query_result = await code_router.handle_query(args.prompt)
|
||||
result["type"] = "code_query"
|
||||
result["query_result"] = query_result.to_dict()
|
||||
result["lsp_used"] = query_result.lsp_used
|
||||
result["citations"] = query_result.citations
|
||||
|
||||
elif is_action:
|
||||
# Route to action orchestrator
|
||||
action_result = await action_orchestrator.execute(
|
||||
args.prompt,
|
||||
auto_approve=(args.approval == "auto")
|
||||
)
|
||||
result["type"] = "action"
|
||||
result["action_result"] = action_result.to_dict()
|
||||
result["read_only_blocked"] = action_result.read_only_blocked
|
||||
|
||||
else:
|
||||
# General query - just return status
|
||||
result["type"] = "general"
|
||||
result["message"] = "Query processed"
|
||||
|
||||
# Get trace
|
||||
trace = runtime.get_trace()
|
||||
if trace:
|
||||
result["trace"] = trace.to_dict()
|
||||
|
||||
result["success"] = True
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
result["success"] = False
|
||||
logger.exception("Debug interactive failed")
|
||||
|
||||
finally:
|
||||
await runtime.stop()
|
||||
result["end_time"] = time.time()
|
||||
result["duration_ms"] = (result["end_time"] - start_time) * 1000
|
||||
|
||||
# Save trace if requested
|
||||
if args.trace_file and "trace" in result:
|
||||
with open(args.trace_file, "w") as f:
|
||||
json.dump(result["trace"], f, indent=2, default=str)
|
||||
if not args.json:
|
||||
print(f"Trace saved to: {args.trace_file}")
|
||||
|
||||
DebugHandler._output_result(result, args.json)
|
||||
return 0 if result.get("success") else 1
|
||||
|
||||
@staticmethod
|
||||
def handle_lsp_status(args: argparse.Namespace) -> int:
|
||||
"""Handle debug lsp status command."""
|
||||
return asyncio.run(DebugHandler._run_lsp_status(args))
|
||||
|
||||
@staticmethod
|
||||
async def _run_lsp_status(args: argparse.Namespace) -> int:
|
||||
"""Run LSP status check."""
|
||||
from .interactive_runtime import create_runtime
|
||||
|
||||
runtime = create_runtime(
|
||||
workspace=args.workspace,
|
||||
lsp=True,
|
||||
acp=False
|
||||
)
|
||||
|
||||
try:
|
||||
await runtime.start()
|
||||
result = {
|
||||
"lsp_enabled": True,
|
||||
"lsp_ready": runtime.lsp_ready,
|
||||
"lsp_status": runtime._lsp_state.status.value,
|
||||
"lsp_error": runtime._lsp_state.error,
|
||||
"workspace": args.workspace
|
||||
}
|
||||
|
||||
DebugHandler._output_result(result, args.json)
|
||||
return 0 if runtime.lsp_ready else 1
|
||||
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
@staticmethod
|
||||
def handle_lsp_symbols(args: argparse.Namespace) -> int:
|
||||
"""Handle debug lsp symbols command."""
|
||||
return asyncio.run(DebugHandler._run_lsp_symbols(args))
|
||||
|
||||
@staticmethod
|
||||
async def _run_lsp_symbols(args: argparse.Namespace) -> int:
|
||||
"""Run LSP symbols query."""
|
||||
from .interactive_runtime import create_runtime
|
||||
from .code_intelligence import CodeIntelligenceRouter
|
||||
|
||||
runtime = create_runtime(
|
||||
workspace=args.workspace,
|
||||
lsp=True,
|
||||
acp=False
|
||||
)
|
||||
|
||||
try:
|
||||
await runtime.start()
|
||||
|
||||
if not runtime.lsp_ready:
|
||||
# Use fallback
|
||||
router = CodeIntelligenceRouter(runtime)
|
||||
result = await router._fallback_list_symbols(args.file)
|
||||
else:
|
||||
symbols = await runtime.lsp_get_symbols(args.file)
|
||||
result = {
|
||||
"file": args.file,
|
||||
"symbols": symbols,
|
||||
"count": len(symbols) if symbols else 0,
|
||||
"lsp_used": True
|
||||
}
|
||||
|
||||
if hasattr(result, 'to_dict'):
|
||||
result = result.to_dict()
|
||||
|
||||
DebugHandler._output_result(result, args.json)
|
||||
return 0
|
||||
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
@staticmethod
|
||||
def handle_lsp_definition(args: argparse.Namespace) -> int:
|
||||
"""Handle debug lsp definition command."""
|
||||
return asyncio.run(DebugHandler._run_lsp_definition(args))
|
||||
|
||||
@staticmethod
|
||||
async def _run_lsp_definition(args: argparse.Namespace) -> int:
|
||||
"""Run LSP definition query."""
|
||||
from .interactive_runtime import create_runtime
|
||||
|
||||
# Parse location
|
||||
parts = args.location.split(":")
|
||||
if len(parts) != 3:
|
||||
print("Error: Location must be in format file:line:col", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
file_path, line, col = parts[0], int(parts[1]), int(parts[2])
|
||||
|
||||
runtime = create_runtime(
|
||||
workspace=args.workspace,
|
||||
lsp=True,
|
||||
acp=False
|
||||
)
|
||||
|
||||
try:
|
||||
await runtime.start()
|
||||
|
||||
definitions = await runtime.lsp_get_definition(file_path, line, col)
|
||||
result = {
|
||||
"file": file_path,
|
||||
"line": line,
|
||||
"col": col,
|
||||
"definitions": definitions,
|
||||
"count": len(definitions) if definitions else 0,
|
||||
"lsp_used": runtime.lsp_ready
|
||||
}
|
||||
|
||||
DebugHandler._output_result(result, args.json)
|
||||
return 0
|
||||
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
@staticmethod
|
||||
def handle_lsp_references(args: argparse.Namespace) -> int:
|
||||
"""Handle debug lsp references command."""
|
||||
return asyncio.run(DebugHandler._run_lsp_references(args))
|
||||
|
||||
@staticmethod
|
||||
async def _run_lsp_references(args: argparse.Namespace) -> int:
|
||||
"""Run LSP references query."""
|
||||
from .interactive_runtime import create_runtime
|
||||
|
||||
# Parse location
|
||||
parts = args.location.split(":")
|
||||
if len(parts) != 3:
|
||||
print("Error: Location must be in format file:line:col", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
file_path, line, col = parts[0], int(parts[1]), int(parts[2])
|
||||
|
||||
runtime = create_runtime(
|
||||
workspace=args.workspace,
|
||||
lsp=True,
|
||||
acp=False
|
||||
)
|
||||
|
||||
try:
|
||||
await runtime.start()
|
||||
|
||||
references = await runtime.lsp_get_references(file_path, line, col)
|
||||
result = {
|
||||
"file": file_path,
|
||||
"line": line,
|
||||
"col": col,
|
||||
"references": references,
|
||||
"count": len(references) if references else 0,
|
||||
"lsp_used": runtime.lsp_ready
|
||||
}
|
||||
|
||||
DebugHandler._output_result(result, args.json)
|
||||
return 0
|
||||
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
@staticmethod
|
||||
def handle_lsp_diagnostics(args: argparse.Namespace) -> int:
|
||||
"""Handle debug lsp diagnostics command."""
|
||||
return asyncio.run(DebugHandler._run_lsp_diagnostics(args))
|
||||
|
||||
@staticmethod
|
||||
async def _run_lsp_diagnostics(args: argparse.Namespace) -> int:
|
||||
"""Run LSP diagnostics query."""
|
||||
from .interactive_runtime import create_runtime
|
||||
|
||||
runtime = create_runtime(
|
||||
workspace=args.workspace,
|
||||
lsp=True,
|
||||
acp=False
|
||||
)
|
||||
|
||||
try:
|
||||
await runtime.start()
|
||||
|
||||
file_path = args.file if not args.all else None
|
||||
diagnostics = await runtime.lsp_get_diagnostics(file_path)
|
||||
|
||||
result = {
|
||||
"file": file_path or "all",
|
||||
"diagnostics": diagnostics,
|
||||
"count": len(diagnostics) if diagnostics else 0,
|
||||
"lsp_used": runtime.lsp_ready
|
||||
}
|
||||
|
||||
DebugHandler._output_result(result, args.json)
|
||||
return 0
|
||||
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
@staticmethod
|
||||
def handle_acp_status(args: argparse.Namespace) -> int:
|
||||
"""Handle debug acp status command."""
|
||||
return asyncio.run(DebugHandler._run_acp_status(args))
|
||||
|
||||
@staticmethod
|
||||
async def _run_acp_status(args: argparse.Namespace) -> int:
|
||||
"""Run ACP status check."""
|
||||
from .interactive_runtime import create_runtime
|
||||
|
||||
runtime = create_runtime(
|
||||
workspace=args.workspace,
|
||||
lsp=False,
|
||||
acp=True
|
||||
)
|
||||
|
||||
try:
|
||||
await runtime.start()
|
||||
result = {
|
||||
"acp_enabled": True,
|
||||
"acp_ready": runtime.acp_ready,
|
||||
"acp_status": runtime._acp_state.status.value,
|
||||
"acp_error": runtime._acp_state.error,
|
||||
"read_only": runtime.read_only,
|
||||
"workspace": args.workspace
|
||||
}
|
||||
|
||||
DebugHandler._output_result(result, args.json)
|
||||
return 0 if runtime.acp_ready else 1
|
||||
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
@staticmethod
|
||||
def handle_acp_plan(args: argparse.Namespace) -> int:
|
||||
"""Handle debug acp plan command."""
|
||||
return asyncio.run(DebugHandler._run_acp_plan(args))
|
||||
|
||||
@staticmethod
|
||||
async def _run_acp_plan(args: argparse.Namespace) -> int:
|
||||
"""Run ACP plan creation."""
|
||||
from .interactive_runtime import create_runtime
|
||||
from .action_orchestrator import ActionOrchestrator
|
||||
|
||||
runtime = create_runtime(
|
||||
workspace=args.workspace,
|
||||
lsp=False,
|
||||
acp=True
|
||||
)
|
||||
|
||||
try:
|
||||
await runtime.start()
|
||||
|
||||
orchestrator = ActionOrchestrator(runtime)
|
||||
result = await orchestrator.create_plan(args.prompt)
|
||||
|
||||
DebugHandler._output_result(result.to_dict(), args.json)
|
||||
return 0 if result.success else 1
|
||||
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
@staticmethod
|
||||
def handle_acp_apply(args: argparse.Namespace) -> int:
|
||||
"""Handle debug acp apply command."""
|
||||
return asyncio.run(DebugHandler._run_acp_apply(args))
|
||||
|
||||
@staticmethod
|
||||
async def _run_acp_apply(args: argparse.Namespace) -> int:
|
||||
"""Run ACP plan and apply."""
|
||||
from .interactive_runtime import create_runtime
|
||||
from .action_orchestrator import ActionOrchestrator
|
||||
|
||||
runtime = create_runtime(
|
||||
workspace=args.workspace,
|
||||
lsp=False,
|
||||
acp=True,
|
||||
approval=args.approval
|
||||
)
|
||||
|
||||
try:
|
||||
await runtime.start()
|
||||
|
||||
orchestrator = ActionOrchestrator(runtime)
|
||||
result = await orchestrator.execute(
|
||||
args.prompt,
|
||||
auto_approve=(args.approval == "auto")
|
||||
)
|
||||
|
||||
DebugHandler._output_result(result.to_dict(), args.json)
|
||||
return 0 if result.success else 1
|
||||
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
@staticmethod
|
||||
def handle_trace_record(args: argparse.Namespace) -> int:
|
||||
"""Handle debug trace record command."""
|
||||
print(f"Trace recording will be saved to: {args.output}")
|
||||
print("Start an interactive session with --trace-file to record.")
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def handle_trace_replay(args: argparse.Namespace) -> int:
|
||||
"""Handle debug trace replay command."""
|
||||
try:
|
||||
with open(args.file) as f:
|
||||
trace = json.load(f)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(trace, indent=2))
|
||||
else:
|
||||
print(f"Trace from: {args.file}")
|
||||
print(f"Start time: {trace.get('start_time')}")
|
||||
print(f"End time: {trace.get('end_time')}")
|
||||
print(f"Entries: {len(trace.get('entries', []))}")
|
||||
print("\nEntries:")
|
||||
for entry in trace.get("entries", []):
|
||||
print(f" [{entry.get('category')}] {entry.get('action')}")
|
||||
if entry.get('duration_ms'):
|
||||
print(f" Duration: {entry.get('duration_ms'):.2f}ms")
|
||||
if entry.get('error'):
|
||||
print(f" Error: {entry.get('error')}")
|
||||
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
@staticmethod
|
||||
def handle_trace_diff(args: argparse.Namespace) -> int:
|
||||
"""Handle debug trace diff command."""
|
||||
try:
|
||||
with open(args.file1) as f:
|
||||
trace1 = json.load(f)
|
||||
with open(args.file2) as f:
|
||||
trace2 = json.load(f)
|
||||
|
||||
diff = {
|
||||
"file1": args.file1,
|
||||
"file2": args.file2,
|
||||
"entries_diff": {
|
||||
"file1_count": len(trace1.get("entries", [])),
|
||||
"file2_count": len(trace2.get("entries", []))
|
||||
},
|
||||
"duration_diff": {
|
||||
"file1": trace1.get("end_time", 0) - trace1.get("start_time", 0),
|
||||
"file2": trace2.get("end_time", 0) - trace2.get("start_time", 0)
|
||||
}
|
||||
}
|
||||
|
||||
DebugHandler._output_result(diff, args.json)
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
@staticmethod
|
||||
def _output_result(result: Dict[str, Any], as_json: bool) -> None:
|
||||
"""Output result in appropriate format."""
|
||||
if as_json:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
for key, value in result.items():
|
||||
if isinstance(value, dict):
|
||||
print(f"{key}:")
|
||||
for k, v in value.items():
|
||||
print(f" {k}: {v}")
|
||||
elif isinstance(value, list):
|
||||
print(f"{key}: ({len(value)} items)")
|
||||
for item in value[:5]: # Show first 5
|
||||
print(f" - {item}")
|
||||
if len(value) > 5:
|
||||
print(f" ... and {len(value) - 5} more")
|
||||
else:
|
||||
print(f"{key}: {value}")
|
||||
|
||||
|
||||
def setup_debug_subparser(subparsers) -> None:
|
||||
"""Set up the debug subcommand parser."""
|
||||
DebugHandler.setup_subparser(subparsers)
|
||||
|
||||
|
||||
def run_debug_command(args: List[str]) -> int:
|
||||
"""Run debug command from CLI."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="praisonai debug",
|
||||
description="Debug and test interactive coding assistant flows"
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="debug_command", help="Debug subcommands")
|
||||
|
||||
# debug interactive
|
||||
interactive_parser = subparsers.add_parser(
|
||||
"interactive",
|
||||
help="Run single interactive turn non-interactively",
|
||||
description="Execute one interactive turn with full LSP/ACP pipeline"
|
||||
)
|
||||
interactive_parser.add_argument("-p", "--prompt", type=str, required=True, help="Prompt to execute")
|
||||
interactive_parser.add_argument("--lsp", action="store_true", default=True, help="Enable LSP (default: enabled)")
|
||||
interactive_parser.add_argument("--no-lsp", action="store_true", help="Disable LSP")
|
||||
interactive_parser.add_argument("--acp", action="store_true", default=True, help="Enable ACP (default: enabled)")
|
||||
interactive_parser.add_argument("--no-acp", action="store_true", help="Disable ACP")
|
||||
interactive_parser.add_argument("--approval", type=str, choices=["manual", "auto", "scoped"], default="auto", help="Approval mode")
|
||||
interactive_parser.add_argument("--workspace", "-w", type=str, default=".", help="Workspace root directory")
|
||||
interactive_parser.add_argument("--json", action="store_true", help="Output JSON trace")
|
||||
interactive_parser.add_argument("--trace-file", type=str, help="Save trace to file")
|
||||
interactive_parser.add_argument("--timeout", type=float, default=60.0, help="Timeout in seconds")
|
||||
interactive_parser.add_argument("--model", type=str, help="LLM model to use")
|
||||
interactive_parser.set_defaults(func=DebugHandler.handle_interactive)
|
||||
|
||||
# debug lsp
|
||||
lsp_parser = subparsers.add_parser("lsp", help="Direct LSP probes")
|
||||
lsp_subparsers = lsp_parser.add_subparsers(dest="lsp_command", help="LSP subcommands")
|
||||
|
||||
# debug lsp status
|
||||
lsp_status = lsp_subparsers.add_parser("status", help="Show LSP status")
|
||||
lsp_status.add_argument("--workspace", "-w", type=str, default=".")
|
||||
lsp_status.add_argument("--json", action="store_true")
|
||||
lsp_status.set_defaults(func=DebugHandler.handle_lsp_status)
|
||||
|
||||
# debug lsp symbols
|
||||
lsp_symbols = lsp_subparsers.add_parser("symbols", help="List symbols in file")
|
||||
lsp_symbols.add_argument("file", type=str, help="File path")
|
||||
lsp_symbols.add_argument("--workspace", "-w", type=str, default=".")
|
||||
lsp_symbols.add_argument("--json", action="store_true")
|
||||
lsp_symbols.set_defaults(func=DebugHandler.handle_lsp_symbols)
|
||||
|
||||
# debug lsp definition
|
||||
lsp_def = lsp_subparsers.add_parser("definition", help="Get definition location")
|
||||
lsp_def.add_argument("location", type=str, help="file:line:col")
|
||||
lsp_def.add_argument("--workspace", "-w", type=str, default=".")
|
||||
lsp_def.add_argument("--json", action="store_true")
|
||||
lsp_def.set_defaults(func=DebugHandler.handle_lsp_definition)
|
||||
|
||||
# debug lsp references
|
||||
lsp_refs = lsp_subparsers.add_parser("references", help="Get references")
|
||||
lsp_refs.add_argument("location", type=str, help="file:line:col")
|
||||
lsp_refs.add_argument("--workspace", "-w", type=str, default=".")
|
||||
lsp_refs.add_argument("--json", action="store_true")
|
||||
lsp_refs.set_defaults(func=DebugHandler.handle_lsp_references)
|
||||
|
||||
# debug lsp diagnostics
|
||||
lsp_diag = lsp_subparsers.add_parser("diagnostics", help="Get diagnostics")
|
||||
lsp_diag.add_argument("file", type=str, nargs="?", help="File path (optional)")
|
||||
lsp_diag.add_argument("--all", action="store_true", help="Get all diagnostics")
|
||||
lsp_diag.add_argument("--workspace", "-w", type=str, default=".")
|
||||
lsp_diag.add_argument("--json", action="store_true")
|
||||
lsp_diag.set_defaults(func=DebugHandler.handle_lsp_diagnostics)
|
||||
|
||||
# debug acp
|
||||
acp_parser = subparsers.add_parser("acp", help="Direct ACP probes")
|
||||
acp_subparsers = acp_parser.add_subparsers(dest="acp_command", help="ACP subcommands")
|
||||
|
||||
# debug acp status
|
||||
acp_status = acp_subparsers.add_parser("status", help="Show ACP status")
|
||||
acp_status.add_argument("--workspace", "-w", type=str, default=".")
|
||||
acp_status.add_argument("--json", action="store_true")
|
||||
acp_status.set_defaults(func=DebugHandler.handle_acp_status)
|
||||
|
||||
# debug acp plan
|
||||
acp_plan = acp_subparsers.add_parser("plan", help="Create action plan")
|
||||
acp_plan.add_argument("-p", "--prompt", type=str, required=True)
|
||||
acp_plan.add_argument("--workspace", "-w", type=str, default=".")
|
||||
acp_plan.add_argument("--json", action="store_true")
|
||||
acp_plan.set_defaults(func=DebugHandler.handle_acp_plan)
|
||||
|
||||
# debug acp apply
|
||||
acp_apply = acp_subparsers.add_parser("apply", help="Apply action plan")
|
||||
acp_apply.add_argument("-p", "--prompt", type=str, required=True)
|
||||
acp_apply.add_argument("--approval", type=str, choices=["manual", "auto", "scoped"], default="auto")
|
||||
acp_apply.add_argument("--workspace", "-w", type=str, default=".")
|
||||
acp_apply.add_argument("--json", action="store_true")
|
||||
acp_apply.set_defaults(func=DebugHandler.handle_acp_apply)
|
||||
|
||||
# debug trace
|
||||
trace_parser = subparsers.add_parser("trace", help="Trace record/replay/diff")
|
||||
trace_subparsers = trace_parser.add_subparsers(dest="trace_command", help="Trace subcommands")
|
||||
|
||||
# debug trace record
|
||||
trace_record = trace_subparsers.add_parser("record", help="Record session trace")
|
||||
trace_record.add_argument("-o", "--output", type=str, required=True, help="Output file")
|
||||
trace_record.set_defaults(func=DebugHandler.handle_trace_record)
|
||||
|
||||
# debug trace replay
|
||||
trace_replay = trace_subparsers.add_parser("replay", help="Replay recorded trace")
|
||||
trace_replay.add_argument("file", type=str, help="Trace file to replay")
|
||||
trace_replay.add_argument("--json", action="store_true")
|
||||
trace_replay.set_defaults(func=DebugHandler.handle_trace_replay)
|
||||
|
||||
# debug trace diff
|
||||
trace_diff = trace_subparsers.add_parser("diff", help="Compare two traces")
|
||||
trace_diff.add_argument("file1", type=str, help="First trace file")
|
||||
trace_diff.add_argument("file2", type=str, help="Second trace file")
|
||||
trace_diff.add_argument("--json", action="store_true")
|
||||
trace_diff.set_defaults(func=DebugHandler.handle_trace_diff)
|
||||
|
||||
parsed = parser.parse_args(args)
|
||||
if hasattr(parsed, 'func'):
|
||||
return parsed.func(parsed)
|
||||
else:
|
||||
parser.print_help()
|
||||
return 0
|
||||
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
Diagnostics export CLI handler for PraisonAI.
|
||||
|
||||
Provides diagnostic export functionality:
|
||||
- diag export: Bundle logs, traces, config for bug reports
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DiagHandler:
|
||||
"""Handler for diag CLI commands."""
|
||||
|
||||
@staticmethod
|
||||
def setup_subparser(subparsers) -> None:
|
||||
"""Set up the diag subcommand parser."""
|
||||
diag_parser = subparsers.add_parser(
|
||||
"diag",
|
||||
help="Diagnostics export",
|
||||
description="Export diagnostic information for bug reports"
|
||||
)
|
||||
|
||||
diag_subparsers = diag_parser.add_subparsers(dest="diag_command", help="Diag subcommands")
|
||||
|
||||
# diag export
|
||||
export_parser = diag_subparsers.add_parser("export", help="Export diagnostic bundle")
|
||||
export_parser.add_argument(
|
||||
"-o", "--output",
|
||||
type=str,
|
||||
help="Output path (default: praisonai-diag-{timestamp}.zip)"
|
||||
)
|
||||
export_parser.add_argument(
|
||||
"--include-logs",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="Include log files (default: true)"
|
||||
)
|
||||
export_parser.add_argument(
|
||||
"--include-config",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="Include configuration (default: true)"
|
||||
)
|
||||
export_parser.add_argument(
|
||||
"--include-trace",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="Include recent traces (default: true)"
|
||||
)
|
||||
export_parser.add_argument(
|
||||
"--workspace", "-w",
|
||||
type=str,
|
||||
default=".",
|
||||
help="Workspace root directory"
|
||||
)
|
||||
export_parser.add_argument("--json", action="store_true", help="Output JSON")
|
||||
export_parser.set_defaults(func=DiagHandler.handle_export)
|
||||
|
||||
@staticmethod
|
||||
def handle_export(args: argparse.Namespace) -> int:
|
||||
"""Handle diag export command."""
|
||||
timestamp = int(time.time())
|
||||
output_path = args.output or f"praisonai-diag-{timestamp}.zip"
|
||||
|
||||
collected_files = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmppath = Path(tmpdir)
|
||||
|
||||
# Collect system info
|
||||
system_info = DiagHandler._collect_system_info()
|
||||
system_file = tmppath / "system_info.json"
|
||||
with open(system_file, "w") as f:
|
||||
json.dump(system_info, f, indent=2, default=str)
|
||||
collected_files.append("system_info.json")
|
||||
|
||||
# Collect config
|
||||
if args.include_config:
|
||||
config_files = DiagHandler._collect_config(args.workspace, tmppath)
|
||||
collected_files.extend(config_files)
|
||||
|
||||
# Collect logs
|
||||
if args.include_logs:
|
||||
log_files = DiagHandler._collect_logs(tmppath)
|
||||
collected_files.extend(log_files)
|
||||
|
||||
# Collect traces
|
||||
if args.include_trace:
|
||||
trace_files = DiagHandler._collect_traces(args.workspace, tmppath)
|
||||
collected_files.extend(trace_files)
|
||||
|
||||
# Create zip
|
||||
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for file in tmppath.rglob("*"):
|
||||
if file.is_file():
|
||||
arcname = file.relative_to(tmppath)
|
||||
zf.write(file, arcname)
|
||||
|
||||
result = {
|
||||
"action": "export",
|
||||
"output": output_path,
|
||||
"files_collected": collected_files,
|
||||
"count": len(collected_files)
|
||||
}
|
||||
|
||||
DiagHandler._output_result(result, args.json)
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _collect_system_info() -> dict:
|
||||
"""Collect system information."""
|
||||
import platform
|
||||
import sys
|
||||
|
||||
info = {
|
||||
"timestamp": time.time(),
|
||||
"platform": {
|
||||
"system": platform.system(),
|
||||
"release": platform.release(),
|
||||
"version": platform.version(),
|
||||
"machine": platform.machine(),
|
||||
"python_version": sys.version
|
||||
},
|
||||
"praisonai": {}
|
||||
}
|
||||
|
||||
# Get PraisonAI version
|
||||
try:
|
||||
from praisonai.version import __version__
|
||||
info["praisonai"]["version"] = __version__
|
||||
except ImportError:
|
||||
info["praisonai"]["version"] = "unknown"
|
||||
|
||||
# Get praisonaiagents version
|
||||
try:
|
||||
import praisonaiagents
|
||||
info["praisonai"]["agents_version"] = getattr(praisonaiagents, '__version__', 'unknown')
|
||||
except ImportError:
|
||||
info["praisonai"]["agents_version"] = "not installed"
|
||||
|
||||
# Check for optional dependencies
|
||||
info["dependencies"] = {}
|
||||
for dep in ["acp", "litellm", "openai", "anthropic", "google.generativeai"]:
|
||||
try:
|
||||
__import__(dep.split(".")[0])
|
||||
info["dependencies"][dep] = "installed"
|
||||
except ImportError:
|
||||
info["dependencies"][dep] = "not installed"
|
||||
|
||||
# Environment (redacted)
|
||||
env_keys = [
|
||||
"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GOOGLE_API_KEY",
|
||||
"PRAISONAI_APPROVAL_MODE", "LOGLEVEL"
|
||||
]
|
||||
info["environment"] = {}
|
||||
for key in env_keys:
|
||||
value = os.environ.get(key)
|
||||
if value:
|
||||
if "KEY" in key or "SECRET" in key:
|
||||
info["environment"][key] = f"{value[:4]}...{value[-4:]}" if len(value) > 8 else "***"
|
||||
else:
|
||||
info["environment"][key] = value
|
||||
else:
|
||||
info["environment"][key] = None
|
||||
|
||||
return info
|
||||
|
||||
@staticmethod
|
||||
def _collect_config(workspace: str, tmppath: Path) -> List[str]:
|
||||
"""Collect configuration files."""
|
||||
collected = []
|
||||
workspace_path = Path(workspace)
|
||||
config_dir = tmppath / "config"
|
||||
config_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Look for config files
|
||||
config_patterns = [
|
||||
"agents.yaml",
|
||||
"workflow.yaml",
|
||||
".praisonai.yaml",
|
||||
".env.example", # Not .env to avoid secrets
|
||||
"pyproject.toml",
|
||||
]
|
||||
|
||||
for pattern in config_patterns:
|
||||
for file in workspace_path.glob(pattern):
|
||||
if file.is_file():
|
||||
dest = config_dir / file.name
|
||||
shutil.copy(file, dest)
|
||||
collected.append(f"config/{file.name}")
|
||||
|
||||
return collected
|
||||
|
||||
@staticmethod
|
||||
def _collect_logs(tmppath: Path) -> List[str]:
|
||||
"""Collect log files."""
|
||||
collected = []
|
||||
logs_dir = tmppath / "logs"
|
||||
logs_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Look for log files in common locations
|
||||
log_locations = [
|
||||
Path.home() / ".praison" / "logs",
|
||||
Path.home() / ".praisonai" / "logs",
|
||||
Path("/var/log/praisonai"),
|
||||
]
|
||||
|
||||
for log_dir in log_locations:
|
||||
if log_dir.exists():
|
||||
for log_file in log_dir.glob("*.log"):
|
||||
if log_file.is_file():
|
||||
dest = logs_dir / log_file.name
|
||||
# Only copy last 1000 lines
|
||||
try:
|
||||
with open(log_file) as f:
|
||||
lines = f.readlines()[-1000:]
|
||||
with open(dest, "w") as f:
|
||||
f.writelines(lines)
|
||||
collected.append(f"logs/{log_file.name}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return collected
|
||||
|
||||
@staticmethod
|
||||
def _collect_traces(workspace: str, tmppath: Path) -> List[str]:
|
||||
"""Collect trace files."""
|
||||
collected = []
|
||||
traces_dir = tmppath / "traces"
|
||||
traces_dir.mkdir(exist_ok=True)
|
||||
|
||||
workspace_path = Path(workspace)
|
||||
|
||||
# Look for trace files
|
||||
trace_patterns = ["*_trace.json", "praisonai_trace*.json", "*.trace"]
|
||||
|
||||
for pattern in trace_patterns:
|
||||
for trace_file in workspace_path.glob(pattern):
|
||||
if trace_file.is_file():
|
||||
dest = traces_dir / trace_file.name
|
||||
shutil.copy(trace_file, dest)
|
||||
collected.append(f"traces/{trace_file.name}")
|
||||
|
||||
return collected
|
||||
|
||||
@staticmethod
|
||||
def _output_result(result: dict, as_json: bool) -> None:
|
||||
"""Output result in appropriate format."""
|
||||
if as_json:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
print(f"Diagnostic bundle created: {result['output']}")
|
||||
print(f"Files collected: {result['count']}")
|
||||
for f in result['files_collected']:
|
||||
print(f" - {f}")
|
||||
|
||||
|
||||
def run_diag_command(args: List[str]) -> int:
|
||||
"""Run diag command from CLI."""
|
||||
parser = argparse.ArgumentParser(prog="praisonai diag")
|
||||
subparsers = parser.add_subparsers(dest="diag_command", help="Diag subcommands")
|
||||
|
||||
# diag export
|
||||
export_parser = subparsers.add_parser("export", help="Export diagnostic bundle")
|
||||
export_parser.add_argument("-o", "--output", type=str)
|
||||
export_parser.add_argument("--include-logs", action="store_true", default=True)
|
||||
export_parser.add_argument("--include-config", action="store_true", default=True)
|
||||
export_parser.add_argument("--include-trace", action="store_true", default=True)
|
||||
export_parser.add_argument("--workspace", "-w", type=str, default=".")
|
||||
export_parser.add_argument("--json", action="store_true")
|
||||
export_parser.set_defaults(func=DiagHandler.handle_export)
|
||||
|
||||
parsed = parser.parse_args(args)
|
||||
if hasattr(parsed, 'func'):
|
||||
return parsed.func(parsed)
|
||||
else:
|
||||
parser.print_help()
|
||||
return 0
|
||||
@@ -20,3 +20,5 @@ def register_all_checks():
|
||||
from . import performance_checks
|
||||
from . import selftest_checks
|
||||
from . import serve_checks
|
||||
from . import lsp_checks
|
||||
from . import acp_checks
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
ACP health checks for PraisonAI Doctor.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from ..models import CheckCategory, CheckResult, CheckStatus
|
||||
from ..registry import register_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="acp_module",
|
||||
title="ACP Module Available",
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if ACP module is installed"
|
||||
)
|
||||
def check_acp_module() -> CheckResult:
|
||||
"""Check if ACP module is available."""
|
||||
try:
|
||||
import importlib.util
|
||||
spec = importlib.util.find_spec("praisonai.acp")
|
||||
|
||||
if spec is not None:
|
||||
return CheckResult(
|
||||
status=CheckStatus.PASS,
|
||||
message="ACP module is available",
|
||||
details={"module": "praisonai.acp"}
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
status=CheckStatus.WARN,
|
||||
message="ACP module not found",
|
||||
details={"hint": "ACP module should be part of praisonai package"}
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"Error checking ACP module: {e}",
|
||||
details={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="acp_sdk",
|
||||
title="ACP SDK Available",
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if agent-client-protocol SDK is installed"
|
||||
)
|
||||
def check_acp_sdk() -> CheckResult:
|
||||
"""Check if ACP SDK is available."""
|
||||
try:
|
||||
import importlib.util
|
||||
spec = importlib.util.find_spec("acp")
|
||||
|
||||
if spec is not None:
|
||||
return CheckResult(
|
||||
status=CheckStatus.PASS,
|
||||
message="ACP SDK (agent-client-protocol) is installed",
|
||||
details={"package": "agent-client-protocol"}
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
status=CheckStatus.INFO,
|
||||
message="ACP SDK not installed (optional)",
|
||||
details={"hint": "Install with: pip install agent-client-protocol"}
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"Error checking ACP SDK: {e}",
|
||||
details={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="acp_server",
|
||||
title="ACP Server Import",
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if ACP server can be imported"
|
||||
)
|
||||
def check_acp_server() -> CheckResult:
|
||||
"""Check if ACP server can be imported."""
|
||||
try:
|
||||
from praisonai.acp import ACPServer # noqa: F401
|
||||
|
||||
return CheckResult(
|
||||
status=CheckStatus.PASS,
|
||||
message="ACP server can be imported",
|
||||
details={"class": "ACPServer"}
|
||||
)
|
||||
except ImportError as e:
|
||||
return CheckResult(
|
||||
status=CheckStatus.WARN,
|
||||
message=f"ACP server import failed: {e}",
|
||||
details={"error": str(e)}
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"Error importing ACP server: {e}",
|
||||
details={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="acp_config",
|
||||
title="ACP Configuration",
|
||||
category=CheckCategory.CONFIG,
|
||||
description="Check ACP configuration"
|
||||
)
|
||||
def check_acp_config() -> CheckResult:
|
||||
"""Check ACP configuration."""
|
||||
import os
|
||||
|
||||
config = {
|
||||
"approval_mode": os.environ.get("PRAISONAI_APPROVAL_MODE", "manual"),
|
||||
"allow_write": os.environ.get("PRAISONAI_ALLOW_WRITE", "false"),
|
||||
"allow_shell": os.environ.get("PRAISONAI_ALLOW_SHELL", "false"),
|
||||
}
|
||||
|
||||
return CheckResult(
|
||||
status=CheckStatus.PASS,
|
||||
message=f"ACP configured with approval_mode={config['approval_mode']}",
|
||||
details=config
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="acp_runtime",
|
||||
title="ACP Runtime Check",
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if ACP runtime can start",
|
||||
requires_deep=True
|
||||
)
|
||||
def check_acp_runtime() -> CheckResult:
|
||||
"""Check if ACP runtime can start."""
|
||||
try:
|
||||
from praisonai.cli.features.interactive_runtime import create_runtime
|
||||
|
||||
async def _check():
|
||||
runtime = create_runtime(
|
||||
workspace=".",
|
||||
lsp=False,
|
||||
acp=True
|
||||
)
|
||||
try:
|
||||
await runtime.start()
|
||||
ready = runtime.acp_ready
|
||||
error = runtime._acp_state.error
|
||||
read_only = runtime.read_only
|
||||
return ready, error, read_only
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
ready, error, read_only = asyncio.run(_check())
|
||||
|
||||
if ready:
|
||||
return CheckResult(
|
||||
status=CheckStatus.PASS,
|
||||
message="ACP runtime started successfully",
|
||||
details={"ready": True, "read_only": read_only}
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
status=CheckStatus.WARN,
|
||||
message=f"ACP runtime failed to start: {error}",
|
||||
details={"ready": False, "error": error, "read_only": read_only}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"ACP runtime check failed: {e}",
|
||||
details={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="interactive_runtime",
|
||||
title="Interactive Runtime Check",
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if full interactive runtime can start",
|
||||
requires_deep=True
|
||||
)
|
||||
def check_interactive_runtime() -> CheckResult:
|
||||
"""Check if full interactive runtime can start."""
|
||||
try:
|
||||
from praisonai.cli.features.interactive_runtime import create_runtime
|
||||
|
||||
async def _check():
|
||||
runtime = create_runtime(
|
||||
workspace=".",
|
||||
lsp=True,
|
||||
acp=True
|
||||
)
|
||||
try:
|
||||
await runtime.start()
|
||||
status = runtime.get_status()
|
||||
return status
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
status = asyncio.run(_check())
|
||||
|
||||
lsp_ready = status.get("lsp", {}).get("ready", False)
|
||||
acp_ready = status.get("acp", {}).get("ready", False)
|
||||
|
||||
if lsp_ready and acp_ready:
|
||||
return CheckResult(
|
||||
status=CheckStatus.PASS,
|
||||
message="Interactive runtime fully operational",
|
||||
details=status
|
||||
)
|
||||
elif lsp_ready or acp_ready:
|
||||
return CheckResult(
|
||||
status=CheckStatus.WARN,
|
||||
message="Interactive runtime partially operational",
|
||||
details=status
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
message="Interactive runtime failed to start",
|
||||
details=status
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"Interactive runtime check failed: {e}",
|
||||
details={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
def register_acp_checks():
|
||||
"""Register all ACP checks."""
|
||||
pass
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
LSP health checks for PraisonAI Doctor.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from ..models import CheckCategory, CheckResult, CheckStatus
|
||||
from ..registry import register_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="lsp_module",
|
||||
title="LSP Module Available",
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if LSP module is installed"
|
||||
)
|
||||
def check_lsp_module() -> CheckResult:
|
||||
"""Check if LSP module is available."""
|
||||
try:
|
||||
import importlib.util
|
||||
spec = importlib.util.find_spec("praisonaiagents.lsp")
|
||||
|
||||
if spec is not None:
|
||||
return CheckResult(
|
||||
status=CheckStatus.PASS,
|
||||
message="LSP module is available",
|
||||
details={"module": "praisonaiagents.lsp"}
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
status=CheckStatus.WARN,
|
||||
message="LSP module not found",
|
||||
details={"hint": "Install praisonaiagents with LSP support"}
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"Error checking LSP module: {e}",
|
||||
details={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="lsp_client",
|
||||
title="LSP Client Import",
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if LSP client can be imported"
|
||||
)
|
||||
def check_lsp_client() -> CheckResult:
|
||||
"""Check if LSP client can be imported."""
|
||||
try:
|
||||
from praisonaiagents.lsp import LSPClient # noqa: F401
|
||||
|
||||
return CheckResult(
|
||||
status=CheckStatus.PASS,
|
||||
message="LSP client can be imported",
|
||||
details={"class": "LSPClient"}
|
||||
)
|
||||
except ImportError as e:
|
||||
return CheckResult(
|
||||
status=CheckStatus.WARN,
|
||||
message=f"LSP client import failed: {e}",
|
||||
details={"error": str(e)}
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"Error importing LSP client: {e}",
|
||||
details={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="lsp_server_python",
|
||||
title="Python LSP Server",
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if Python language server is available"
|
||||
)
|
||||
def check_lsp_server_python() -> CheckResult:
|
||||
"""Check if Python language server is available."""
|
||||
import shutil
|
||||
|
||||
# Check for common Python language servers
|
||||
servers = [
|
||||
("pyright-langserver", "pyright"),
|
||||
("pylsp", "python-lsp-server"),
|
||||
("pyls", "python-language-server"),
|
||||
]
|
||||
|
||||
found = []
|
||||
for cmd, package in servers:
|
||||
if shutil.which(cmd):
|
||||
found.append({"command": cmd, "package": package})
|
||||
|
||||
if found:
|
||||
return CheckResult(
|
||||
status=CheckStatus.PASS,
|
||||
message=f"Python LSP server available: {found[0]['command']}",
|
||||
details={"servers": found}
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
status=CheckStatus.WARN,
|
||||
message="No Python LSP server found",
|
||||
details={
|
||||
"hint": "Install with: npm install -g pyright OR pip install python-lsp-server",
|
||||
"checked": [s[0] for s in servers]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="lsp_server_typescript",
|
||||
title="TypeScript LSP Server",
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if TypeScript language server is available"
|
||||
)
|
||||
def check_lsp_server_typescript() -> CheckResult:
|
||||
"""Check if TypeScript language server is available."""
|
||||
import shutil
|
||||
|
||||
# Check for TypeScript language server
|
||||
if shutil.which("typescript-language-server"):
|
||||
return CheckResult(
|
||||
status=CheckStatus.PASS,
|
||||
message="TypeScript LSP server available",
|
||||
details={"command": "typescript-language-server"}
|
||||
)
|
||||
elif shutil.which("tsserver"):
|
||||
return CheckResult(
|
||||
status=CheckStatus.PASS,
|
||||
message="TypeScript server available (tsserver)",
|
||||
details={"command": "tsserver"}
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
status=CheckStatus.INFO,
|
||||
message="TypeScript LSP server not found (optional)",
|
||||
details={
|
||||
"hint": "Install with: npm install -g typescript-language-server typescript"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="lsp_runtime",
|
||||
title="LSP Runtime Check",
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if LSP runtime can start",
|
||||
requires_deep=True
|
||||
)
|
||||
def check_lsp_runtime() -> CheckResult:
|
||||
"""Check if LSP runtime can start."""
|
||||
try:
|
||||
from praisonai.cli.features.interactive_runtime import create_runtime
|
||||
|
||||
async def _check():
|
||||
runtime = create_runtime(
|
||||
workspace=".",
|
||||
lsp=True,
|
||||
acp=False
|
||||
)
|
||||
try:
|
||||
await runtime.start()
|
||||
ready = runtime.lsp_ready
|
||||
error = runtime._lsp_state.error
|
||||
return ready, error
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
ready, error = asyncio.run(_check())
|
||||
|
||||
if ready:
|
||||
return CheckResult(
|
||||
status=CheckStatus.PASS,
|
||||
message="LSP runtime started successfully",
|
||||
details={"ready": True}
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
status=CheckStatus.WARN,
|
||||
message=f"LSP runtime failed to start: {error}",
|
||||
details={"ready": False, "error": error}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"LSP runtime check failed: {e}",
|
||||
details={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
def register_lsp_checks():
|
||||
"""Register all LSP checks."""
|
||||
pass
|
||||
@@ -0,0 +1,585 @@
|
||||
"""
|
||||
Interactive Runtime for PraisonAI.
|
||||
|
||||
Provides the core runtime that powers both TUI interactive mode and debug non-interactive mode.
|
||||
Manages LSP and ACP subsystem lifecycle with graceful degradation.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubsystemStatus(Enum):
|
||||
"""Status of a subsystem."""
|
||||
NOT_STARTED = "not_started"
|
||||
STARTING = "starting"
|
||||
READY = "ready"
|
||||
FAILED = "failed"
|
||||
STOPPED = "stopped"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubsystemState:
|
||||
"""State of a subsystem."""
|
||||
status: SubsystemStatus = SubsystemStatus.NOT_STARTED
|
||||
error: Optional[str] = None
|
||||
start_time: Optional[float] = None
|
||||
ready_time: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeConfig:
|
||||
"""Configuration for InteractiveRuntime."""
|
||||
workspace: str = "."
|
||||
lsp_enabled: bool = True
|
||||
acp_enabled: bool = True
|
||||
approval_mode: str = "manual" # manual, auto, scoped
|
||||
trace_enabled: bool = False
|
||||
trace_file: Optional[str] = None
|
||||
json_output: bool = False
|
||||
timeout: float = 60.0
|
||||
model: Optional[str] = None
|
||||
verbose: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class TraceEntry:
|
||||
"""A single trace entry."""
|
||||
timestamp: float
|
||||
category: str # lsp, acp, tool, llm, file
|
||||
action: str
|
||||
params: Dict[str, Any] = field(default_factory=dict)
|
||||
result: Optional[Any] = None
|
||||
duration_ms: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeTrace:
|
||||
"""Trace of a runtime session."""
|
||||
version: str = "1.0"
|
||||
start_time: float = field(default_factory=time.time)
|
||||
end_time: Optional[float] = None
|
||||
config: Optional[RuntimeConfig] = None
|
||||
entries: List[TraceEntry] = field(default_factory=list)
|
||||
|
||||
def add_entry(self, category: str, action: str, params: Dict[str, Any] = None,
|
||||
result: Any = None, duration_ms: float = None, error: str = None):
|
||||
"""Add a trace entry."""
|
||||
self.entries.append(TraceEntry(
|
||||
timestamp=time.time(),
|
||||
category=category,
|
||||
action=action,
|
||||
params=params or {},
|
||||
result=result,
|
||||
duration_ms=duration_ms,
|
||||
error=error
|
||||
))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert trace to dictionary."""
|
||||
return {
|
||||
"version": self.version,
|
||||
"start_time": self.start_time,
|
||||
"end_time": self.end_time,
|
||||
"config": {
|
||||
"workspace": self.config.workspace if self.config else None,
|
||||
"lsp_enabled": self.config.lsp_enabled if self.config else None,
|
||||
"acp_enabled": self.config.acp_enabled if self.config else None,
|
||||
"approval_mode": self.config.approval_mode if self.config else None,
|
||||
} if self.config else None,
|
||||
"entries": [
|
||||
{
|
||||
"timestamp": e.timestamp,
|
||||
"category": e.category,
|
||||
"action": e.action,
|
||||
"params": e.params,
|
||||
"result": e.result,
|
||||
"duration_ms": e.duration_ms,
|
||||
"error": e.error
|
||||
}
|
||||
for e in self.entries
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class InteractiveRuntime:
|
||||
"""
|
||||
Core runtime for interactive coding assistant.
|
||||
|
||||
Manages:
|
||||
- LSP subsystem for code intelligence
|
||||
- ACP subsystem for action orchestration
|
||||
- Trace collection for debugging
|
||||
- Graceful degradation when subsystems fail
|
||||
"""
|
||||
|
||||
def __init__(self, config: RuntimeConfig = None):
|
||||
"""Initialize the runtime."""
|
||||
self.config = config or RuntimeConfig()
|
||||
self._lsp_state = SubsystemState()
|
||||
self._acp_state = SubsystemState()
|
||||
self._lsp_client = None
|
||||
self._acp_session = None
|
||||
self._trace = RuntimeTrace(config=self.config) if self.config.trace_enabled else None
|
||||
self._started = False
|
||||
self._read_only = False # Enforced when ACP fails
|
||||
|
||||
@property
|
||||
def lsp_ready(self) -> bool:
|
||||
"""Check if LSP is ready."""
|
||||
return self._lsp_state.status == SubsystemStatus.READY
|
||||
|
||||
@property
|
||||
def acp_ready(self) -> bool:
|
||||
"""Check if ACP is ready."""
|
||||
return self._acp_state.status == SubsystemStatus.READY
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
"""Check if runtime is in read-only mode."""
|
||||
return self._read_only or not self.acp_ready
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get runtime status."""
|
||||
return {
|
||||
"started": self._started,
|
||||
"workspace": self.config.workspace,
|
||||
"lsp": {
|
||||
"enabled": self.config.lsp_enabled,
|
||||
"status": self._lsp_state.status.value,
|
||||
"ready": self.lsp_ready,
|
||||
"error": self._lsp_state.error
|
||||
},
|
||||
"acp": {
|
||||
"enabled": self.config.acp_enabled,
|
||||
"status": self._acp_state.status.value,
|
||||
"ready": self.acp_ready,
|
||||
"error": self._acp_state.error
|
||||
},
|
||||
"read_only": self.read_only,
|
||||
"approval_mode": self.config.approval_mode
|
||||
}
|
||||
|
||||
async def start(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Start the runtime and all enabled subsystems.
|
||||
|
||||
Returns status dict with subsystem states.
|
||||
"""
|
||||
if self._started:
|
||||
return self.get_status()
|
||||
|
||||
workspace = Path(self.config.workspace).resolve()
|
||||
if not workspace.exists():
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.config.workspace = str(workspace)
|
||||
|
||||
# Start subsystems in parallel
|
||||
tasks = []
|
||||
if self.config.lsp_enabled:
|
||||
tasks.append(self._start_lsp())
|
||||
if self.config.acp_enabled:
|
||||
tasks.append(self._start_acp())
|
||||
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Enforce read-only if ACP failed
|
||||
if self.config.acp_enabled and not self.acp_ready:
|
||||
self._read_only = True
|
||||
logger.warning("ACP unavailable - runtime is READ-ONLY")
|
||||
|
||||
self._started = True
|
||||
|
||||
if self._trace:
|
||||
self._trace.add_entry(
|
||||
category="runtime",
|
||||
action="start",
|
||||
params={"workspace": self.config.workspace},
|
||||
result=self.get_status()
|
||||
)
|
||||
|
||||
return self.get_status()
|
||||
|
||||
async def _start_lsp(self):
|
||||
"""Start LSP subsystem."""
|
||||
self._lsp_state.status = SubsystemStatus.STARTING
|
||||
self._lsp_state.start_time = time.time()
|
||||
|
||||
try:
|
||||
# Lazy import LSP client
|
||||
from praisonaiagents.lsp import LSPClient
|
||||
|
||||
# Detect language from workspace
|
||||
language = self._detect_workspace_language()
|
||||
|
||||
self._lsp_client = LSPClient(
|
||||
language=language,
|
||||
root_uri=f"file://{self.config.workspace}"
|
||||
)
|
||||
await self._lsp_client.start()
|
||||
|
||||
# Wait for initialization
|
||||
if self._lsp_client.is_running:
|
||||
self._lsp_state.status = SubsystemStatus.READY
|
||||
self._lsp_state.ready_time = time.time()
|
||||
logger.info(f"LSP ready for {language}")
|
||||
else:
|
||||
raise RuntimeError("LSP client failed to start")
|
||||
|
||||
except ImportError as e:
|
||||
self._lsp_state.status = SubsystemStatus.FAILED
|
||||
self._lsp_state.error = f"LSP module not available: {e}"
|
||||
logger.warning(self._lsp_state.error)
|
||||
except Exception as e:
|
||||
self._lsp_state.status = SubsystemStatus.FAILED
|
||||
self._lsp_state.error = str(e)
|
||||
logger.warning(f"LSP failed to start: {e}")
|
||||
|
||||
async def _start_acp(self):
|
||||
"""Start ACP subsystem (in-process session)."""
|
||||
self._acp_state.status = SubsystemStatus.STARTING
|
||||
self._acp_state.start_time = time.time()
|
||||
|
||||
try:
|
||||
# Lazy import ACP
|
||||
from praisonai.acp.session import ACPSession
|
||||
from pathlib import Path
|
||||
|
||||
# Create a session for the workspace
|
||||
self._acp_session = ACPSession.create(
|
||||
workspace=Path(self.config.workspace)
|
||||
)
|
||||
self._acp_session.mode = self.config.approval_mode
|
||||
|
||||
self._acp_state.status = SubsystemStatus.READY
|
||||
self._acp_state.ready_time = time.time()
|
||||
logger.info("ACP session ready")
|
||||
|
||||
except ImportError as e:
|
||||
self._acp_state.status = SubsystemStatus.FAILED
|
||||
self._acp_state.error = f"ACP module not available: {e}"
|
||||
logger.warning(self._acp_state.error)
|
||||
except Exception as e:
|
||||
self._acp_state.status = SubsystemStatus.FAILED
|
||||
self._acp_state.error = str(e)
|
||||
logger.warning(f"ACP failed to start: {e}")
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the runtime and all subsystems."""
|
||||
if self._lsp_client:
|
||||
try:
|
||||
await self._lsp_client.stop()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error stopping LSP: {e}")
|
||||
self._lsp_state.status = SubsystemStatus.STOPPED
|
||||
|
||||
if self._acp_session:
|
||||
# ACPSession is a dataclass, no close method needed
|
||||
self._acp_session = None
|
||||
self._acp_state.status = SubsystemStatus.STOPPED
|
||||
|
||||
if self._trace:
|
||||
self._trace.end_time = time.time()
|
||||
self._trace.add_entry(
|
||||
category="runtime",
|
||||
action="stop",
|
||||
result=self.get_status()
|
||||
)
|
||||
|
||||
self._started = False
|
||||
|
||||
def _detect_workspace_language(self) -> str:
|
||||
"""Detect primary language in workspace."""
|
||||
workspace = Path(self.config.workspace)
|
||||
|
||||
# Count files by extension
|
||||
extensions = {}
|
||||
for ext in [".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java"]:
|
||||
count = len(list(workspace.rglob(f"*{ext}")))
|
||||
if count > 0:
|
||||
extensions[ext] = count
|
||||
|
||||
if not extensions:
|
||||
return "python" # Default
|
||||
|
||||
# Map extensions to languages
|
||||
ext_to_lang = {
|
||||
".py": "python",
|
||||
".js": "javascript",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescript",
|
||||
".jsx": "javascript",
|
||||
".go": "go",
|
||||
".rs": "rust",
|
||||
".java": "java"
|
||||
}
|
||||
|
||||
# Return language with most files
|
||||
top_ext = max(extensions, key=extensions.get)
|
||||
return ext_to_lang.get(top_ext, "python")
|
||||
|
||||
# LSP Operations
|
||||
async def lsp_get_symbols(self, file_path: str) -> List[Dict[str, Any]]:
|
||||
"""Get symbols in a file via LSP."""
|
||||
if not self.lsp_ready:
|
||||
return []
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
# Use workspace symbols or document symbols
|
||||
result = await self._lsp_client.get_document_symbols(file_path)
|
||||
duration = (time.time() - start) * 1000
|
||||
|
||||
if self._trace:
|
||||
self._trace.add_entry(
|
||||
category="lsp",
|
||||
action="get_symbols",
|
||||
params={"file": file_path},
|
||||
result={"count": len(result) if result else 0},
|
||||
duration_ms=duration
|
||||
)
|
||||
|
||||
return result or []
|
||||
except Exception as e:
|
||||
if self._trace:
|
||||
self._trace.add_entry(
|
||||
category="lsp",
|
||||
action="get_symbols",
|
||||
params={"file": file_path},
|
||||
error=str(e)
|
||||
)
|
||||
return []
|
||||
|
||||
async def lsp_get_definition(self, file_path: str, line: int, col: int) -> List[Dict[str, Any]]:
|
||||
"""Get definition location via LSP."""
|
||||
if not self.lsp_ready:
|
||||
return []
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
result = await self._lsp_client.get_definition(file_path, line, col)
|
||||
duration = (time.time() - start) * 1000
|
||||
|
||||
if self._trace:
|
||||
self._trace.add_entry(
|
||||
category="lsp",
|
||||
action="get_definition",
|
||||
params={"file": file_path, "line": line, "col": col},
|
||||
result={"count": len(result) if result else 0},
|
||||
duration_ms=duration
|
||||
)
|
||||
|
||||
return [loc.__dict__ if hasattr(loc, '__dict__') else loc for loc in (result or [])]
|
||||
except Exception as e:
|
||||
if self._trace:
|
||||
self._trace.add_entry(
|
||||
category="lsp",
|
||||
action="get_definition",
|
||||
params={"file": file_path, "line": line, "col": col},
|
||||
error=str(e)
|
||||
)
|
||||
return []
|
||||
|
||||
async def lsp_get_references(self, file_path: str, line: int, col: int) -> List[Dict[str, Any]]:
|
||||
"""Get references via LSP."""
|
||||
if not self.lsp_ready:
|
||||
return []
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
result = await self._lsp_client.get_references(file_path, line, col)
|
||||
duration = (time.time() - start) * 1000
|
||||
|
||||
if self._trace:
|
||||
self._trace.add_entry(
|
||||
category="lsp",
|
||||
action="get_references",
|
||||
params={"file": file_path, "line": line, "col": col},
|
||||
result={"count": len(result) if result else 0},
|
||||
duration_ms=duration
|
||||
)
|
||||
|
||||
return [loc.__dict__ if hasattr(loc, '__dict__') else loc for loc in (result or [])]
|
||||
except Exception as e:
|
||||
if self._trace:
|
||||
self._trace.add_entry(
|
||||
category="lsp",
|
||||
action="get_references",
|
||||
params={"file": file_path, "line": line, "col": col},
|
||||
error=str(e)
|
||||
)
|
||||
return []
|
||||
|
||||
async def lsp_get_diagnostics(self, file_path: str = None) -> List[Dict[str, Any]]:
|
||||
"""Get diagnostics via LSP."""
|
||||
if not self.lsp_ready:
|
||||
return []
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
if file_path:
|
||||
result = await self._lsp_client.get_diagnostics(file_path)
|
||||
else:
|
||||
result = self._lsp_client._diagnostics # All cached diagnostics
|
||||
duration = (time.time() - start) * 1000
|
||||
|
||||
if self._trace:
|
||||
self._trace.add_entry(
|
||||
category="lsp",
|
||||
action="get_diagnostics",
|
||||
params={"file": file_path},
|
||||
result={"count": len(result) if result else 0},
|
||||
duration_ms=duration
|
||||
)
|
||||
|
||||
return [d.__dict__ if hasattr(d, '__dict__') else d for d in (result or [])]
|
||||
except Exception as e:
|
||||
if self._trace:
|
||||
self._trace.add_entry(
|
||||
category="lsp",
|
||||
action="get_diagnostics",
|
||||
params={"file": file_path},
|
||||
error=str(e)
|
||||
)
|
||||
return []
|
||||
|
||||
# ACP Operations
|
||||
async def acp_create_plan(self, prompt: str) -> Dict[str, Any]:
|
||||
"""Create an action plan via ACP session tracking."""
|
||||
if not self.acp_ready:
|
||||
return {"error": "ACP not available", "read_only": True}
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
# ACP session tracks the plan but doesn't create it
|
||||
# Return empty steps - ActionOrchestrator handles actual planning
|
||||
result = {"steps": [], "prompt": prompt}
|
||||
duration = (time.time() - start) * 1000
|
||||
|
||||
if self._trace:
|
||||
self._trace.add_entry(
|
||||
category="acp",
|
||||
action="create_plan",
|
||||
params={"prompt": prompt[:100]},
|
||||
result=result,
|
||||
duration_ms=duration
|
||||
)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
if self._trace:
|
||||
self._trace.add_entry(
|
||||
category="acp",
|
||||
action="create_plan",
|
||||
params={"prompt": prompt[:100]},
|
||||
error=str(e)
|
||||
)
|
||||
return {"error": str(e)}
|
||||
|
||||
async def acp_apply_plan(self, plan: Dict[str, Any], auto_approve: bool = False) -> Dict[str, Any]:
|
||||
"""Apply an action plan via ACP session tracking."""
|
||||
if self.read_only:
|
||||
return {"error": "Runtime is read-only", "applied": False}
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
# Track the action in session
|
||||
if self._acp_session:
|
||||
self._acp_session.add_tool_call(
|
||||
tool_call_id=f"plan_{int(time.time())}",
|
||||
title="apply_plan",
|
||||
status="applied" if auto_approve else "pending"
|
||||
)
|
||||
|
||||
result = {"applied": True, "auto_approve": auto_approve}
|
||||
duration = (time.time() - start) * 1000
|
||||
|
||||
if self._trace:
|
||||
self._trace.add_entry(
|
||||
category="acp",
|
||||
action="apply_plan",
|
||||
params={"plan_id": plan.get("id"), "auto_approve": auto_approve},
|
||||
result=result,
|
||||
duration_ms=duration
|
||||
)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
if self._trace:
|
||||
self._trace.add_entry(
|
||||
category="acp",
|
||||
action="apply_plan",
|
||||
params={"plan_id": plan.get("id")},
|
||||
error=str(e)
|
||||
)
|
||||
return {"error": str(e), "applied": False}
|
||||
|
||||
def get_trace(self) -> Optional[RuntimeTrace]:
|
||||
"""Get the runtime trace."""
|
||||
return self._trace
|
||||
|
||||
def save_trace(self, path: str = None):
|
||||
"""Save trace to file."""
|
||||
if not self._trace:
|
||||
return
|
||||
|
||||
import json
|
||||
path = path or self.config.trace_file or "praisonai_trace.json"
|
||||
with open(path, "w") as f:
|
||||
json.dump(self._trace.to_dict(), f, indent=2, default=str)
|
||||
logger.info(f"Trace saved to {path}")
|
||||
|
||||
|
||||
def create_runtime(
|
||||
workspace: str = ".",
|
||||
lsp: bool = True,
|
||||
acp: bool = True,
|
||||
approval: str = "manual",
|
||||
trace: bool = False,
|
||||
trace_file: str = None,
|
||||
json_output: bool = False,
|
||||
timeout: float = 60.0,
|
||||
model: str = None,
|
||||
verbose: bool = False
|
||||
) -> InteractiveRuntime:
|
||||
"""
|
||||
Factory function to create an InteractiveRuntime.
|
||||
|
||||
Args:
|
||||
workspace: Workspace root directory
|
||||
lsp: Enable LSP subsystem
|
||||
acp: Enable ACP subsystem
|
||||
approval: Approval mode (manual, auto, scoped)
|
||||
trace: Enable trace collection
|
||||
trace_file: Path to save trace
|
||||
json_output: Enable JSON output mode
|
||||
timeout: Operation timeout
|
||||
model: LLM model to use
|
||||
verbose: Enable verbose output
|
||||
|
||||
Returns:
|
||||
Configured InteractiveRuntime instance
|
||||
"""
|
||||
config = RuntimeConfig(
|
||||
workspace=workspace,
|
||||
lsp_enabled=lsp,
|
||||
acp_enabled=acp,
|
||||
approval_mode=approval,
|
||||
trace_enabled=trace,
|
||||
trace_file=trace_file,
|
||||
json_output=json_output,
|
||||
timeout=timeout,
|
||||
model=model,
|
||||
verbose=verbose
|
||||
)
|
||||
return InteractiveRuntime(config)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
LSP CLI handler for PraisonAI.
|
||||
|
||||
Provides LSP service lifecycle commands:
|
||||
- lsp start: Start LSP server(s)
|
||||
- lsp stop: Stop LSP server(s)
|
||||
- lsp status: Show status
|
||||
- lsp logs: Show recent logs
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LSPHandler:
|
||||
"""Handler for LSP CLI commands."""
|
||||
|
||||
@staticmethod
|
||||
def setup_subparser(subparsers) -> None:
|
||||
"""Set up the lsp subcommand parser."""
|
||||
lsp_parser = subparsers.add_parser(
|
||||
"lsp",
|
||||
help="LSP service lifecycle management",
|
||||
description="Manage Language Server Protocol servers"
|
||||
)
|
||||
|
||||
lsp_subparsers = lsp_parser.add_subparsers(dest="lsp_command", help="LSP subcommands")
|
||||
|
||||
# lsp start
|
||||
start_parser = lsp_subparsers.add_parser("start", help="Start LSP server(s)")
|
||||
start_parser.add_argument(
|
||||
"--language", "-l",
|
||||
type=str,
|
||||
help="Language to start (default: auto-detect)"
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--workspace", "-w",
|
||||
type=str,
|
||||
default=".",
|
||||
help="Workspace root directory"
|
||||
)
|
||||
start_parser.add_argument("--json", action="store_true", help="Output JSON")
|
||||
start_parser.set_defaults(func=LSPHandler.handle_start)
|
||||
|
||||
# lsp stop
|
||||
stop_parser = lsp_subparsers.add_parser("stop", help="Stop LSP server(s)")
|
||||
stop_parser.add_argument("--json", action="store_true", help="Output JSON")
|
||||
stop_parser.set_defaults(func=LSPHandler.handle_stop)
|
||||
|
||||
# lsp status
|
||||
status_parser = lsp_subparsers.add_parser("status", help="Show LSP status")
|
||||
status_parser.add_argument(
|
||||
"--workspace", "-w",
|
||||
type=str,
|
||||
default=".",
|
||||
help="Workspace root directory"
|
||||
)
|
||||
status_parser.add_argument("--json", action="store_true", help="Output JSON")
|
||||
status_parser.set_defaults(func=LSPHandler.handle_status)
|
||||
|
||||
# lsp logs
|
||||
logs_parser = lsp_subparsers.add_parser("logs", help="Show recent logs")
|
||||
logs_parser.add_argument(
|
||||
"--tail", "-n",
|
||||
type=int,
|
||||
default=50,
|
||||
help="Number of lines to show"
|
||||
)
|
||||
logs_parser.add_argument("--json", action="store_true", help="Output JSON")
|
||||
logs_parser.set_defaults(func=LSPHandler.handle_logs)
|
||||
|
||||
@staticmethod
|
||||
def handle_start(args: argparse.Namespace) -> int:
|
||||
"""Handle lsp start command."""
|
||||
return asyncio.run(LSPHandler._run_start(args))
|
||||
|
||||
@staticmethod
|
||||
async def _run_start(args: argparse.Namespace) -> int:
|
||||
"""Start LSP server."""
|
||||
from .interactive_runtime import create_runtime
|
||||
|
||||
runtime = create_runtime(
|
||||
workspace=args.workspace,
|
||||
lsp=True,
|
||||
acp=False
|
||||
)
|
||||
|
||||
try:
|
||||
await runtime.start()
|
||||
|
||||
result = {
|
||||
"action": "start",
|
||||
"lsp_ready": runtime.lsp_ready,
|
||||
"lsp_status": runtime._lsp_state.status.value,
|
||||
"workspace": args.workspace,
|
||||
"language": getattr(args, 'language', None) or "auto-detected"
|
||||
}
|
||||
|
||||
if runtime._lsp_state.error:
|
||||
result["error"] = runtime._lsp_state.error
|
||||
|
||||
LSPHandler._output_result(result, args.json)
|
||||
|
||||
# Keep running if started successfully
|
||||
if runtime.lsp_ready:
|
||||
if not args.json:
|
||||
print("\nLSP server running. Press Ctrl+C to stop.")
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
return 0 if runtime.lsp_ready else 1
|
||||
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
@staticmethod
|
||||
def handle_stop(args: argparse.Namespace) -> int:
|
||||
"""Handle lsp stop command."""
|
||||
result = {
|
||||
"action": "stop",
|
||||
"message": "LSP servers stopped"
|
||||
}
|
||||
LSPHandler._output_result(result, args.json)
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def handle_status(args: argparse.Namespace) -> int:
|
||||
"""Handle lsp status command."""
|
||||
return asyncio.run(LSPHandler._run_status(args))
|
||||
|
||||
@staticmethod
|
||||
async def _run_status(args: argparse.Namespace) -> int:
|
||||
"""Check LSP status."""
|
||||
from .interactive_runtime import create_runtime
|
||||
|
||||
runtime = create_runtime(
|
||||
workspace=args.workspace,
|
||||
lsp=True,
|
||||
acp=False
|
||||
)
|
||||
|
||||
try:
|
||||
await runtime.start()
|
||||
|
||||
result = {
|
||||
"lsp_enabled": True,
|
||||
"lsp_ready": runtime.lsp_ready,
|
||||
"lsp_status": runtime._lsp_state.status.value,
|
||||
"workspace": args.workspace
|
||||
}
|
||||
|
||||
if runtime._lsp_state.error:
|
||||
result["error"] = runtime._lsp_state.error
|
||||
|
||||
LSPHandler._output_result(result, args.json)
|
||||
return 0 if runtime.lsp_ready else 1
|
||||
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
@staticmethod
|
||||
def handle_logs(args: argparse.Namespace) -> int:
|
||||
"""Handle lsp logs command."""
|
||||
result = {
|
||||
"action": "logs",
|
||||
"tail": args.tail,
|
||||
"logs": [],
|
||||
"message": "LSP logging not yet implemented"
|
||||
}
|
||||
LSPHandler._output_result(result, args.json)
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _output_result(result: dict, as_json: bool) -> None:
|
||||
"""Output result in appropriate format."""
|
||||
if as_json:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
for key, value in result.items():
|
||||
print(f"{key}: {value}")
|
||||
|
||||
|
||||
def run_lsp_command(args: List[str]) -> int:
|
||||
"""Run lsp command from CLI."""
|
||||
parser = argparse.ArgumentParser(prog="praisonai lsp")
|
||||
subparsers = parser.add_subparsers(dest="lsp_command", help="LSP subcommands")
|
||||
|
||||
# lsp start
|
||||
start_parser = subparsers.add_parser("start", help="Start LSP server(s)")
|
||||
start_parser.add_argument("--language", "-l", type=str)
|
||||
start_parser.add_argument("--workspace", "-w", type=str, default=".")
|
||||
start_parser.add_argument("--json", action="store_true")
|
||||
start_parser.set_defaults(func=LSPHandler.handle_start)
|
||||
|
||||
# lsp stop
|
||||
stop_parser = subparsers.add_parser("stop", help="Stop LSP server(s)")
|
||||
stop_parser.add_argument("--json", action="store_true")
|
||||
stop_parser.set_defaults(func=LSPHandler.handle_stop)
|
||||
|
||||
# lsp status
|
||||
status_parser = subparsers.add_parser("status", help="Show LSP status")
|
||||
status_parser.add_argument("--workspace", "-w", type=str, default=".")
|
||||
status_parser.add_argument("--json", action="store_true")
|
||||
status_parser.set_defaults(func=LSPHandler.handle_status)
|
||||
|
||||
# lsp logs
|
||||
logs_parser = subparsers.add_parser("logs", help="Show recent logs")
|
||||
logs_parser.add_argument("--tail", "-n", type=int, default=50)
|
||||
logs_parser.add_argument("--json", action="store_true")
|
||||
logs_parser.set_defaults(func=LSPHandler.handle_logs)
|
||||
|
||||
parsed = parser.parse_args(args)
|
||||
if hasattr(parsed, 'func'):
|
||||
return parsed.func(parsed)
|
||||
else:
|
||||
parser.print_help()
|
||||
return 0
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Queue System for PraisonAI TUI.
|
||||
|
||||
Provides multi-run, multi-agent queueing with priorities, cancel/retry,
|
||||
streaming output, and persistence.
|
||||
|
||||
Lazy-loaded to avoid import overhead when not used.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import QueuedRun, RunState, RunPriority, QueueConfig
|
||||
from .scheduler import QueueScheduler
|
||||
from .worker import WorkerPool
|
||||
from .persistence import QueuePersistence
|
||||
|
||||
_lazy_cache = {}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load queue components."""
|
||||
global _lazy_cache
|
||||
|
||||
if name in _lazy_cache:
|
||||
return _lazy_cache[name]
|
||||
|
||||
if name == "QueuedRun":
|
||||
from .models import QueuedRun
|
||||
_lazy_cache[name] = QueuedRun
|
||||
return QueuedRun
|
||||
elif name == "RunState":
|
||||
from .models import RunState
|
||||
_lazy_cache[name] = RunState
|
||||
return RunState
|
||||
elif name == "RunPriority":
|
||||
from .models import RunPriority
|
||||
_lazy_cache[name] = RunPriority
|
||||
return RunPriority
|
||||
elif name == "QueueConfig":
|
||||
from .models import QueueConfig
|
||||
_lazy_cache[name] = QueueConfig
|
||||
return QueueConfig
|
||||
elif name == "QueueScheduler":
|
||||
from .scheduler import QueueScheduler
|
||||
_lazy_cache[name] = QueueScheduler
|
||||
return QueueScheduler
|
||||
elif name == "WorkerPool":
|
||||
from .worker import WorkerPool
|
||||
_lazy_cache[name] = WorkerPool
|
||||
return WorkerPool
|
||||
elif name == "QueuePersistence":
|
||||
from .persistence import QueuePersistence
|
||||
_lazy_cache[name] = QueuePersistence
|
||||
return QueuePersistence
|
||||
elif name == "QueueManager":
|
||||
from .manager import QueueManager
|
||||
_lazy_cache[name] = QueueManager
|
||||
return QueueManager
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"QueuedRun",
|
||||
"RunState",
|
||||
"RunPriority",
|
||||
"QueueConfig",
|
||||
"QueueScheduler",
|
||||
"WorkerPool",
|
||||
"QueuePersistence",
|
||||
"QueueManager",
|
||||
]
|
||||
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
Queue Manager for PraisonAI.
|
||||
|
||||
High-level interface that coordinates scheduler, workers, and persistence.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Callable, Coroutine, Dict, List, Optional
|
||||
|
||||
from .models import QueuedRun, RunState, RunPriority, QueueConfig, QueueStats, QueueEvent
|
||||
from .scheduler import QueueScheduler, QueueFullError
|
||||
from .worker import WorkerPool
|
||||
from .persistence import QueuePersistence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QueueManager:
|
||||
"""
|
||||
High-level queue manager that coordinates all queue components.
|
||||
|
||||
Provides a unified interface for:
|
||||
- Submitting and managing runs
|
||||
- Starting/stopping workers
|
||||
- Persistence and crash recovery
|
||||
- Event handling
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[QueueConfig] = None,
|
||||
on_output: Optional[Callable[[str, str], Coroutine[Any, Any, None]]] = None,
|
||||
on_complete: Optional[Callable[[str, QueuedRun], Coroutine[Any, Any, None]]] = None,
|
||||
on_error: Optional[Callable[[str, Exception], Coroutine[Any, Any, None]]] = None,
|
||||
on_event: Optional[Callable[[QueueEvent], Coroutine[Any, Any, None]]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize queue manager.
|
||||
|
||||
Args:
|
||||
config: Queue configuration.
|
||||
on_output: Callback for streaming output.
|
||||
on_complete: Callback when run completes.
|
||||
on_error: Callback when run fails.
|
||||
on_event: Callback for queue events.
|
||||
"""
|
||||
self.config = config or QueueConfig()
|
||||
|
||||
# Initialize components
|
||||
self.scheduler = QueueScheduler(self.config)
|
||||
self.persistence = QueuePersistence(self.config.db_path) if self.config.enable_persistence else None
|
||||
|
||||
# Store callbacks
|
||||
self._on_output = on_output
|
||||
self._on_complete = on_complete
|
||||
self._on_error = on_error
|
||||
self._on_event = on_event
|
||||
|
||||
# Worker pool (created on start)
|
||||
self.workers: Optional[WorkerPool] = None
|
||||
|
||||
# Autosave task
|
||||
self._autosave_task: Optional[asyncio.Task] = None
|
||||
|
||||
# Running state
|
||||
self._running = False
|
||||
|
||||
# Session tracking
|
||||
self._current_session_id: Optional[str] = None
|
||||
|
||||
async def start(self, recover: bool = True) -> None:
|
||||
"""
|
||||
Start the queue manager.
|
||||
|
||||
Args:
|
||||
recover: If True, recover pending runs from persistence.
|
||||
"""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
logger.info("Starting queue manager")
|
||||
|
||||
# Initialize persistence
|
||||
if self.persistence:
|
||||
self.persistence.initialize()
|
||||
|
||||
if recover:
|
||||
await self._recover_runs()
|
||||
|
||||
# Create and start worker pool
|
||||
self.workers = WorkerPool(
|
||||
scheduler=self.scheduler,
|
||||
on_output=self._on_output,
|
||||
on_complete=self._wrap_complete_callback(),
|
||||
on_error=self._wrap_error_callback(),
|
||||
on_event=self._on_event,
|
||||
max_workers=self.config.max_concurrent_global,
|
||||
poll_interval=self.config.worker_poll_interval,
|
||||
stream_buffer_size=self.config.stream_buffer_size,
|
||||
)
|
||||
|
||||
await self.workers.start()
|
||||
|
||||
# Start autosave
|
||||
if self.persistence and self.config.autosave_interval_seconds > 0:
|
||||
self._autosave_task = asyncio.create_task(
|
||||
self._autosave_loop(),
|
||||
name="queue-autosave"
|
||||
)
|
||||
|
||||
self._running = True
|
||||
logger.info("Queue manager started")
|
||||
|
||||
async def stop(self, timeout: float = 10.0) -> None:
|
||||
"""
|
||||
Stop the queue manager gracefully.
|
||||
|
||||
Args:
|
||||
timeout: Seconds to wait for workers to finish.
|
||||
"""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
logger.info("Stopping queue manager")
|
||||
|
||||
# Stop autosave
|
||||
if self._autosave_task:
|
||||
self._autosave_task.cancel()
|
||||
try:
|
||||
await self._autosave_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._autosave_task = None
|
||||
|
||||
# Stop workers
|
||||
if self.workers:
|
||||
await self.workers.stop(timeout=timeout)
|
||||
self.workers = None
|
||||
|
||||
# Final save
|
||||
if self.persistence:
|
||||
await self._save_all_runs()
|
||||
self.persistence.close()
|
||||
|
||||
self._running = False
|
||||
logger.info("Queue manager stopped")
|
||||
|
||||
async def submit(
|
||||
self,
|
||||
input_content: str,
|
||||
agent_name: str = "Assistant",
|
||||
priority: RunPriority = RunPriority.NORMAL,
|
||||
session_id: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
run_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Submit a new run to the queue.
|
||||
|
||||
Args:
|
||||
input_content: The input/prompt for the agent.
|
||||
agent_name: Name of the agent to use.
|
||||
priority: Run priority.
|
||||
session_id: Session ID for grouping.
|
||||
workspace: Workspace path.
|
||||
config: Additional configuration.
|
||||
run_id: Optional specific run ID.
|
||||
|
||||
Returns:
|
||||
The run ID.
|
||||
"""
|
||||
run = QueuedRun(
|
||||
agent_name=agent_name,
|
||||
input_content=input_content,
|
||||
priority=priority,
|
||||
session_id=session_id or self._current_session_id,
|
||||
workspace=workspace or os.getcwd(),
|
||||
config=config or {},
|
||||
max_retries=self.config.default_max_retries,
|
||||
)
|
||||
|
||||
if run_id:
|
||||
run.run_id = run_id
|
||||
|
||||
# Submit to scheduler
|
||||
result_id = await self.scheduler.submit(run)
|
||||
|
||||
# Persist
|
||||
if self.persistence:
|
||||
self.persistence.save_run(run)
|
||||
|
||||
return result_id
|
||||
|
||||
async def cancel(self, run_id: str) -> bool:
|
||||
"""Cancel a run."""
|
||||
result = await self.scheduler.cancel(run_id)
|
||||
|
||||
if result and self.persistence:
|
||||
run = self.scheduler.get_run(run_id)
|
||||
if run:
|
||||
self.persistence.save_run(run)
|
||||
|
||||
return result
|
||||
|
||||
async def retry(self, run_id: str) -> Optional[str]:
|
||||
"""Retry a failed run."""
|
||||
new_id = await self.scheduler.retry(run_id)
|
||||
|
||||
if new_id and self.persistence:
|
||||
run = self.scheduler.get_run(new_id)
|
||||
if run:
|
||||
self.persistence.save_run(run)
|
||||
|
||||
return new_id
|
||||
|
||||
async def pause(self, run_id: str) -> bool:
|
||||
"""Pause a running run."""
|
||||
return await self.scheduler.pause(run_id)
|
||||
|
||||
async def resume(self, run_id: str) -> bool:
|
||||
"""Resume a paused run."""
|
||||
return await self.scheduler.resume(run_id)
|
||||
|
||||
def get_run(self, run_id: str) -> Optional[QueuedRun]:
|
||||
"""Get a run by ID."""
|
||||
return self.scheduler.get_run(run_id)
|
||||
|
||||
def get_queued(self) -> List[QueuedRun]:
|
||||
"""Get all queued runs."""
|
||||
return self.scheduler.get_queued()
|
||||
|
||||
def get_running(self) -> List[QueuedRun]:
|
||||
"""Get all running runs."""
|
||||
return self.scheduler.get_running()
|
||||
|
||||
def list_runs(
|
||||
self,
|
||||
state: Optional[RunState] = None,
|
||||
session_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
) -> List[QueuedRun]:
|
||||
"""
|
||||
List runs with optional filters.
|
||||
|
||||
For active runs, uses scheduler. For historical, uses persistence.
|
||||
"""
|
||||
if state and state.is_terminal() and self.persistence:
|
||||
return self.persistence.list_runs(
|
||||
state=state,
|
||||
session_id=session_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Get from scheduler
|
||||
runs = self.scheduler.get_all()
|
||||
|
||||
if state:
|
||||
runs = [r for r in runs if r.state == state]
|
||||
if session_id:
|
||||
runs = [r for r in runs if r.session_id == session_id]
|
||||
|
||||
return runs[:limit]
|
||||
|
||||
async def clear_queue(self) -> int:
|
||||
"""Clear all queued runs."""
|
||||
count = await self.scheduler.clear_queue()
|
||||
return count
|
||||
|
||||
def get_stats(self) -> QueueStats:
|
||||
"""Get queue statistics."""
|
||||
if self.persistence:
|
||||
return self.persistence.get_stats(self._current_session_id)
|
||||
|
||||
# Calculate from scheduler
|
||||
queued = self.scheduler.get_queued()
|
||||
running = self.scheduler.get_running()
|
||||
|
||||
return QueueStats(
|
||||
queued_count=len(queued),
|
||||
running_count=len(running),
|
||||
)
|
||||
|
||||
# Session management
|
||||
|
||||
def set_session(self, session_id: str) -> None:
|
||||
"""Set the current session ID."""
|
||||
self._current_session_id = session_id
|
||||
|
||||
def save_session_state(self, state: Dict[str, Any]) -> None:
|
||||
"""Save session state."""
|
||||
if self.persistence and self._current_session_id:
|
||||
self.persistence.save_session(
|
||||
self._current_session_id,
|
||||
state=state,
|
||||
)
|
||||
|
||||
def load_session_state(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load session state."""
|
||||
if self.persistence:
|
||||
return self.persistence.load_session(session_id)
|
||||
return None
|
||||
|
||||
def list_sessions(self, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""List recent sessions."""
|
||||
if self.persistence:
|
||||
return self.persistence.list_sessions(limit)
|
||||
return []
|
||||
|
||||
# Internal methods
|
||||
|
||||
async def _recover_runs(self) -> None:
|
||||
"""Recover pending runs from persistence."""
|
||||
if not self.persistence:
|
||||
return
|
||||
|
||||
# Mark interrupted runs as failed
|
||||
interrupted = self.persistence.mark_interrupted_as_failed()
|
||||
if interrupted:
|
||||
logger.warning(f"Marked {interrupted} interrupted runs as failed")
|
||||
|
||||
# Load pending runs
|
||||
pending = self.persistence.load_pending_runs()
|
||||
if pending:
|
||||
logger.info(f"Recovering {len(pending)} pending runs")
|
||||
self.scheduler.load_runs(pending)
|
||||
|
||||
for run in pending:
|
||||
self.persistence.mark_recovered(run.run_id)
|
||||
|
||||
async def _save_all_runs(self) -> None:
|
||||
"""Save all active runs to persistence."""
|
||||
if not self.persistence:
|
||||
return
|
||||
|
||||
for run in self.scheduler.get_all():
|
||||
self.persistence.save_run(run)
|
||||
|
||||
async def _autosave_loop(self) -> None:
|
||||
"""Periodically save runs to persistence."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self.config.autosave_interval_seconds)
|
||||
await self._save_all_runs()
|
||||
logger.debug("Autosaved queue state")
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Autosave error: {e}")
|
||||
|
||||
def _wrap_complete_callback(self):
|
||||
"""Wrap complete callback to include persistence."""
|
||||
async def wrapper(run_id: str, run: QueuedRun):
|
||||
# Persist
|
||||
if self.persistence:
|
||||
self.persistence.save_run(run)
|
||||
|
||||
# Call user callback
|
||||
if self._on_complete:
|
||||
await self._on_complete(run_id, run)
|
||||
|
||||
return wrapper
|
||||
|
||||
def _wrap_error_callback(self):
|
||||
"""Wrap error callback to include persistence."""
|
||||
async def wrapper(run_id: str, error: Exception):
|
||||
# Persist
|
||||
if self.persistence:
|
||||
run = self.scheduler.get_run(run_id)
|
||||
if run:
|
||||
self.persistence.save_run(run)
|
||||
|
||||
# Call user callback
|
||||
if self._on_error:
|
||||
await self._on_error(run_id, error)
|
||||
|
||||
return wrapper
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Whether the queue manager is running."""
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def queued_count(self) -> int:
|
||||
"""Number of queued runs."""
|
||||
return self.scheduler.queued_count
|
||||
|
||||
@property
|
||||
def running_count(self) -> int:
|
||||
"""Number of running runs."""
|
||||
return self.scheduler.running_count
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Data models for the PraisonAI Queue System.
|
||||
|
||||
Defines QueuedRun, RunState, RunPriority, and QueueConfig.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, IntEnum
|
||||
from typing import Any, Dict, List, Optional
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
class RunState(str, Enum):
|
||||
"""State of a queued run."""
|
||||
QUEUED = "queued"
|
||||
RUNNING = "running"
|
||||
PAUSED = "paused"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
def is_terminal(self) -> bool:
|
||||
"""Check if this is a terminal state."""
|
||||
return self in (RunState.SUCCEEDED, RunState.FAILED, RunState.CANCELLED)
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Check if this is an active (non-terminal) state."""
|
||||
return not self.is_terminal()
|
||||
|
||||
|
||||
class RunPriority(IntEnum):
|
||||
"""Priority levels for queued runs. Higher value = higher priority."""
|
||||
LOW = 0
|
||||
NORMAL = 1
|
||||
HIGH = 2
|
||||
URGENT = 3
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, s: str) -> "RunPriority":
|
||||
"""Parse priority from string."""
|
||||
mapping = {
|
||||
"low": cls.LOW,
|
||||
"normal": cls.NORMAL,
|
||||
"high": cls.HIGH,
|
||||
"urgent": cls.URGENT,
|
||||
}
|
||||
return mapping.get(s.lower(), cls.NORMAL)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueuedRun:
|
||||
"""A single queued agent run."""
|
||||
|
||||
# Identity
|
||||
run_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
|
||||
agent_name: str = ""
|
||||
input_content: str = ""
|
||||
|
||||
# State
|
||||
state: RunState = RunState.QUEUED
|
||||
priority: RunPriority = RunPriority.NORMAL
|
||||
|
||||
# Attribution / tracing
|
||||
session_id: Optional[str] = None
|
||||
trace_id: Optional[str] = None
|
||||
workspace: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
|
||||
# Timing
|
||||
created_at: float = field(default_factory=time.time)
|
||||
started_at: Optional[float] = None
|
||||
ended_at: Optional[float] = None
|
||||
|
||||
# Results
|
||||
output_content: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
metrics: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Retry tracking
|
||||
retry_count: int = 0
|
||||
max_retries: int = 3
|
||||
parent_run_id: Optional[str] = None # Link to original run if this is a retry
|
||||
|
||||
# Configuration
|
||||
config: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Streaming state
|
||||
output_chunks: List[str] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Ensure proper types after initialization."""
|
||||
if isinstance(self.state, str):
|
||||
self.state = RunState(self.state)
|
||||
if isinstance(self.priority, int) and not isinstance(self.priority, RunPriority):
|
||||
self.priority = RunPriority(self.priority)
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> Optional[float]:
|
||||
"""Get run duration in seconds."""
|
||||
if self.started_at is None:
|
||||
return None
|
||||
end = self.ended_at or time.time()
|
||||
return end - self.started_at
|
||||
|
||||
@property
|
||||
def wait_seconds(self) -> float:
|
||||
"""Get time spent waiting in queue."""
|
||||
start = self.started_at or time.time()
|
||||
return start - self.created_at
|
||||
|
||||
def can_retry(self) -> bool:
|
||||
"""Check if this run can be retried."""
|
||||
return (
|
||||
self.state == RunState.FAILED and
|
||||
self.retry_count < self.max_retries
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for persistence."""
|
||||
return {
|
||||
"run_id": self.run_id,
|
||||
"agent_name": self.agent_name,
|
||||
"input_content": self.input_content,
|
||||
"state": self.state.value,
|
||||
"priority": int(self.priority),
|
||||
"session_id": self.session_id,
|
||||
"trace_id": self.trace_id,
|
||||
"workspace": self.workspace,
|
||||
"user_id": self.user_id,
|
||||
"created_at": self.created_at,
|
||||
"started_at": self.started_at,
|
||||
"ended_at": self.ended_at,
|
||||
"output_content": self.output_content,
|
||||
"error": self.error,
|
||||
"metrics": self.metrics,
|
||||
"retry_count": self.retry_count,
|
||||
"max_retries": self.max_retries,
|
||||
"parent_run_id": self.parent_run_id,
|
||||
"config": self.config,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "QueuedRun":
|
||||
"""Create from dictionary."""
|
||||
# Handle state conversion
|
||||
state = data.get("state", "queued")
|
||||
if isinstance(state, str):
|
||||
state = RunState(state)
|
||||
|
||||
# Handle priority conversion
|
||||
priority = data.get("priority", 1)
|
||||
if isinstance(priority, int):
|
||||
priority = RunPriority(priority)
|
||||
|
||||
return cls(
|
||||
run_id=data.get("run_id", str(uuid.uuid4())[:8]),
|
||||
agent_name=data.get("agent_name", ""),
|
||||
input_content=data.get("input_content", ""),
|
||||
state=state,
|
||||
priority=priority,
|
||||
session_id=data.get("session_id"),
|
||||
trace_id=data.get("trace_id"),
|
||||
workspace=data.get("workspace"),
|
||||
user_id=data.get("user_id"),
|
||||
created_at=data.get("created_at", time.time()),
|
||||
started_at=data.get("started_at"),
|
||||
ended_at=data.get("ended_at"),
|
||||
output_content=data.get("output_content"),
|
||||
error=data.get("error"),
|
||||
metrics=data.get("metrics", {}),
|
||||
retry_count=data.get("retry_count", 0),
|
||||
max_retries=data.get("max_retries", 3),
|
||||
parent_run_id=data.get("parent_run_id"),
|
||||
config=data.get("config", {}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueueConfig:
|
||||
"""Configuration for the queue system."""
|
||||
|
||||
# Concurrency limits
|
||||
max_concurrent_global: int = 4
|
||||
max_concurrent_per_agent: int = 2
|
||||
max_concurrent_per_workspace: int = 4
|
||||
|
||||
# Queue limits
|
||||
max_queue_size: int = 100
|
||||
|
||||
# Defaults
|
||||
default_priority: RunPriority = RunPriority.NORMAL
|
||||
default_max_retries: int = 3
|
||||
|
||||
# Persistence
|
||||
enable_persistence: bool = True
|
||||
db_path: str = ".praison/queue.db"
|
||||
|
||||
# Autosave
|
||||
autosave_interval_seconds: float = 30.0
|
||||
|
||||
# Backpressure
|
||||
stream_buffer_size: int = 1000
|
||||
drop_strategy: str = "oldest" # "oldest" or "newest"
|
||||
|
||||
# Timeouts
|
||||
run_timeout_seconds: Optional[float] = None # None = no timeout
|
||||
worker_poll_interval: float = 0.1
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"max_concurrent_global": self.max_concurrent_global,
|
||||
"max_concurrent_per_agent": self.max_concurrent_per_agent,
|
||||
"max_concurrent_per_workspace": self.max_concurrent_per_workspace,
|
||||
"max_queue_size": self.max_queue_size,
|
||||
"default_priority": int(self.default_priority),
|
||||
"default_max_retries": self.default_max_retries,
|
||||
"enable_persistence": self.enable_persistence,
|
||||
"db_path": self.db_path,
|
||||
"autosave_interval_seconds": self.autosave_interval_seconds,
|
||||
"stream_buffer_size": self.stream_buffer_size,
|
||||
"drop_strategy": self.drop_strategy,
|
||||
"run_timeout_seconds": self.run_timeout_seconds,
|
||||
"worker_poll_interval": self.worker_poll_interval,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "QueueConfig":
|
||||
"""Create from dictionary."""
|
||||
priority = data.get("default_priority", 1)
|
||||
if isinstance(priority, int):
|
||||
priority = RunPriority(priority)
|
||||
|
||||
return cls(
|
||||
max_concurrent_global=data.get("max_concurrent_global", 4),
|
||||
max_concurrent_per_agent=data.get("max_concurrent_per_agent", 2),
|
||||
max_concurrent_per_workspace=data.get("max_concurrent_per_workspace", 4),
|
||||
max_queue_size=data.get("max_queue_size", 100),
|
||||
default_priority=priority,
|
||||
default_max_retries=data.get("default_max_retries", 3),
|
||||
enable_persistence=data.get("enable_persistence", True),
|
||||
db_path=data.get("db_path", ".praison/queue.db"),
|
||||
autosave_interval_seconds=data.get("autosave_interval_seconds", 30.0),
|
||||
stream_buffer_size=data.get("stream_buffer_size", 1000),
|
||||
drop_strategy=data.get("drop_strategy", "oldest"),
|
||||
run_timeout_seconds=data.get("run_timeout_seconds"),
|
||||
worker_poll_interval=data.get("worker_poll_interval", 0.1),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueueStats:
|
||||
"""Statistics about the queue."""
|
||||
queued_count: int = 0
|
||||
running_count: int = 0
|
||||
succeeded_count: int = 0
|
||||
failed_count: int = 0
|
||||
cancelled_count: int = 0
|
||||
total_runs: int = 0
|
||||
avg_wait_seconds: float = 0.0
|
||||
avg_duration_seconds: float = 0.0
|
||||
|
||||
@property
|
||||
def active_count(self) -> int:
|
||||
"""Count of active (non-terminal) runs."""
|
||||
return self.queued_count + self.running_count
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamChunk:
|
||||
"""A chunk of streaming output."""
|
||||
run_id: str
|
||||
content: str
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
chunk_index: int = 0
|
||||
is_final: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueueEvent:
|
||||
"""An event from the queue system."""
|
||||
event_type: str # "run_started", "run_completed", "run_failed", "output_chunk", "state_changed"
|
||||
run_id: str
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
data: Dict[str, Any] = field(default_factory=dict)
|
||||
@@ -0,0 +1,564 @@
|
||||
"""
|
||||
SQLite persistence layer for the PraisonAI Queue System.
|
||||
|
||||
Provides crash recovery, session persistence, and run history.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .models import QueuedRun, RunState, RunPriority, QueueStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA_VERSION = "1.0"
|
||||
|
||||
SCHEMA_SQL = """
|
||||
-- Schema version tracking
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at REAL NOT NULL
|
||||
);
|
||||
|
||||
-- Queue runs
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
agent_name TEXT NOT NULL,
|
||||
input_content TEXT,
|
||||
output_content TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'queued',
|
||||
priority INTEGER NOT NULL DEFAULT 1,
|
||||
session_id TEXT,
|
||||
trace_id TEXT,
|
||||
workspace TEXT,
|
||||
user_id TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
started_at REAL,
|
||||
ended_at REAL,
|
||||
error TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
max_retries INTEGER DEFAULT 3,
|
||||
parent_run_id TEXT,
|
||||
config TEXT,
|
||||
metrics TEXT,
|
||||
recovered INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_state ON runs(state);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_session ON runs(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_priority_created ON runs(priority DESC, created_at ASC);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_workspace ON runs(workspace);
|
||||
|
||||
-- Messages (chat history per run)
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
run_id TEXT REFERENCES runs(run_id),
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
timestamp REAL NOT NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_run ON messages(run_id);
|
||||
|
||||
-- Tool calls
|
||||
CREATE TABLE IF NOT EXISTS tool_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
run_id TEXT REFERENCES runs(run_id),
|
||||
tool_name TEXT NOT NULL,
|
||||
args TEXT,
|
||||
result TEXT,
|
||||
started_at REAL NOT NULL,
|
||||
ended_at REAL,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_calls_run ON tool_calls(run_id);
|
||||
|
||||
-- Sessions
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
state TEXT,
|
||||
config TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
"""
|
||||
|
||||
|
||||
class QueuePersistence:
|
||||
"""SQLite-backed persistence for the queue system."""
|
||||
|
||||
def __init__(self, db_path: str = ".praison/queue.db"):
|
||||
"""
|
||||
Initialize persistence layer.
|
||||
|
||||
Args:
|
||||
db_path: Path to SQLite database file.
|
||||
"""
|
||||
self.db_path = db_path
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._lock = threading.Lock()
|
||||
self._initialized = False
|
||||
|
||||
def _ensure_dir(self) -> None:
|
||||
"""Ensure database directory exists."""
|
||||
db_dir = os.path.dirname(self.db_path)
|
||||
if db_dir:
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
|
||||
def _get_connection(self) -> sqlite3.Connection:
|
||||
"""Get or create database connection."""
|
||||
if self._conn is None:
|
||||
self._ensure_dir()
|
||||
self._conn = sqlite3.connect(
|
||||
self.db_path,
|
||||
check_same_thread=False,
|
||||
timeout=30.0
|
||||
)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
# Enable WAL mode for better concurrency
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=NORMAL")
|
||||
return self._conn
|
||||
|
||||
@contextmanager
|
||||
def _transaction(self):
|
||||
"""Context manager for database transactions."""
|
||||
conn = self._get_connection()
|
||||
with self._lock:
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialize database schema."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
with self._transaction() as conn:
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
|
||||
# Check/set schema version
|
||||
cursor = conn.execute(
|
||||
"SELECT version FROM schema_version ORDER BY applied_at DESC LIMIT 1"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
||||
(SCHEMA_VERSION, time.time())
|
||||
)
|
||||
elif row["version"] != SCHEMA_VERSION:
|
||||
# Future: handle migrations
|
||||
logger.warning(
|
||||
f"Schema version mismatch: {row['version']} vs {SCHEMA_VERSION}"
|
||||
)
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close database connection."""
|
||||
if self._conn is not None:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
self._initialized = False
|
||||
|
||||
# Run operations
|
||||
|
||||
def save_run(self, run: QueuedRun) -> None:
|
||||
"""Save or update a run."""
|
||||
self.initialize()
|
||||
|
||||
with self._transaction() as conn:
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO runs (
|
||||
run_id, agent_name, input_content, output_content,
|
||||
state, priority, session_id, trace_id, workspace, user_id,
|
||||
created_at, started_at, ended_at, error,
|
||||
retry_count, max_retries, parent_run_id,
|
||||
config, metrics
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
run.run_id,
|
||||
run.agent_name,
|
||||
run.input_content,
|
||||
run.output_content,
|
||||
run.state.value,
|
||||
int(run.priority),
|
||||
run.session_id,
|
||||
run.trace_id,
|
||||
run.workspace,
|
||||
run.user_id,
|
||||
run.created_at,
|
||||
run.started_at,
|
||||
run.ended_at,
|
||||
run.error,
|
||||
run.retry_count,
|
||||
run.max_retries,
|
||||
run.parent_run_id,
|
||||
json.dumps(run.config) if run.config else None,
|
||||
json.dumps(run.metrics) if run.metrics else None,
|
||||
))
|
||||
|
||||
def load_run(self, run_id: str) -> Optional[QueuedRun]:
|
||||
"""Load a run by ID."""
|
||||
self.initialize()
|
||||
|
||||
with self._transaction() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM runs WHERE run_id = ?",
|
||||
(run_id,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return self._row_to_run(row)
|
||||
|
||||
def list_runs(
|
||||
self,
|
||||
state: Optional[RunState] = None,
|
||||
session_id: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> List[QueuedRun]:
|
||||
"""List runs with optional filters."""
|
||||
self.initialize()
|
||||
|
||||
query = "SELECT * FROM runs WHERE 1=1"
|
||||
params: List[Any] = []
|
||||
|
||||
if state is not None:
|
||||
query += " AND state = ?"
|
||||
params.append(state.value)
|
||||
|
||||
if session_id is not None:
|
||||
query += " AND session_id = ?"
|
||||
params.append(session_id)
|
||||
|
||||
if workspace is not None:
|
||||
query += " AND workspace = ?"
|
||||
params.append(workspace)
|
||||
|
||||
query += " ORDER BY priority DESC, created_at ASC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
with self._transaction() as conn:
|
||||
cursor = conn.execute(query, params)
|
||||
return [self._row_to_run(row) for row in cursor.fetchall()]
|
||||
|
||||
def delete_run(self, run_id: str) -> bool:
|
||||
"""Delete a run and its related data."""
|
||||
self.initialize()
|
||||
|
||||
with self._transaction() as conn:
|
||||
# Delete related data first
|
||||
conn.execute("DELETE FROM messages WHERE run_id = ?", (run_id,))
|
||||
conn.execute("DELETE FROM tool_calls WHERE run_id = ?", (run_id,))
|
||||
|
||||
cursor = conn.execute("DELETE FROM runs WHERE run_id = ?", (run_id,))
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def update_run_state(
|
||||
self,
|
||||
run_id: str,
|
||||
state: RunState,
|
||||
error: Optional[str] = None,
|
||||
output: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Update run state."""
|
||||
self.initialize()
|
||||
|
||||
updates = ["state = ?"]
|
||||
params: List[Any] = [state.value]
|
||||
|
||||
if state == RunState.RUNNING:
|
||||
updates.append("started_at = ?")
|
||||
params.append(time.time())
|
||||
elif state.is_terminal():
|
||||
updates.append("ended_at = ?")
|
||||
params.append(time.time())
|
||||
|
||||
if error is not None:
|
||||
updates.append("error = ?")
|
||||
params.append(error)
|
||||
|
||||
if output is not None:
|
||||
updates.append("output_content = ?")
|
||||
params.append(output)
|
||||
|
||||
params.append(run_id)
|
||||
|
||||
with self._transaction() as conn:
|
||||
cursor = conn.execute(
|
||||
f"UPDATE runs SET {', '.join(updates)} WHERE run_id = ?",
|
||||
params
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
# Crash recovery
|
||||
|
||||
def load_pending_runs(self) -> List[QueuedRun]:
|
||||
"""Load runs that were QUEUED or RUNNING at crash."""
|
||||
self.initialize()
|
||||
|
||||
with self._transaction() as conn:
|
||||
cursor = conn.execute("""
|
||||
SELECT * FROM runs
|
||||
WHERE state IN ('queued', 'running')
|
||||
ORDER BY priority DESC, created_at ASC
|
||||
""")
|
||||
return [self._row_to_run(row) for row in cursor.fetchall()]
|
||||
|
||||
def mark_recovered(self, run_id: str) -> None:
|
||||
"""Mark a run as recovered after restart."""
|
||||
self.initialize()
|
||||
|
||||
with self._transaction() as conn:
|
||||
conn.execute(
|
||||
"UPDATE runs SET recovered = 1 WHERE run_id = ?",
|
||||
(run_id,)
|
||||
)
|
||||
|
||||
def mark_interrupted_as_failed(self) -> int:
|
||||
"""Mark all RUNNING runs as FAILED (for crash recovery)."""
|
||||
self.initialize()
|
||||
|
||||
with self._transaction() as conn:
|
||||
cursor = conn.execute("""
|
||||
UPDATE runs
|
||||
SET state = 'failed',
|
||||
error = 'Interrupted by crash/restart',
|
||||
ended_at = ?
|
||||
WHERE state = 'running'
|
||||
""", (time.time(),))
|
||||
return cursor.rowcount
|
||||
|
||||
# Statistics
|
||||
|
||||
def get_stats(self, session_id: Optional[str] = None) -> QueueStats:
|
||||
"""Get queue statistics."""
|
||||
self.initialize()
|
||||
|
||||
with self._transaction() as conn:
|
||||
where = ""
|
||||
params: List[Any] = []
|
||||
if session_id:
|
||||
where = "WHERE session_id = ?"
|
||||
params = [session_id]
|
||||
|
||||
# Count by state
|
||||
cursor = conn.execute(f"""
|
||||
SELECT state, COUNT(*) as count
|
||||
FROM runs {where}
|
||||
GROUP BY state
|
||||
""", params)
|
||||
|
||||
counts = {row["state"]: row["count"] for row in cursor.fetchall()}
|
||||
|
||||
# Average wait time
|
||||
cursor = conn.execute(f"""
|
||||
SELECT AVG(started_at - created_at) as avg_wait
|
||||
FROM runs
|
||||
{where + ' AND' if where else 'WHERE'} started_at IS NOT NULL
|
||||
""", params)
|
||||
row = cursor.fetchone()
|
||||
avg_wait = row["avg_wait"] or 0.0
|
||||
|
||||
# Average duration
|
||||
cursor = conn.execute(f"""
|
||||
SELECT AVG(ended_at - started_at) as avg_duration
|
||||
FROM runs
|
||||
{where + ' AND' if where else 'WHERE'} ended_at IS NOT NULL AND started_at IS NOT NULL
|
||||
""", params)
|
||||
row = cursor.fetchone()
|
||||
avg_duration = row["avg_duration"] or 0.0
|
||||
|
||||
return QueueStats(
|
||||
queued_count=counts.get("queued", 0),
|
||||
running_count=counts.get("running", 0),
|
||||
succeeded_count=counts.get("succeeded", 0),
|
||||
failed_count=counts.get("failed", 0),
|
||||
cancelled_count=counts.get("cancelled", 0),
|
||||
total_runs=sum(counts.values()),
|
||||
avg_wait_seconds=avg_wait,
|
||||
avg_duration_seconds=avg_duration,
|
||||
)
|
||||
|
||||
# Session operations
|
||||
|
||||
def save_session(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
state: Optional[Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Save or update a session."""
|
||||
self.initialize()
|
||||
|
||||
now = time.time()
|
||||
|
||||
with self._transaction() as conn:
|
||||
# Check if exists
|
||||
cursor = conn.execute(
|
||||
"SELECT session_id FROM sessions WHERE session_id = ?",
|
||||
(session_id,)
|
||||
)
|
||||
exists = cursor.fetchone() is not None
|
||||
|
||||
if exists:
|
||||
conn.execute("""
|
||||
UPDATE sessions SET
|
||||
user_id = COALESCE(?, user_id),
|
||||
updated_at = ?,
|
||||
state = COALESCE(?, state),
|
||||
config = COALESCE(?, config)
|
||||
WHERE session_id = ?
|
||||
""", (
|
||||
user_id,
|
||||
now,
|
||||
json.dumps(state) if state else None,
|
||||
json.dumps(config) if config else None,
|
||||
session_id,
|
||||
))
|
||||
else:
|
||||
conn.execute("""
|
||||
INSERT INTO sessions (session_id, user_id, created_at, updated_at, state, config)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
session_id,
|
||||
user_id,
|
||||
now,
|
||||
now,
|
||||
json.dumps(state) if state else None,
|
||||
json.dumps(config) if config else None,
|
||||
))
|
||||
|
||||
def load_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load a session."""
|
||||
self.initialize()
|
||||
|
||||
with self._transaction() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM sessions WHERE session_id = ?",
|
||||
(session_id,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"session_id": row["session_id"],
|
||||
"user_id": row["user_id"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"state": json.loads(row["state"]) if row["state"] else None,
|
||||
"config": json.loads(row["config"]) if row["config"] else None,
|
||||
}
|
||||
|
||||
def list_sessions(self, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""List recent sessions."""
|
||||
self.initialize()
|
||||
|
||||
with self._transaction() as conn:
|
||||
cursor = conn.execute("""
|
||||
SELECT * FROM sessions
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
|
||||
return [
|
||||
{
|
||||
"session_id": row["session_id"],
|
||||
"user_id": row["user_id"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"state": json.loads(row["state"]) if row["state"] else None,
|
||||
"config": json.loads(row["config"]) if row["config"] else None,
|
||||
}
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
|
||||
# Helper methods
|
||||
|
||||
def _row_to_run(self, row: sqlite3.Row) -> QueuedRun:
|
||||
"""Convert database row to QueuedRun."""
|
||||
return QueuedRun(
|
||||
run_id=row["run_id"],
|
||||
agent_name=row["agent_name"],
|
||||
input_content=row["input_content"] or "",
|
||||
output_content=row["output_content"],
|
||||
state=RunState(row["state"]),
|
||||
priority=RunPriority(row["priority"]),
|
||||
session_id=row["session_id"],
|
||||
trace_id=row["trace_id"],
|
||||
workspace=row["workspace"],
|
||||
user_id=row["user_id"],
|
||||
created_at=row["created_at"],
|
||||
started_at=row["started_at"],
|
||||
ended_at=row["ended_at"],
|
||||
error=row["error"],
|
||||
retry_count=row["retry_count"] or 0,
|
||||
max_retries=row["max_retries"] or 3,
|
||||
parent_run_id=row["parent_run_id"],
|
||||
config=json.loads(row["config"]) if row["config"] else {},
|
||||
metrics=json.loads(row["metrics"]) if row["metrics"] else {},
|
||||
)
|
||||
|
||||
# Cleanup
|
||||
|
||||
def cleanup_old_runs(self, days: int = 30) -> int:
|
||||
"""Delete runs older than specified days."""
|
||||
self.initialize()
|
||||
|
||||
cutoff = time.time() - (days * 24 * 60 * 60)
|
||||
|
||||
with self._transaction() as conn:
|
||||
# Get run IDs to delete
|
||||
cursor = conn.execute("""
|
||||
SELECT run_id FROM runs
|
||||
WHERE created_at < ? AND state IN ('succeeded', 'failed', 'cancelled')
|
||||
""", (cutoff,))
|
||||
run_ids = [row["run_id"] for row in cursor.fetchall()]
|
||||
|
||||
if not run_ids:
|
||||
return 0
|
||||
|
||||
placeholders = ",".join("?" * len(run_ids))
|
||||
|
||||
# Delete related data
|
||||
conn.execute(
|
||||
f"DELETE FROM messages WHERE run_id IN ({placeholders})",
|
||||
run_ids
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM tool_calls WHERE run_id IN ({placeholders})",
|
||||
run_ids
|
||||
)
|
||||
|
||||
# Delete runs
|
||||
cursor = conn.execute(
|
||||
f"DELETE FROM runs WHERE run_id IN ({placeholders})",
|
||||
run_ids
|
||||
)
|
||||
|
||||
return cursor.rowcount
|
||||
@@ -0,0 +1,484 @@
|
||||
"""
|
||||
Queue Scheduler for PraisonAI.
|
||||
|
||||
Priority-based FIFO scheduler with concurrency limits.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
|
||||
from .models import QueuedRun, RunState, RunPriority, QueueConfig, QueueEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QueueFullError(Exception):
|
||||
"""Raised when queue is at capacity."""
|
||||
pass
|
||||
|
||||
|
||||
class RunNotFoundError(Exception):
|
||||
"""Raised when a run is not found."""
|
||||
pass
|
||||
|
||||
|
||||
class QueueScheduler:
|
||||
"""
|
||||
Priority-based FIFO scheduler with concurrency limits.
|
||||
|
||||
Runs are organized by priority (URGENT > HIGH > NORMAL > LOW),
|
||||
with FIFO ordering within each priority level.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[QueueConfig] = None):
|
||||
"""
|
||||
Initialize scheduler.
|
||||
|
||||
Args:
|
||||
config: Queue configuration. Uses defaults if not provided.
|
||||
"""
|
||||
self.config = config or QueueConfig()
|
||||
|
||||
# Priority queues: priority -> list of runs (FIFO order)
|
||||
self._queues: Dict[RunPriority, List[QueuedRun]] = {
|
||||
p: [] for p in RunPriority
|
||||
}
|
||||
|
||||
# Running runs: run_id -> run
|
||||
self._running: Dict[str, QueuedRun] = {}
|
||||
|
||||
# All runs by ID for quick lookup
|
||||
self._all_runs: Dict[str, QueuedRun] = {}
|
||||
|
||||
# Lock for thread safety
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# Event callbacks
|
||||
self._event_callbacks: List[Callable[[QueueEvent], None]] = []
|
||||
|
||||
# Cancellation tokens
|
||||
self._cancel_tokens: Set[str] = set()
|
||||
|
||||
def add_event_callback(self, callback: Callable[[QueueEvent], None]) -> None:
|
||||
"""Add a callback for queue events."""
|
||||
self._event_callbacks.append(callback)
|
||||
|
||||
def remove_event_callback(self, callback: Callable[[QueueEvent], None]) -> None:
|
||||
"""Remove an event callback."""
|
||||
if callback in self._event_callbacks:
|
||||
self._event_callbacks.remove(callback)
|
||||
|
||||
def _emit_event(self, event: QueueEvent) -> None:
|
||||
"""Emit an event to all callbacks."""
|
||||
for callback in self._event_callbacks:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in event callback: {e}")
|
||||
|
||||
async def submit(
|
||||
self,
|
||||
run: QueuedRun,
|
||||
check_duplicate: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Submit a run to the queue.
|
||||
|
||||
Args:
|
||||
run: The run to submit.
|
||||
check_duplicate: If True, reject duplicate run_ids.
|
||||
|
||||
Returns:
|
||||
The run_id.
|
||||
|
||||
Raises:
|
||||
QueueFullError: If queue is at capacity.
|
||||
ValueError: If run_id already exists (when check_duplicate=True).
|
||||
"""
|
||||
async with self._lock:
|
||||
# Check for duplicates
|
||||
if check_duplicate and run.run_id in self._all_runs:
|
||||
raise ValueError(f"Run {run.run_id} already exists")
|
||||
|
||||
# Check queue capacity
|
||||
total_queued = sum(len(q) for q in self._queues.values())
|
||||
if total_queued >= self.config.max_queue_size:
|
||||
raise QueueFullError(
|
||||
f"Queue is full ({total_queued}/{self.config.max_queue_size})"
|
||||
)
|
||||
|
||||
# Ensure state is QUEUED
|
||||
run.state = RunState.QUEUED
|
||||
|
||||
# Add to appropriate priority queue
|
||||
self._queues[run.priority].append(run)
|
||||
self._all_runs[run.run_id] = run
|
||||
|
||||
logger.debug(f"Submitted run {run.run_id} with priority {run.priority.name}")
|
||||
|
||||
self._emit_event(QueueEvent(
|
||||
event_type="run_submitted",
|
||||
run_id=run.run_id,
|
||||
data={"priority": run.priority.name, "agent": run.agent_name}
|
||||
))
|
||||
|
||||
return run.run_id
|
||||
|
||||
async def next(self) -> Optional[QueuedRun]:
|
||||
"""
|
||||
Get the next run to execute, respecting priority and concurrency limits.
|
||||
|
||||
Returns:
|
||||
The next run, or None if no run is available.
|
||||
"""
|
||||
async with self._lock:
|
||||
if not self._can_start_new():
|
||||
return None
|
||||
|
||||
# Check priorities in order (URGENT first = highest value)
|
||||
for priority in reversed(list(RunPriority)):
|
||||
queue = self._queues[priority]
|
||||
|
||||
for i, run in enumerate(queue):
|
||||
if self._can_run(run):
|
||||
# Remove from queue
|
||||
queue.pop(i)
|
||||
|
||||
# Update state
|
||||
run.state = RunState.RUNNING
|
||||
run.started_at = time.time()
|
||||
|
||||
# Add to running
|
||||
self._running[run.run_id] = run
|
||||
|
||||
logger.debug(f"Starting run {run.run_id}")
|
||||
|
||||
self._emit_event(QueueEvent(
|
||||
event_type="run_started",
|
||||
run_id=run.run_id,
|
||||
data={"agent": run.agent_name}
|
||||
))
|
||||
|
||||
return run
|
||||
|
||||
return None
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
run_id: str,
|
||||
output: Optional[str] = None,
|
||||
metrics: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[QueuedRun]:
|
||||
"""
|
||||
Mark a run as completed successfully.
|
||||
|
||||
Args:
|
||||
run_id: The run ID.
|
||||
output: The output content.
|
||||
metrics: Optional metrics.
|
||||
|
||||
Returns:
|
||||
The completed run, or None if not found.
|
||||
"""
|
||||
async with self._lock:
|
||||
run = self._running.pop(run_id, None)
|
||||
if run is None:
|
||||
logger.warning(f"Run {run_id} not found in running")
|
||||
return None
|
||||
|
||||
run.state = RunState.SUCCEEDED
|
||||
run.ended_at = time.time()
|
||||
run.output_content = output
|
||||
if metrics:
|
||||
run.metrics.update(metrics)
|
||||
|
||||
logger.debug(f"Completed run {run_id}")
|
||||
|
||||
self._emit_event(QueueEvent(
|
||||
event_type="run_completed",
|
||||
run_id=run_id,
|
||||
data={"duration": run.duration_seconds}
|
||||
))
|
||||
|
||||
return run
|
||||
|
||||
async def fail(
|
||||
self,
|
||||
run_id: str,
|
||||
error: str,
|
||||
metrics: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[QueuedRun]:
|
||||
"""
|
||||
Mark a run as failed.
|
||||
|
||||
Args:
|
||||
run_id: The run ID.
|
||||
error: The error message.
|
||||
metrics: Optional metrics.
|
||||
|
||||
Returns:
|
||||
The failed run, or None if not found.
|
||||
"""
|
||||
async with self._lock:
|
||||
run = self._running.pop(run_id, None)
|
||||
if run is None:
|
||||
logger.warning(f"Run {run_id} not found in running")
|
||||
return None
|
||||
|
||||
run.state = RunState.FAILED
|
||||
run.ended_at = time.time()
|
||||
run.error = error
|
||||
if metrics:
|
||||
run.metrics.update(metrics)
|
||||
|
||||
logger.debug(f"Failed run {run_id}: {error}")
|
||||
|
||||
self._emit_event(QueueEvent(
|
||||
event_type="run_failed",
|
||||
run_id=run_id,
|
||||
data={"error": error}
|
||||
))
|
||||
|
||||
return run
|
||||
|
||||
async def cancel(self, run_id: str) -> bool:
|
||||
"""
|
||||
Cancel a queued or running run.
|
||||
|
||||
Args:
|
||||
run_id: The run ID to cancel.
|
||||
|
||||
Returns:
|
||||
True if cancelled, False if not found.
|
||||
"""
|
||||
async with self._lock:
|
||||
# Check if running
|
||||
if run_id in self._running:
|
||||
run = self._running.pop(run_id)
|
||||
run.state = RunState.CANCELLED
|
||||
run.ended_at = time.time()
|
||||
self._cancel_tokens.add(run_id)
|
||||
|
||||
logger.debug(f"Cancelled running run {run_id}")
|
||||
|
||||
self._emit_event(QueueEvent(
|
||||
event_type="run_cancelled",
|
||||
run_id=run_id,
|
||||
data={"was_running": True}
|
||||
))
|
||||
|
||||
return True
|
||||
|
||||
# Check if queued
|
||||
for priority in RunPriority:
|
||||
queue = self._queues[priority]
|
||||
for i, run in enumerate(queue):
|
||||
if run.run_id == run_id:
|
||||
queue.pop(i)
|
||||
run.state = RunState.CANCELLED
|
||||
run.ended_at = time.time()
|
||||
|
||||
logger.debug(f"Cancelled queued run {run_id}")
|
||||
|
||||
self._emit_event(QueueEvent(
|
||||
event_type="run_cancelled",
|
||||
run_id=run_id,
|
||||
data={"was_running": False}
|
||||
))
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def is_cancelled(self, run_id: str) -> bool:
|
||||
"""Check if a run has been cancelled."""
|
||||
return run_id in self._cancel_tokens
|
||||
|
||||
def clear_cancel_token(self, run_id: str) -> None:
|
||||
"""Clear a cancellation token."""
|
||||
self._cancel_tokens.discard(run_id)
|
||||
|
||||
async def retry(self, run_id: str) -> Optional[str]:
|
||||
"""
|
||||
Retry a failed run.
|
||||
|
||||
Creates a new run with incremented retry count and link to parent.
|
||||
|
||||
Args:
|
||||
run_id: The run ID to retry.
|
||||
|
||||
Returns:
|
||||
The new run_id, or None if retry not allowed.
|
||||
"""
|
||||
async with self._lock:
|
||||
# Find the original run
|
||||
original = self._all_runs.get(run_id)
|
||||
if original is None:
|
||||
logger.warning(f"Run {run_id} not found for retry")
|
||||
return None
|
||||
|
||||
if not original.can_retry():
|
||||
logger.warning(
|
||||
f"Run {run_id} cannot be retried "
|
||||
f"(state={original.state}, retries={original.retry_count}/{original.max_retries})"
|
||||
)
|
||||
return None
|
||||
|
||||
# Create new run
|
||||
new_run = QueuedRun(
|
||||
run_id=str(uuid.uuid4())[:8],
|
||||
agent_name=original.agent_name,
|
||||
input_content=original.input_content,
|
||||
state=RunState.QUEUED,
|
||||
priority=original.priority,
|
||||
session_id=original.session_id,
|
||||
trace_id=original.trace_id,
|
||||
workspace=original.workspace,
|
||||
user_id=original.user_id,
|
||||
retry_count=original.retry_count + 1,
|
||||
max_retries=original.max_retries,
|
||||
parent_run_id=original.run_id,
|
||||
config=original.config.copy(),
|
||||
)
|
||||
|
||||
# Submit outside lock to avoid deadlock
|
||||
await self.submit(new_run, check_duplicate=True)
|
||||
|
||||
logger.debug(f"Retrying run {run_id} as {new_run.run_id}")
|
||||
|
||||
self._emit_event(QueueEvent(
|
||||
event_type="run_retried",
|
||||
run_id=new_run.run_id,
|
||||
data={"parent_run_id": run_id, "retry_count": new_run.retry_count}
|
||||
))
|
||||
|
||||
return new_run.run_id
|
||||
|
||||
async def pause(self, run_id: str) -> bool:
|
||||
"""
|
||||
Pause a running run.
|
||||
|
||||
Note: The worker must check for pause state and handle accordingly.
|
||||
"""
|
||||
async with self._lock:
|
||||
if run_id not in self._running:
|
||||
return False
|
||||
|
||||
run = self._running[run_id]
|
||||
run.state = RunState.PAUSED
|
||||
|
||||
self._emit_event(QueueEvent(
|
||||
event_type="run_paused",
|
||||
run_id=run_id,
|
||||
))
|
||||
|
||||
return True
|
||||
|
||||
async def resume(self, run_id: str) -> bool:
|
||||
"""Resume a paused run."""
|
||||
async with self._lock:
|
||||
if run_id not in self._running:
|
||||
return False
|
||||
|
||||
run = self._running[run_id]
|
||||
if run.state != RunState.PAUSED:
|
||||
return False
|
||||
|
||||
run.state = RunState.RUNNING
|
||||
|
||||
self._emit_event(QueueEvent(
|
||||
event_type="run_resumed",
|
||||
run_id=run_id,
|
||||
))
|
||||
|
||||
return True
|
||||
|
||||
def get_run(self, run_id: str) -> Optional[QueuedRun]:
|
||||
"""Get a run by ID."""
|
||||
return self._all_runs.get(run_id)
|
||||
|
||||
def get_queued(self) -> List[QueuedRun]:
|
||||
"""Get all queued runs in priority order."""
|
||||
result = []
|
||||
for priority in reversed(list(RunPriority)):
|
||||
result.extend(self._queues[priority])
|
||||
return result
|
||||
|
||||
def get_running(self) -> List[QueuedRun]:
|
||||
"""Get all running runs."""
|
||||
return list(self._running.values())
|
||||
|
||||
def get_all(self) -> List[QueuedRun]:
|
||||
"""Get all runs."""
|
||||
return list(self._all_runs.values())
|
||||
|
||||
@property
|
||||
def queued_count(self) -> int:
|
||||
"""Number of queued runs."""
|
||||
return sum(len(q) for q in self._queues.values())
|
||||
|
||||
@property
|
||||
def running_count(self) -> int:
|
||||
"""Number of running runs."""
|
||||
return len(self._running)
|
||||
|
||||
def _can_start_new(self) -> bool:
|
||||
"""Check if we can start a new run (global limit)."""
|
||||
return len(self._running) < self.config.max_concurrent_global
|
||||
|
||||
def _can_run(self, run: QueuedRun) -> bool:
|
||||
"""Check if a specific run can start (per-agent and per-workspace limits)."""
|
||||
# Count running by agent
|
||||
agent_count = sum(
|
||||
1 for r in self._running.values()
|
||||
if r.agent_name == run.agent_name
|
||||
)
|
||||
if agent_count >= self.config.max_concurrent_per_agent:
|
||||
return False
|
||||
|
||||
# Count running by workspace
|
||||
if run.workspace:
|
||||
workspace_count = sum(
|
||||
1 for r in self._running.values()
|
||||
if r.workspace == run.workspace
|
||||
)
|
||||
if workspace_count >= self.config.max_concurrent_per_workspace:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def clear_queue(self) -> int:
|
||||
"""Clear all queued (not running) runs."""
|
||||
async with self._lock:
|
||||
count = 0
|
||||
for priority in RunPriority:
|
||||
count += len(self._queues[priority])
|
||||
for run in self._queues[priority]:
|
||||
run.state = RunState.CANCELLED
|
||||
del self._all_runs[run.run_id]
|
||||
self._queues[priority] = []
|
||||
|
||||
return count
|
||||
|
||||
def load_runs(self, runs: List[QueuedRun]) -> None:
|
||||
"""
|
||||
Load runs (e.g., from persistence after restart).
|
||||
|
||||
Queued runs are added to queues, running runs are re-queued.
|
||||
"""
|
||||
for run in runs:
|
||||
if run.state == RunState.RUNNING:
|
||||
# Re-queue running runs (they were interrupted)
|
||||
run.state = RunState.QUEUED
|
||||
run.started_at = None
|
||||
|
||||
if run.state == RunState.QUEUED:
|
||||
self._queues[run.priority].append(run)
|
||||
|
||||
self._all_runs[run.run_id] = run
|
||||
|
||||
logger.info(f"Loaded {len(runs)} runs from persistence")
|
||||
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
Worker Pool for PraisonAI Queue System.
|
||||
|
||||
Manages async workers that execute queued runs.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any, Callable, Coroutine, Dict, List, Optional, Set
|
||||
|
||||
from .models import QueuedRun, RunState, StreamChunk, QueueEvent
|
||||
from .scheduler import QueueScheduler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StreamBuffer:
|
||||
"""Bounded buffer with backpressure for streaming output."""
|
||||
|
||||
def __init__(self, max_size: int = 1000, drop_strategy: str = "oldest"):
|
||||
"""
|
||||
Initialize stream buffer.
|
||||
|
||||
Args:
|
||||
max_size: Maximum number of chunks to buffer.
|
||||
drop_strategy: "oldest" to drop oldest, "newest" to reject new.
|
||||
"""
|
||||
self._buffer: deque = deque(maxlen=max_size)
|
||||
self._lock = asyncio.Lock()
|
||||
self._drop_strategy = drop_strategy
|
||||
self._dropped_count = 0
|
||||
self._max_size = max_size
|
||||
|
||||
async def push(self, chunk: StreamChunk) -> bool:
|
||||
"""
|
||||
Push a chunk to the buffer.
|
||||
|
||||
Returns:
|
||||
True if added, False if dropped.
|
||||
"""
|
||||
async with self._lock:
|
||||
if len(self._buffer) >= self._max_size:
|
||||
if self._drop_strategy == "oldest":
|
||||
self._buffer.popleft()
|
||||
self._dropped_count += 1
|
||||
elif self._drop_strategy == "newest":
|
||||
self._dropped_count += 1
|
||||
return False
|
||||
self._buffer.append(chunk)
|
||||
return True
|
||||
|
||||
async def drain(self, batch_size: int = 50) -> List[StreamChunk]:
|
||||
"""Drain up to batch_size chunks."""
|
||||
async with self._lock:
|
||||
result = []
|
||||
for _ in range(min(batch_size, len(self._buffer))):
|
||||
result.append(self._buffer.popleft())
|
||||
return result
|
||||
|
||||
async def drain_all(self) -> List[StreamChunk]:
|
||||
"""Drain all chunks."""
|
||||
async with self._lock:
|
||||
result = list(self._buffer)
|
||||
self._buffer.clear()
|
||||
return result
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
"""Current buffer size."""
|
||||
return len(self._buffer)
|
||||
|
||||
@property
|
||||
def dropped_count(self) -> int:
|
||||
"""Number of dropped chunks."""
|
||||
return self._dropped_count
|
||||
|
||||
|
||||
class WorkerPool:
|
||||
"""
|
||||
Manages worker tasks for executing queued runs.
|
||||
|
||||
Workers poll the scheduler for runs and execute them with streaming output.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scheduler: QueueScheduler,
|
||||
on_output: Optional[Callable[[str, str], Coroutine[Any, Any, None]]] = None,
|
||||
on_complete: Optional[Callable[[str, QueuedRun], Coroutine[Any, Any, None]]] = None,
|
||||
on_error: Optional[Callable[[str, Exception], Coroutine[Any, Any, None]]] = None,
|
||||
on_event: Optional[Callable[[QueueEvent], Coroutine[Any, Any, None]]] = None,
|
||||
max_workers: int = 4,
|
||||
poll_interval: float = 0.1,
|
||||
stream_buffer_size: int = 1000,
|
||||
):
|
||||
"""
|
||||
Initialize worker pool.
|
||||
|
||||
Args:
|
||||
scheduler: The queue scheduler.
|
||||
on_output: Callback for streaming output (run_id, chunk).
|
||||
on_complete: Callback when run completes (run_id, run).
|
||||
on_error: Callback when run fails (run_id, exception).
|
||||
on_event: Callback for queue events.
|
||||
max_workers: Maximum number of concurrent workers.
|
||||
poll_interval: Seconds between scheduler polls.
|
||||
stream_buffer_size: Size of stream buffer per run.
|
||||
"""
|
||||
self.scheduler = scheduler
|
||||
self.on_output = on_output
|
||||
self.on_complete = on_complete
|
||||
self.on_error = on_error
|
||||
self.on_event = on_event
|
||||
self.max_workers = max_workers
|
||||
self.poll_interval = poll_interval
|
||||
self.stream_buffer_size = stream_buffer_size
|
||||
|
||||
self._workers: List[asyncio.Task] = []
|
||||
self._running = False
|
||||
self._stream_buffers: Dict[str, StreamBuffer] = {}
|
||||
self._active_runs: Set[str] = set()
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the worker pool."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
|
||||
for i in range(self.max_workers):
|
||||
task = asyncio.create_task(
|
||||
self._worker_loop(i),
|
||||
name=f"queue-worker-{i}"
|
||||
)
|
||||
self._workers.append(task)
|
||||
|
||||
logger.info(f"Started {self.max_workers} queue workers")
|
||||
|
||||
async def stop(self, timeout: float = 5.0) -> None:
|
||||
"""
|
||||
Stop all workers gracefully.
|
||||
|
||||
Args:
|
||||
timeout: Seconds to wait for workers to finish.
|
||||
"""
|
||||
self._running = False
|
||||
|
||||
if not self._workers:
|
||||
return
|
||||
|
||||
# Wait for workers to finish current work
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*self._workers, return_exceptions=True),
|
||||
timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# Cancel remaining workers
|
||||
for task in self._workers:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
await asyncio.gather(*self._workers, return_exceptions=True)
|
||||
|
||||
self._workers.clear()
|
||||
logger.info("Stopped queue workers")
|
||||
|
||||
async def _worker_loop(self, worker_id: int) -> None:
|
||||
"""Main worker loop."""
|
||||
logger.debug(f"Worker {worker_id} started")
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
# Get next run from scheduler
|
||||
run = await self.scheduler.next()
|
||||
|
||||
if run is None:
|
||||
# No work available, wait and retry
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
continue
|
||||
|
||||
# Execute the run
|
||||
self._active_runs.add(run.run_id)
|
||||
try:
|
||||
await self._execute_run(run, worker_id)
|
||||
finally:
|
||||
self._active_runs.discard(run.run_id)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"Worker {worker_id} cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Worker {worker_id} error: {e}", exc_info=True)
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
|
||||
logger.debug(f"Worker {worker_id} stopped")
|
||||
|
||||
async def _execute_run(self, run: QueuedRun, worker_id: int) -> None:
|
||||
"""Execute a single run with streaming output."""
|
||||
logger.debug(f"Worker {worker_id} executing run {run.run_id}")
|
||||
|
||||
# Create stream buffer for this run
|
||||
buffer = StreamBuffer(max_size=self.stream_buffer_size)
|
||||
self._stream_buffers[run.run_id] = buffer
|
||||
|
||||
output_chunks: List[str] = []
|
||||
|
||||
try:
|
||||
# Import agent lazily to avoid circular imports
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Get agent configuration
|
||||
agent_config = run.config.get("agent_config", {})
|
||||
agent_name = run.agent_name or agent_config.get("name", "Assistant")
|
||||
|
||||
# Create agent
|
||||
agent = Agent(
|
||||
name=agent_name,
|
||||
instructions=agent_config.get("instructions", "You are a helpful assistant."),
|
||||
model=agent_config.get("model"),
|
||||
tools=agent_config.get("tools", []),
|
||||
verbose=agent_config.get("verbose", False),
|
||||
)
|
||||
|
||||
# Check for cancellation before starting
|
||||
if self.scheduler.is_cancelled(run.run_id):
|
||||
logger.debug(f"Run {run.run_id} was cancelled before execution")
|
||||
return
|
||||
|
||||
# Execute with streaming if available
|
||||
chunk_index = 0
|
||||
|
||||
# Try streaming first
|
||||
try:
|
||||
async for chunk in self._stream_agent(agent, run.input_content):
|
||||
# Check for cancellation
|
||||
if self.scheduler.is_cancelled(run.run_id):
|
||||
logger.debug(f"Run {run.run_id} cancelled during execution")
|
||||
self.scheduler.clear_cancel_token(run.run_id)
|
||||
return
|
||||
|
||||
# Check for pause
|
||||
while run.state == RunState.PAUSED:
|
||||
await asyncio.sleep(0.1)
|
||||
if self.scheduler.is_cancelled(run.run_id):
|
||||
return
|
||||
|
||||
output_chunks.append(chunk)
|
||||
|
||||
# Buffer the chunk
|
||||
stream_chunk = StreamChunk(
|
||||
run_id=run.run_id,
|
||||
content=chunk,
|
||||
chunk_index=chunk_index,
|
||||
)
|
||||
await buffer.push(stream_chunk)
|
||||
chunk_index += 1
|
||||
|
||||
# Emit output callback
|
||||
if self.on_output:
|
||||
try:
|
||||
await self.on_output(run.run_id, chunk)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in output callback: {e}")
|
||||
|
||||
except Exception as stream_error:
|
||||
# Fallback to non-streaming
|
||||
logger.debug(f"Streaming failed, falling back to sync: {stream_error}")
|
||||
result = agent.chat(run.input_content)
|
||||
output_chunks = [result]
|
||||
|
||||
if self.on_output:
|
||||
try:
|
||||
await self.on_output(run.run_id, result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in output callback: {e}")
|
||||
|
||||
# Combine output
|
||||
full_output = "".join(output_chunks)
|
||||
|
||||
# Mark final chunk
|
||||
final_chunk = StreamChunk(
|
||||
run_id=run.run_id,
|
||||
content="",
|
||||
chunk_index=chunk_index,
|
||||
is_final=True,
|
||||
)
|
||||
await buffer.push(final_chunk)
|
||||
|
||||
# Complete the run
|
||||
completed_run = await self.scheduler.complete(
|
||||
run.run_id,
|
||||
output=full_output,
|
||||
metrics={
|
||||
"chunks": chunk_index,
|
||||
"output_length": len(full_output),
|
||||
}
|
||||
)
|
||||
|
||||
if completed_run and self.on_complete:
|
||||
try:
|
||||
await self.on_complete(run.run_id, completed_run)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in complete callback: {e}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Worker was cancelled
|
||||
await self.scheduler.cancel(run.run_id)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Run {run.run_id} failed: {e}", exc_info=True)
|
||||
|
||||
# Mark as failed
|
||||
failed_run = await self.scheduler.fail(run.run_id, str(e))
|
||||
|
||||
if self.on_error:
|
||||
try:
|
||||
await self.on_error(run.run_id, e)
|
||||
except Exception as callback_error:
|
||||
logger.error(f"Error in error callback: {callback_error}")
|
||||
|
||||
finally:
|
||||
# Cleanup buffer
|
||||
self._stream_buffers.pop(run.run_id, None)
|
||||
|
||||
async def _stream_agent(self, agent, input_content: str):
|
||||
"""
|
||||
Stream output from agent.
|
||||
|
||||
Yields chunks of output text.
|
||||
"""
|
||||
# Try to use agent's async streaming if available
|
||||
if hasattr(agent, 'astream'):
|
||||
async for chunk in agent.astream(input_content):
|
||||
yield chunk
|
||||
elif hasattr(agent, 'stream'):
|
||||
# Wrap sync stream in async
|
||||
for chunk in agent.stream(input_content):
|
||||
yield chunk
|
||||
await asyncio.sleep(0) # Yield control
|
||||
else:
|
||||
# Fallback: get full response and yield in chunks
|
||||
result = agent.chat(input_content)
|
||||
|
||||
# Simulate streaming by yielding words
|
||||
words = result.split()
|
||||
chunk_size = 3 # Words per chunk
|
||||
|
||||
for i in range(0, len(words), chunk_size):
|
||||
chunk_words = words[i:i + chunk_size]
|
||||
chunk = " ".join(chunk_words)
|
||||
if i + chunk_size < len(words):
|
||||
chunk += " "
|
||||
yield chunk
|
||||
await asyncio.sleep(0.05) # Small delay for streaming effect
|
||||
|
||||
def get_stream_buffer(self, run_id: str) -> Optional[StreamBuffer]:
|
||||
"""Get the stream buffer for a run."""
|
||||
return self._stream_buffers.get(run_id)
|
||||
|
||||
@property
|
||||
def active_run_count(self) -> int:
|
||||
"""Number of currently executing runs."""
|
||||
return len(self._active_runs)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Whether the worker pool is running."""
|
||||
return self._running
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
TUI (Terminal User Interface) for PraisonAI.
|
||||
|
||||
Provides an app-like interactive experience with:
|
||||
- Event-loop driven async UI
|
||||
- Multi-pane layout (chat, tools, queue, status)
|
||||
- Streaming output
|
||||
- Queue management
|
||||
- Session persistence
|
||||
- Headless simulation mode for CI/testing
|
||||
|
||||
Requires the [tui] extra: pip install praisonai[tui]
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .app import TUIApp
|
||||
from .events import TUIEvent, TUIEventType
|
||||
from .orchestrator import TuiOrchestrator, UIStateModel, SimulationRunner
|
||||
from .mock_provider import MockProvider, MockProviderConfig
|
||||
|
||||
_lazy_cache = {}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load TUI components."""
|
||||
global _lazy_cache
|
||||
|
||||
if name in _lazy_cache:
|
||||
return _lazy_cache[name]
|
||||
|
||||
if name == "TUIApp":
|
||||
from .app import TUIApp
|
||||
_lazy_cache[name] = TUIApp
|
||||
return TUIApp
|
||||
elif name == "TUIEvent":
|
||||
from .events import TUIEvent
|
||||
_lazy_cache[name] = TUIEvent
|
||||
return TUIEvent
|
||||
elif name == "TUIEventType":
|
||||
from .events import TUIEventType
|
||||
_lazy_cache[name] = TUIEventType
|
||||
return TUIEventType
|
||||
elif name == "run_tui":
|
||||
from .app import run_tui
|
||||
_lazy_cache[name] = run_tui
|
||||
return run_tui
|
||||
elif name == "TuiOrchestrator":
|
||||
from .orchestrator import TuiOrchestrator
|
||||
_lazy_cache[name] = TuiOrchestrator
|
||||
return TuiOrchestrator
|
||||
elif name == "UIStateModel":
|
||||
from .orchestrator import UIStateModel
|
||||
_lazy_cache[name] = UIStateModel
|
||||
return UIStateModel
|
||||
elif name == "SimulationRunner":
|
||||
from .orchestrator import SimulationRunner
|
||||
_lazy_cache[name] = SimulationRunner
|
||||
return SimulationRunner
|
||||
elif name == "MockProvider":
|
||||
from .mock_provider import MockProvider
|
||||
_lazy_cache[name] = MockProvider
|
||||
return MockProvider
|
||||
elif name == "MockProviderConfig":
|
||||
from .mock_provider import MockProviderConfig
|
||||
_lazy_cache[name] = MockProviderConfig
|
||||
return MockProviderConfig
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TUIApp",
|
||||
"TUIEvent",
|
||||
"TUIEventType",
|
||||
"run_tui",
|
||||
"TuiOrchestrator",
|
||||
"UIStateModel",
|
||||
"SimulationRunner",
|
||||
"MockProvider",
|
||||
"MockProviderConfig",
|
||||
]
|
||||
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
Main TUI Application for PraisonAI.
|
||||
|
||||
Event-loop driven async TUI with multi-pane layout.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.screen import Screen
|
||||
from textual import events
|
||||
TEXTUAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
TEXTUAL_AVAILABLE = False
|
||||
App = object
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
if TEXTUAL_AVAILABLE:
|
||||
from .screens.main import MainScreen
|
||||
from .screens.queue import QueueScreen
|
||||
from .screens.settings import SettingsScreen
|
||||
from .screens.session import SessionScreen
|
||||
from .widgets.chat import ChatMessage
|
||||
from .widgets.status import StatusInfo
|
||||
from .widgets.queue_panel import QueueItem
|
||||
from ..queue import QueueManager, QueueConfig, QueuedRun, RunPriority
|
||||
|
||||
class TUIApp(App):
|
||||
"""
|
||||
PraisonAI Terminal User Interface Application.
|
||||
|
||||
Features:
|
||||
- Event-loop driven async UI
|
||||
- Multi-pane layout (chat, tools, queue, status)
|
||||
- Streaming output
|
||||
- Queue management
|
||||
- Session persistence
|
||||
"""
|
||||
|
||||
TITLE = "PraisonAI"
|
||||
SUB_TITLE = "AI Agent Terminal"
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
background: $surface;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+q", "quit", "Quit", show=True, priority=True),
|
||||
Binding("ctrl+c", "cancel", "Cancel", show=True),
|
||||
]
|
||||
|
||||
SCREENS = {
|
||||
"main": MainScreen,
|
||||
"queue": QueueScreen,
|
||||
"settings": SettingsScreen,
|
||||
"sessions": SessionScreen,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
agent_config: Optional[Dict[str, Any]] = None,
|
||||
queue_config: Optional[QueueConfig] = None,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.workspace = workspace or os.getcwd()
|
||||
self.session_id = session_id or str(uuid.uuid4())[:8]
|
||||
self.model = model or os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
|
||||
self.agent_config = agent_config or {}
|
||||
|
||||
# Queue manager
|
||||
self.queue_config = queue_config or QueueConfig()
|
||||
self.queue_manager: Optional[QueueManager] = None
|
||||
|
||||
# State
|
||||
self._current_run_id: Optional[str] = None
|
||||
self._streaming_content: str = ""
|
||||
self._total_tokens: int = 0
|
||||
self._total_cost: float = 0.0
|
||||
|
||||
# Slash command handlers
|
||||
self._command_handlers: Dict[str, callable] = {}
|
||||
self._setup_commands()
|
||||
|
||||
def _setup_commands(self) -> None:
|
||||
"""Setup slash command handlers."""
|
||||
self._command_handlers = {
|
||||
"help": self._cmd_help,
|
||||
"clear": self._cmd_clear,
|
||||
"model": self._cmd_model,
|
||||
"queue": self._cmd_queue,
|
||||
"cancel": self._cmd_cancel,
|
||||
"retry": self._cmd_retry,
|
||||
"settings": self._cmd_settings,
|
||||
"sessions": self._cmd_sessions,
|
||||
"cost": self._cmd_cost,
|
||||
"exit": self._cmd_exit,
|
||||
"quit": self._cmd_exit,
|
||||
}
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Handle app mount."""
|
||||
# Initialize queue manager
|
||||
self.queue_manager = QueueManager(
|
||||
config=self.queue_config,
|
||||
on_output=self._handle_output,
|
||||
on_complete=self._handle_complete,
|
||||
on_error=self._handle_error,
|
||||
)
|
||||
|
||||
await self.queue_manager.start(recover=True)
|
||||
self.queue_manager.set_session(self.session_id)
|
||||
|
||||
# Push main screen
|
||||
await self.push_screen("main")
|
||||
|
||||
# Update status
|
||||
self._update_status()
|
||||
|
||||
async def on_unmount(self) -> None:
|
||||
"""Handle app unmount."""
|
||||
if self.queue_manager:
|
||||
await self.queue_manager.stop()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the app."""
|
||||
yield from ()
|
||||
|
||||
# Event handlers
|
||||
|
||||
async def on_main_screen_message_submitted(
|
||||
self, event: MainScreen.MessageSubmitted
|
||||
) -> None:
|
||||
"""Handle message submission from main screen."""
|
||||
content = event.content
|
||||
|
||||
# Add user message to chat
|
||||
main_screen = self.screen
|
||||
if isinstance(main_screen, MainScreen):
|
||||
await main_screen.add_user_message(content)
|
||||
main_screen.set_processing(True)
|
||||
|
||||
# Submit to queue
|
||||
try:
|
||||
run_id = await self.queue_manager.submit(
|
||||
input_content=content,
|
||||
agent_name=self.agent_config.get("name", "Assistant"),
|
||||
config={
|
||||
"agent_config": {
|
||||
"name": self.agent_config.get("name", "Assistant"),
|
||||
"instructions": self.agent_config.get(
|
||||
"instructions",
|
||||
"You are a helpful AI assistant."
|
||||
),
|
||||
"model": self.model,
|
||||
"tools": self.agent_config.get("tools", []),
|
||||
"verbose": False,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
self._current_run_id = run_id
|
||||
self._streaming_content = ""
|
||||
|
||||
# Add streaming placeholder
|
||||
if isinstance(main_screen, MainScreen):
|
||||
await main_screen.add_assistant_message(
|
||||
content="",
|
||||
run_id=run_id,
|
||||
agent_name=self.agent_config.get("name", "Assistant"),
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to submit: {e}")
|
||||
if isinstance(main_screen, MainScreen):
|
||||
main_screen.set_processing(False)
|
||||
await main_screen.add_assistant_message(
|
||||
content=f"Error: {e}",
|
||||
agent_name="System",
|
||||
)
|
||||
|
||||
self._update_status()
|
||||
|
||||
async def on_main_screen_command_executed(
|
||||
self, event: MainScreen.CommandExecuted
|
||||
) -> None:
|
||||
"""Handle command execution."""
|
||||
command = event.command.lower()
|
||||
args = event.args
|
||||
|
||||
handler = self._command_handlers.get(command)
|
||||
if handler:
|
||||
await handler(args)
|
||||
else:
|
||||
# Unknown command
|
||||
main_screen = self.screen
|
||||
if isinstance(main_screen, MainScreen):
|
||||
await main_screen.add_assistant_message(
|
||||
content=f"Unknown command: /{command}\nType /help for available commands.",
|
||||
agent_name="System",
|
||||
)
|
||||
|
||||
async def on_main_screen_cancel_requested(
|
||||
self, event: MainScreen.CancelRequested
|
||||
) -> None:
|
||||
"""Handle cancel request."""
|
||||
if self._current_run_id:
|
||||
await self.queue_manager.cancel(self._current_run_id)
|
||||
self._current_run_id = None
|
||||
|
||||
main_screen = self.screen
|
||||
if isinstance(main_screen, MainScreen):
|
||||
main_screen.set_processing(False)
|
||||
await main_screen.add_assistant_message(
|
||||
content="Operation cancelled.",
|
||||
agent_name="System",
|
||||
)
|
||||
|
||||
self._update_status()
|
||||
|
||||
async def on_queue_screen_run_cancelled(
|
||||
self, event: QueueScreen.RunCancelled
|
||||
) -> None:
|
||||
"""Handle run cancellation from queue screen."""
|
||||
await self.queue_manager.cancel(event.run_id)
|
||||
self._update_queue_screen()
|
||||
|
||||
async def on_queue_screen_run_retried(
|
||||
self, event: QueueScreen.RunRetried
|
||||
) -> None:
|
||||
"""Handle run retry from queue screen."""
|
||||
await self.queue_manager.retry(event.run_id)
|
||||
self._update_queue_screen()
|
||||
|
||||
async def on_queue_screen_queue_cleared(
|
||||
self, event: QueueScreen.QueueCleared
|
||||
) -> None:
|
||||
"""Handle queue clear."""
|
||||
await self.queue_manager.clear_queue()
|
||||
self._update_queue_screen()
|
||||
|
||||
async def on_settings_screen_settings_saved(
|
||||
self, event: SettingsScreen.SettingsSaved
|
||||
) -> None:
|
||||
"""Handle settings save."""
|
||||
settings = event.settings
|
||||
|
||||
if "model" in settings:
|
||||
self.model = settings["model"]
|
||||
|
||||
if "max_concurrent" in settings:
|
||||
self.queue_config.max_concurrent_global = settings["max_concurrent"]
|
||||
|
||||
if "autosave_interval" in settings:
|
||||
self.queue_config.autosave_interval_seconds = settings["autosave_interval"]
|
||||
|
||||
self._update_status()
|
||||
|
||||
async def on_session_screen_session_selected(
|
||||
self, event: SessionScreen.SessionSelected
|
||||
) -> None:
|
||||
"""Handle session selection."""
|
||||
self.session_id = event.session_id
|
||||
self.queue_manager.set_session(self.session_id)
|
||||
self._update_status()
|
||||
|
||||
# Queue callbacks
|
||||
|
||||
async def _handle_output(self, run_id: str, chunk: str) -> None:
|
||||
"""Handle streaming output."""
|
||||
if run_id != self._current_run_id:
|
||||
return
|
||||
|
||||
self._streaming_content += chunk
|
||||
|
||||
main_screen = self.screen
|
||||
if isinstance(main_screen, MainScreen):
|
||||
await main_screen.update_streaming(run_id, self._streaming_content)
|
||||
|
||||
async def _handle_complete(self, run_id: str, run: QueuedRun) -> None:
|
||||
"""Handle run completion."""
|
||||
main_screen = self.screen
|
||||
if isinstance(main_screen, MainScreen):
|
||||
await main_screen.complete_streaming(
|
||||
run_id,
|
||||
run.output_content or self._streaming_content
|
||||
)
|
||||
main_screen.set_processing(False)
|
||||
|
||||
# Update metrics
|
||||
if run.metrics:
|
||||
self._total_tokens += run.metrics.get("tokens", 0)
|
||||
self._total_cost += run.metrics.get("cost", 0.0)
|
||||
|
||||
if run_id == self._current_run_id:
|
||||
self._current_run_id = None
|
||||
self._streaming_content = ""
|
||||
|
||||
self._update_status()
|
||||
|
||||
async def _handle_error(self, run_id: str, error: Exception) -> None:
|
||||
"""Handle run error."""
|
||||
main_screen = self.screen
|
||||
if isinstance(main_screen, MainScreen):
|
||||
await main_screen.complete_streaming(run_id, f"Error: {error}")
|
||||
main_screen.set_processing(False)
|
||||
|
||||
if run_id == self._current_run_id:
|
||||
self._current_run_id = None
|
||||
self._streaming_content = ""
|
||||
|
||||
self._update_status()
|
||||
|
||||
# Command handlers
|
||||
|
||||
async def _cmd_help(self, args: str) -> None:
|
||||
"""Show help."""
|
||||
help_text = """
|
||||
**Available Commands:**
|
||||
|
||||
- `/help` - Show this help
|
||||
- `/clear` - Clear chat history
|
||||
- `/model [name]` - Show or set model
|
||||
- `/queue` - Show queue screen
|
||||
- `/cancel [run_id]` - Cancel a run
|
||||
- `/retry [run_id]` - Retry a failed run
|
||||
- `/settings` - Show settings
|
||||
- `/sessions` - Browse sessions
|
||||
- `/cost` - Show cost summary
|
||||
- `/exit` or `/quit` - Exit TUI
|
||||
|
||||
**Keyboard Shortcuts:**
|
||||
|
||||
- `Ctrl+Enter` - Send message
|
||||
- `Ctrl+C` - Cancel current operation
|
||||
- `Ctrl+Q` - Quit
|
||||
- `Ctrl+L` - Clear screen
|
||||
- `F1` - Help
|
||||
- `F2` - Toggle queue panel
|
||||
- `F3` - Settings
|
||||
- `F5` - Clear chat
|
||||
- `Tab` - Cycle focus
|
||||
- `Esc` - Cancel/close
|
||||
"""
|
||||
main_screen = self.screen
|
||||
if isinstance(main_screen, MainScreen):
|
||||
await main_screen.add_assistant_message(
|
||||
content=help_text,
|
||||
agent_name="Help",
|
||||
)
|
||||
|
||||
async def _cmd_clear(self, args: str) -> None:
|
||||
"""Clear chat."""
|
||||
main_screen = self.screen
|
||||
if isinstance(main_screen, MainScreen):
|
||||
await main_screen.query_one("#chat-widget").clear()
|
||||
|
||||
async def _cmd_model(self, args: str) -> None:
|
||||
"""Show or set model."""
|
||||
main_screen = self.screen
|
||||
if args:
|
||||
self.model = args.strip()
|
||||
msg = f"Model set to: {self.model}"
|
||||
else:
|
||||
msg = f"Current model: {self.model}"
|
||||
|
||||
if isinstance(main_screen, MainScreen):
|
||||
await main_screen.add_assistant_message(content=msg, agent_name="System")
|
||||
|
||||
self._update_status()
|
||||
|
||||
async def _cmd_queue(self, args: str) -> None:
|
||||
"""Show queue screen."""
|
||||
await self.push_screen("queue")
|
||||
self._update_queue_screen()
|
||||
|
||||
async def _cmd_cancel(self, args: str) -> None:
|
||||
"""Cancel a run."""
|
||||
run_id = args.strip() if args else self._current_run_id
|
||||
if run_id:
|
||||
await self.queue_manager.cancel(run_id)
|
||||
main_screen = self.screen
|
||||
if isinstance(main_screen, MainScreen):
|
||||
await main_screen.add_assistant_message(
|
||||
content=f"Cancelled run: {run_id}",
|
||||
agent_name="System",
|
||||
)
|
||||
self._update_status()
|
||||
|
||||
async def _cmd_retry(self, args: str) -> None:
|
||||
"""Retry a failed run."""
|
||||
run_id = args.strip()
|
||||
if run_id:
|
||||
new_id = await self.queue_manager.retry(run_id)
|
||||
main_screen = self.screen
|
||||
if isinstance(main_screen, MainScreen):
|
||||
if new_id:
|
||||
await main_screen.add_assistant_message(
|
||||
content=f"Retrying run {run_id} as {new_id}",
|
||||
agent_name="System",
|
||||
)
|
||||
else:
|
||||
await main_screen.add_assistant_message(
|
||||
content=f"Cannot retry run: {run_id}",
|
||||
agent_name="System",
|
||||
)
|
||||
self._update_status()
|
||||
|
||||
async def _cmd_settings(self, args: str) -> None:
|
||||
"""Show settings."""
|
||||
await self.push_screen(SettingsScreen(current_settings={
|
||||
"model": self.model,
|
||||
"max_concurrent": self.queue_config.max_concurrent_global,
|
||||
"autosave_interval": int(self.queue_config.autosave_interval_seconds),
|
||||
}))
|
||||
|
||||
async def _cmd_sessions(self, args: str) -> None:
|
||||
"""Show sessions."""
|
||||
await self.push_screen("sessions")
|
||||
|
||||
async def _cmd_cost(self, args: str) -> None:
|
||||
"""Show cost summary."""
|
||||
msg = f"""
|
||||
**Cost Summary:**
|
||||
|
||||
- Total Tokens: {self._total_tokens:,}
|
||||
- Total Cost: ${self._total_cost:.4f}
|
||||
- Session: {self.session_id}
|
||||
"""
|
||||
main_screen = self.screen
|
||||
if isinstance(main_screen, MainScreen):
|
||||
await main_screen.add_assistant_message(content=msg, agent_name="System")
|
||||
|
||||
async def _cmd_exit(self, args: str) -> None:
|
||||
"""Exit TUI."""
|
||||
self.exit()
|
||||
|
||||
# Helper methods
|
||||
|
||||
def _update_status(self) -> None:
|
||||
"""Update status bar."""
|
||||
main_screen = self.screen
|
||||
if isinstance(main_screen, MainScreen):
|
||||
main_screen.update_status(StatusInfo(
|
||||
session_id=self.session_id,
|
||||
model=self.model,
|
||||
total_tokens=self._total_tokens,
|
||||
total_cost=self._total_cost,
|
||||
queued_count=self.queue_manager.queued_count if self.queue_manager else 0,
|
||||
running_count=self.queue_manager.running_count if self.queue_manager else 0,
|
||||
is_processing=self._current_run_id is not None,
|
||||
))
|
||||
|
||||
def _update_queue_screen(self) -> None:
|
||||
"""Update queue screen if visible."""
|
||||
if isinstance(self.screen, QueueScreen):
|
||||
runs = self.queue_manager.list_runs(limit=100)
|
||||
self.screen.update_runs([
|
||||
{
|
||||
"run_id": r.run_id,
|
||||
"agent_name": r.agent_name,
|
||||
"input": r.input_content,
|
||||
"state": r.state.value,
|
||||
"priority": r.priority.name.lower(),
|
||||
"wait_time": f"{r.wait_seconds:.1f}s" if r.wait_seconds else "-",
|
||||
"duration": f"{r.duration_seconds:.1f}s" if r.duration_seconds else "-",
|
||||
}
|
||||
for r in runs
|
||||
])
|
||||
|
||||
def action_quit(self) -> None:
|
||||
"""Quit the application."""
|
||||
self.exit()
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
"""Cancel current operation."""
|
||||
if self._current_run_id and self.queue_manager:
|
||||
asyncio.create_task(self.queue_manager.cancel(self._current_run_id))
|
||||
|
||||
else:
|
||||
class TUIApp:
|
||||
"""Placeholder when Textual is not available."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(
|
||||
"Textual is required for TUI. Install with: pip install praisonai[tui]"
|
||||
)
|
||||
|
||||
|
||||
def run_tui(
|
||||
workspace: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
agent_config: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Run the PraisonAI TUI.
|
||||
|
||||
Args:
|
||||
workspace: Working directory.
|
||||
session_id: Session ID to resume.
|
||||
model: Default model.
|
||||
agent_config: Agent configuration.
|
||||
"""
|
||||
if not TEXTUAL_AVAILABLE:
|
||||
raise ImportError(
|
||||
"Textual is required for TUI. Install with: pip install praisonai[tui]"
|
||||
)
|
||||
|
||||
app = TUIApp(
|
||||
workspace=workspace,
|
||||
session_id=session_id,
|
||||
model=model,
|
||||
agent_config=agent_config,
|
||||
)
|
||||
app.run()
|
||||
@@ -0,0 +1,556 @@
|
||||
"""
|
||||
CLI Commands for PraisonAI TUI and Queue.
|
||||
|
||||
Provides Typer-based CLI commands for:
|
||||
- praison tui - Launch interactive TUI
|
||||
- praison run - Execute single task
|
||||
- praison queue ls/cancel/retry/clear
|
||||
- praison session ls/resume
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
TYPER_AVAILABLE = True
|
||||
except ImportError:
|
||||
TYPER_AVAILABLE = False
|
||||
typer = None
|
||||
|
||||
console = Console() if TYPER_AVAILABLE else None
|
||||
|
||||
|
||||
def create_tui_app():
|
||||
"""Create the TUI CLI app."""
|
||||
if not TYPER_AVAILABLE:
|
||||
return None
|
||||
|
||||
app = typer.Typer(
|
||||
name="tui",
|
||||
help="PraisonAI TUI and Queue commands",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
|
||||
@app.command("launch")
|
||||
def tui_launch(
|
||||
workspace: Optional[str] = typer.Option(
|
||||
None, "--workspace", "-w",
|
||||
help="Workspace directory"
|
||||
),
|
||||
session: Optional[str] = typer.Option(
|
||||
None, "--session", "-s",
|
||||
help="Resume session ID"
|
||||
),
|
||||
model: Optional[str] = typer.Option(
|
||||
None, "--model", "-m",
|
||||
help="Default model"
|
||||
),
|
||||
agent: Optional[str] = typer.Option(
|
||||
None, "--agent", "-a",
|
||||
help="Agent config file (YAML)"
|
||||
),
|
||||
):
|
||||
"""Launch the interactive TUI."""
|
||||
try:
|
||||
from .app import run_tui
|
||||
except ImportError:
|
||||
console.print(
|
||||
"[red]Textual is required for TUI. Install with:[/red]\n"
|
||||
" pip install praisonai[tui]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Load agent config if provided
|
||||
agent_config = {}
|
||||
if agent:
|
||||
import yaml
|
||||
with open(agent, "r") as f:
|
||||
agent_config = yaml.safe_load(f)
|
||||
|
||||
run_tui(
|
||||
workspace=workspace,
|
||||
session_id=session,
|
||||
model=model,
|
||||
agent_config=agent_config,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def create_queue_app():
|
||||
"""Create the queue CLI app."""
|
||||
if not TYPER_AVAILABLE:
|
||||
return None
|
||||
|
||||
app = typer.Typer(
|
||||
name="queue",
|
||||
help="Queue management commands",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
|
||||
@app.command("ls")
|
||||
def queue_ls(
|
||||
state: Optional[str] = typer.Option(
|
||||
None, "--state", "-s",
|
||||
help="Filter by state (queued, running, succeeded, failed, cancelled)"
|
||||
),
|
||||
session: Optional[str] = typer.Option(
|
||||
None, "--session",
|
||||
help="Filter by session ID"
|
||||
),
|
||||
limit: int = typer.Option(
|
||||
20, "--limit", "-n",
|
||||
help="Maximum number of results"
|
||||
),
|
||||
json_output: bool = typer.Option(
|
||||
False, "--json", "-j",
|
||||
help="Output as JSON"
|
||||
),
|
||||
):
|
||||
"""List queued runs."""
|
||||
from ..queue import QueuePersistence, RunState
|
||||
|
||||
persistence = QueuePersistence()
|
||||
persistence.initialize()
|
||||
|
||||
# Parse state filter
|
||||
state_filter = None
|
||||
if state:
|
||||
try:
|
||||
state_filter = RunState(state.lower())
|
||||
except ValueError:
|
||||
console.print(f"[red]Invalid state: {state}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
runs = persistence.list_runs(
|
||||
state=state_filter,
|
||||
session_id=session,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
if json_output:
|
||||
output = [r.to_dict() for r in runs]
|
||||
console.print(json.dumps(output, indent=2, default=str))
|
||||
return
|
||||
|
||||
if not runs:
|
||||
console.print("[dim]No runs found.[/dim]")
|
||||
return
|
||||
|
||||
# Create table
|
||||
table = Table(title="Queue Runs")
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Agent", style="green")
|
||||
table.add_column("Input", max_width=30)
|
||||
table.add_column("State", style="yellow")
|
||||
table.add_column("Priority")
|
||||
table.add_column("Wait", style="dim")
|
||||
table.add_column("Duration", style="dim")
|
||||
|
||||
for run in runs:
|
||||
state_style = {
|
||||
"queued": "yellow",
|
||||
"running": "green",
|
||||
"succeeded": "cyan",
|
||||
"failed": "red",
|
||||
"cancelled": "dim",
|
||||
}.get(run.state.value, "")
|
||||
|
||||
input_preview = run.input_content[:27] + "..." if len(run.input_content) > 30 else run.input_content
|
||||
|
||||
table.add_row(
|
||||
run.run_id[:8],
|
||||
run.agent_name,
|
||||
input_preview,
|
||||
f"[{state_style}]{run.state.value}[/{state_style}]",
|
||||
run.priority.name.lower(),
|
||||
f"{run.wait_seconds:.1f}s" if run.wait_seconds else "-",
|
||||
f"{run.duration_seconds:.1f}s" if run.duration_seconds else "-",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
persistence.close()
|
||||
|
||||
@app.command("cancel")
|
||||
def queue_cancel(
|
||||
run_id: str = typer.Argument(..., help="Run ID to cancel"),
|
||||
):
|
||||
"""Cancel a queued or running run."""
|
||||
from ..queue import QueuePersistence, RunState
|
||||
|
||||
persistence = QueuePersistence()
|
||||
persistence.initialize()
|
||||
|
||||
# Find the run
|
||||
run = persistence.load_run(run_id)
|
||||
if not run:
|
||||
# Try partial match
|
||||
runs = persistence.list_runs(limit=100)
|
||||
matches = [r for r in runs if r.run_id.startswith(run_id)]
|
||||
if len(matches) == 1:
|
||||
run = matches[0]
|
||||
elif len(matches) > 1:
|
||||
console.print(f"[yellow]Multiple matches for '{run_id}':[/yellow]")
|
||||
for r in matches:
|
||||
console.print(f" {r.run_id}")
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
console.print(f"[red]Run not found: {run_id}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if run.state.is_terminal():
|
||||
console.print(f"[yellow]Run {run.run_id[:8]} is already {run.state.value}[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Update state
|
||||
persistence.update_run_state(run.run_id, RunState.CANCELLED)
|
||||
console.print(f"[green]Cancelled run: {run.run_id[:8]}[/green]")
|
||||
persistence.close()
|
||||
|
||||
@app.command("retry")
|
||||
def queue_retry(
|
||||
run_id: str = typer.Argument(..., help="Run ID to retry"),
|
||||
):
|
||||
"""Retry a failed run."""
|
||||
from ..queue import QueuePersistence, QueuedRun, RunState
|
||||
import uuid
|
||||
|
||||
persistence = QueuePersistence()
|
||||
persistence.initialize()
|
||||
|
||||
# Find the run
|
||||
run = persistence.load_run(run_id)
|
||||
if not run:
|
||||
runs = persistence.list_runs(limit=100)
|
||||
matches = [r for r in runs if r.run_id.startswith(run_id)]
|
||||
if len(matches) == 1:
|
||||
run = matches[0]
|
||||
else:
|
||||
console.print(f"[red]Run not found: {run_id}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if run.state != RunState.FAILED:
|
||||
console.print(f"[yellow]Can only retry failed runs (current: {run.state.value})[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if run.retry_count >= run.max_retries:
|
||||
console.print(f"[yellow]Max retries reached ({run.retry_count}/{run.max_retries})[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Create new run
|
||||
new_run = QueuedRun(
|
||||
run_id=str(uuid.uuid4())[:8],
|
||||
agent_name=run.agent_name,
|
||||
input_content=run.input_content,
|
||||
state=RunState.QUEUED,
|
||||
priority=run.priority,
|
||||
session_id=run.session_id,
|
||||
workspace=run.workspace,
|
||||
retry_count=run.retry_count + 1,
|
||||
max_retries=run.max_retries,
|
||||
parent_run_id=run.run_id,
|
||||
config=run.config,
|
||||
)
|
||||
|
||||
persistence.save_run(new_run)
|
||||
console.print(f"[green]Created retry run: {new_run.run_id} (from {run.run_id[:8]})[/green]")
|
||||
persistence.close()
|
||||
|
||||
@app.command("clear")
|
||||
def queue_clear(
|
||||
force: bool = typer.Option(
|
||||
False, "--force", "-f",
|
||||
help="Skip confirmation"
|
||||
),
|
||||
):
|
||||
"""Clear all queued runs."""
|
||||
from ..queue import QueuePersistence, RunState
|
||||
|
||||
if not force:
|
||||
confirm = typer.confirm("Clear all queued runs?")
|
||||
if not confirm:
|
||||
raise typer.Abort()
|
||||
|
||||
persistence = QueuePersistence()
|
||||
persistence.initialize()
|
||||
|
||||
# Get queued runs
|
||||
runs = persistence.list_runs(state=RunState.QUEUED, limit=1000)
|
||||
|
||||
count = 0
|
||||
for run in runs:
|
||||
persistence.update_run_state(run.run_id, RunState.CANCELLED)
|
||||
count += 1
|
||||
|
||||
console.print(f"[green]Cleared {count} queued runs[/green]")
|
||||
persistence.close()
|
||||
|
||||
@app.command("stats")
|
||||
def queue_stats(
|
||||
session: Optional[str] = typer.Option(
|
||||
None, "--session",
|
||||
help="Filter by session ID"
|
||||
),
|
||||
):
|
||||
"""Show queue statistics."""
|
||||
from ..queue import QueuePersistence
|
||||
|
||||
persistence = QueuePersistence()
|
||||
persistence.initialize()
|
||||
|
||||
stats = persistence.get_stats(session)
|
||||
|
||||
console.print(Panel(
|
||||
f"""
|
||||
[bold]Queue Statistics[/bold]
|
||||
|
||||
Queued: {stats.queued_count}
|
||||
Running: {stats.running_count}
|
||||
Succeeded: {stats.succeeded_count}
|
||||
Failed: {stats.failed_count}
|
||||
Cancelled: {stats.cancelled_count}
|
||||
|
||||
Total Runs: {stats.total_runs}
|
||||
Avg Wait: {stats.avg_wait_seconds:.1f}s
|
||||
Avg Duration: {stats.avg_duration_seconds:.1f}s
|
||||
""",
|
||||
title="Queue Stats"
|
||||
))
|
||||
persistence.close()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def create_session_app():
|
||||
"""Create the session CLI app."""
|
||||
if not TYPER_AVAILABLE:
|
||||
return None
|
||||
|
||||
app = typer.Typer(
|
||||
name="session",
|
||||
help="Session management commands",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
|
||||
@app.command("ls")
|
||||
def session_ls(
|
||||
limit: int = typer.Option(
|
||||
20, "--limit", "-n",
|
||||
help="Maximum number of results"
|
||||
),
|
||||
):
|
||||
"""List recent sessions."""
|
||||
from ..queue import QueuePersistence
|
||||
from datetime import datetime
|
||||
|
||||
persistence = QueuePersistence()
|
||||
persistence.initialize()
|
||||
|
||||
sessions = persistence.list_sessions(limit=limit)
|
||||
|
||||
if not sessions:
|
||||
console.print("[dim]No sessions found.[/dim]")
|
||||
return
|
||||
|
||||
table = Table(title="Sessions")
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("User")
|
||||
table.add_column("Created", style="dim")
|
||||
table.add_column("Updated", style="dim")
|
||||
|
||||
for session in sessions:
|
||||
created = datetime.fromtimestamp(session["created_at"]).strftime("%Y-%m-%d %H:%M")
|
||||
updated = datetime.fromtimestamp(session["updated_at"]).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
table.add_row(
|
||||
session["session_id"][:8],
|
||||
session.get("user_id", "-"),
|
||||
created,
|
||||
updated,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
persistence.close()
|
||||
|
||||
@app.command("resume")
|
||||
def session_resume(
|
||||
session_id: str = typer.Argument(..., help="Session ID to resume"),
|
||||
):
|
||||
"""Resume a session in TUI."""
|
||||
try:
|
||||
from .app import run_tui
|
||||
except ImportError:
|
||||
console.print(
|
||||
"[red]Textual is required for TUI. Install with:[/red]\n"
|
||||
" pip install praisonai[tui]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
run_tui(session_id=session_id)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def create_run_command():
|
||||
"""Create the run command."""
|
||||
if not TYPER_AVAILABLE:
|
||||
return None
|
||||
|
||||
def run_command(
|
||||
prompt: str = typer.Argument(..., help="Prompt to execute"),
|
||||
agent: Optional[str] = typer.Option(
|
||||
None, "--agent", "-a",
|
||||
help="Agent config file (YAML)"
|
||||
),
|
||||
model: Optional[str] = typer.Option(
|
||||
None, "--model", "-m",
|
||||
help="Model to use"
|
||||
),
|
||||
stream: bool = typer.Option(
|
||||
True, "--stream/--no-stream",
|
||||
help="Stream output"
|
||||
),
|
||||
priority: str = typer.Option(
|
||||
"normal", "--priority", "-p",
|
||||
help="Run priority (low, normal, high, urgent)"
|
||||
),
|
||||
):
|
||||
"""Run a single agent task."""
|
||||
from ..queue import QueueManager, QueueConfig, RunPriority
|
||||
|
||||
# Parse priority
|
||||
try:
|
||||
run_priority = RunPriority.from_string(priority)
|
||||
except (ValueError, KeyError):
|
||||
console.print(f"[red]Invalid priority: {priority}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Load agent config
|
||||
agent_config = {}
|
||||
if agent:
|
||||
import yaml
|
||||
with open(agent, "r") as f:
|
||||
agent_config = yaml.safe_load(f)
|
||||
|
||||
# Set model
|
||||
if model:
|
||||
agent_config["model"] = model
|
||||
|
||||
async def execute():
|
||||
output_chunks = []
|
||||
|
||||
async def on_output(run_id: str, chunk: str):
|
||||
output_chunks.append(chunk)
|
||||
if stream:
|
||||
console.print(chunk, end="")
|
||||
|
||||
async def on_complete(run_id: str, run):
|
||||
if not stream:
|
||||
console.print(run.output_content or "".join(output_chunks))
|
||||
|
||||
async def on_error(run_id: str, error: Exception):
|
||||
console.print(f"\n[red]Error: {error}[/red]")
|
||||
|
||||
config = QueueConfig(enable_persistence=False)
|
||||
manager = QueueManager(
|
||||
config=config,
|
||||
on_output=on_output,
|
||||
on_complete=on_complete,
|
||||
on_error=on_error,
|
||||
)
|
||||
|
||||
await manager.start(recover=False)
|
||||
|
||||
try:
|
||||
run_id = await manager.submit(
|
||||
input_content=prompt,
|
||||
agent_name=agent_config.get("name", "Assistant"),
|
||||
priority=run_priority,
|
||||
config={"agent_config": agent_config},
|
||||
)
|
||||
|
||||
# Wait for completion
|
||||
while True:
|
||||
run = manager.get_run(run_id)
|
||||
if run and run.state.is_terminal():
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if stream:
|
||||
console.print() # Newline after streaming
|
||||
|
||||
finally:
|
||||
await manager.stop()
|
||||
|
||||
asyncio.run(execute())
|
||||
|
||||
return run_command
|
||||
|
||||
|
||||
# Main CLI integration
|
||||
def register_tui_commands(app):
|
||||
"""Register TUI commands with the main CLI app."""
|
||||
if not TYPER_AVAILABLE:
|
||||
return
|
||||
|
||||
tui_app = create_tui_app()
|
||||
queue_app = create_queue_app()
|
||||
session_app = create_session_app()
|
||||
run_cmd = create_run_command()
|
||||
|
||||
if tui_app:
|
||||
app.add_typer(tui_app, name="tui")
|
||||
if queue_app:
|
||||
app.add_typer(queue_app, name="queue")
|
||||
if session_app:
|
||||
app.add_typer(session_app, name="session")
|
||||
if run_cmd:
|
||||
app.command("run")(run_cmd)
|
||||
|
||||
|
||||
# Standalone entry point
|
||||
def main():
|
||||
"""Main entry point for TUI CLI."""
|
||||
if not TYPER_AVAILABLE:
|
||||
print("Typer is required. Install with: pip install typer")
|
||||
sys.exit(1)
|
||||
|
||||
app = typer.Typer(
|
||||
name="praison-tui",
|
||||
help="PraisonAI TUI and Queue CLI",
|
||||
)
|
||||
|
||||
register_tui_commands(app)
|
||||
|
||||
# Add direct tui command
|
||||
@app.callback(invoke_without_command=True)
|
||||
def default(
|
||||
ctx: typer.Context,
|
||||
workspace: Optional[str] = typer.Option(None, "--workspace", "-w"),
|
||||
session: Optional[str] = typer.Option(None, "--session", "-s"),
|
||||
model: Optional[str] = typer.Option(None, "--model", "-m"),
|
||||
):
|
||||
"""Launch TUI if no subcommand given."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
try:
|
||||
from .app import run_tui
|
||||
run_tui(workspace=workspace, session_id=session, model=model)
|
||||
except ImportError:
|
||||
console.print(
|
||||
"[red]Textual is required for TUI. Install with:[/red]\n"
|
||||
" pip install praisonai[tui]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,511 @@
|
||||
"""
|
||||
Debug and Simulation CLI Commands for PraisonAI TUI.
|
||||
|
||||
Provides headless TUI simulation, tracing, and debugging capabilities.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
from rich.live import Live
|
||||
from rich.text import Text
|
||||
TYPER_AVAILABLE = True
|
||||
except ImportError:
|
||||
TYPER_AVAILABLE = False
|
||||
typer = None
|
||||
|
||||
console = Console() if TYPER_AVAILABLE else None
|
||||
|
||||
|
||||
def create_debug_app():
|
||||
"""Create the debug CLI app with simulation commands."""
|
||||
if not TYPER_AVAILABLE:
|
||||
return None
|
||||
|
||||
app = typer.Typer(
|
||||
name="tui",
|
||||
help="TUI and simulation commands",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
|
||||
@app.command("launch")
|
||||
def tui_launch(
|
||||
workspace: Optional[str] = typer.Option(None, "--workspace", "-w"),
|
||||
session: Optional[str] = typer.Option(None, "--session", "-s"),
|
||||
model: Optional[str] = typer.Option(None, "--model", "-m"),
|
||||
debug: bool = typer.Option(False, "--debug", "-d", help="Enable debug overlays"),
|
||||
log_jsonl: Optional[str] = typer.Option(None, "--log-jsonl", help="Write events to JSONL file"),
|
||||
profile: bool = typer.Option(False, "--profile", help="Enable performance profiling"),
|
||||
):
|
||||
"""Launch the interactive TUI."""
|
||||
try:
|
||||
from .app import run_tui
|
||||
except ImportError:
|
||||
console.print("[red]Textual is required for TUI. Install with:[/red]\n pip install praisonai[tui]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Set debug environment
|
||||
if debug:
|
||||
import os
|
||||
os.environ["PRAISONAI_TUI_DEBUG"] = "1"
|
||||
|
||||
if log_jsonl:
|
||||
import os
|
||||
os.environ["PRAISONAI_TUI_JSONL"] = log_jsonl
|
||||
|
||||
run_tui(workspace=workspace, session_id=session, model=model)
|
||||
|
||||
@app.command("simulate")
|
||||
def tui_simulate(
|
||||
script: str = typer.Argument(..., help="Path to simulation script (YAML/JSON)"),
|
||||
mock: bool = typer.Option(True, "--mock/--real-llm", help="Use mock provider"),
|
||||
pretty: bool = typer.Option(True, "--pretty/--jsonl", help="Output format"),
|
||||
assert_mode: bool = typer.Option(False, "--assert", help="Validate expected outcomes"),
|
||||
timeout: float = typer.Option(60.0, "--timeout", help="Max execution time"),
|
||||
):
|
||||
"""Run a headless TUI simulation script."""
|
||||
import os
|
||||
|
||||
# Safety gate for real LLM
|
||||
if not mock:
|
||||
if not os.environ.get("PRAISONAI_REAL_LLM"):
|
||||
console.print("[red]Real LLM mode requires PRAISONAI_REAL_LLM=1 environment variable[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Load script
|
||||
script_path = Path(script)
|
||||
if not script_path.exists():
|
||||
console.print(f"[red]Script not found: {script}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
with open(script_path) as f:
|
||||
if script_path.suffix in (".yaml", ".yml"):
|
||||
import yaml
|
||||
script_data = yaml.safe_load(f)
|
||||
else:
|
||||
script_data = json.load(f)
|
||||
|
||||
# Run simulation
|
||||
from .orchestrator import TuiOrchestrator, SimulationRunner, OutputMode
|
||||
from ..queue import QueueConfig
|
||||
|
||||
output_mode = OutputMode.PRETTY if pretty else OutputMode.JSONL
|
||||
|
||||
config = QueueConfig(enable_persistence=not mock)
|
||||
orchestrator = TuiOrchestrator(
|
||||
queue_config=config,
|
||||
output_mode=output_mode,
|
||||
debug=True,
|
||||
)
|
||||
|
||||
runner = SimulationRunner(orchestrator, assert_mode=assert_mode)
|
||||
|
||||
async def run():
|
||||
return await asyncio.wait_for(
|
||||
runner.run_script(script_data),
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
try:
|
||||
success = asyncio.run(run())
|
||||
except asyncio.TimeoutError:
|
||||
console.print(f"[red]Simulation timed out after {timeout}s[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Print summary
|
||||
summary = runner.get_summary()
|
||||
if assert_mode:
|
||||
console.print(Panel(
|
||||
f"Passed: {summary['assertions_passed']}\n"
|
||||
f"Failed: {summary['assertions_failed']}\n"
|
||||
f"Errors: {len(summary['errors'])}",
|
||||
title="Simulation Results"
|
||||
))
|
||||
|
||||
if summary['errors']:
|
||||
for error in summary['errors']:
|
||||
console.print(f"[red] ✗ {error}[/red]")
|
||||
|
||||
if not success:
|
||||
raise typer.Exit(1)
|
||||
|
||||
@app.command("snapshot")
|
||||
def tui_snapshot(
|
||||
session: Optional[str] = typer.Option(None, "--session", "-s"),
|
||||
run_id: Optional[str] = typer.Option(None, "--run", "-r"),
|
||||
json_output: bool = typer.Option(False, "--json", "-j"),
|
||||
):
|
||||
"""Print a TUI-like snapshot of current state."""
|
||||
from ..queue import QueuePersistence, RunState
|
||||
|
||||
persistence = QueuePersistence()
|
||||
persistence.initialize()
|
||||
|
||||
# Build snapshot
|
||||
snapshot = {
|
||||
"timestamp": time.time(),
|
||||
"session_id": session,
|
||||
}
|
||||
|
||||
# Get runs
|
||||
runs = persistence.list_runs(session_id=session, limit=100)
|
||||
|
||||
queued = [r for r in runs if r.state == RunState.QUEUED]
|
||||
running = [r for r in runs if r.state == RunState.RUNNING]
|
||||
recent = [r for r in runs if r.state.is_terminal()][-5:]
|
||||
|
||||
snapshot["queued_count"] = len(queued)
|
||||
snapshot["running_count"] = len(running)
|
||||
snapshot["recent_runs"] = [
|
||||
{
|
||||
"run_id": r.run_id[:8],
|
||||
"agent": r.agent_name,
|
||||
"state": r.state.value,
|
||||
"input": r.input_content[:50],
|
||||
}
|
||||
for r in recent
|
||||
]
|
||||
|
||||
# Get specific run if requested
|
||||
if run_id:
|
||||
run = persistence.load_run(run_id)
|
||||
if run:
|
||||
snapshot["run"] = run.to_dict()
|
||||
|
||||
# Get session info
|
||||
if session:
|
||||
session_data = persistence.load_session(session)
|
||||
if session_data:
|
||||
snapshot["session"] = session_data
|
||||
|
||||
persistence.close()
|
||||
|
||||
if json_output:
|
||||
console.print(json.dumps(snapshot, indent=2, default=str))
|
||||
else:
|
||||
# Pretty print
|
||||
console.print("─" * 60)
|
||||
console.print(f"◉ PraisonAI Snapshot │ {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
console.print("─" * 60)
|
||||
|
||||
console.print(f"\n[bold]Queue Status[/bold]")
|
||||
console.print(f" Queued: {snapshot['queued_count']} │ Running: {snapshot['running_count']}")
|
||||
|
||||
if snapshot["recent_runs"]:
|
||||
console.print(f"\n[bold]Recent Runs[/bold]")
|
||||
for r in snapshot["recent_runs"]:
|
||||
state_color = {
|
||||
"succeeded": "green",
|
||||
"failed": "red",
|
||||
"cancelled": "dim",
|
||||
}.get(r["state"], "yellow")
|
||||
console.print(f" [{state_color}]{r['run_id']}[/{state_color}] {r['agent']}: {r['input'][:30]}...")
|
||||
|
||||
if "run" in snapshot:
|
||||
console.print(f"\n[bold]Run Details[/bold]")
|
||||
run = snapshot["run"]
|
||||
console.print(f" ID: {run.get('run_id', 'N/A')}")
|
||||
console.print(f" State: {run.get('state', 'N/A')}")
|
||||
console.print(f" Agent: {run.get('agent_name', 'N/A')}")
|
||||
|
||||
console.print("─" * 60)
|
||||
|
||||
@app.command("trace")
|
||||
def tui_trace(
|
||||
id: str = typer.Argument(..., help="Session or run ID to trace"),
|
||||
follow: bool = typer.Option(False, "--follow", "-f", help="Follow new events"),
|
||||
limit: int = typer.Option(50, "--limit", "-n", help="Max events to show"),
|
||||
):
|
||||
"""Replay events from persistence like a timeline."""
|
||||
from ..queue import QueuePersistence
|
||||
|
||||
persistence = QueuePersistence()
|
||||
persistence.initialize()
|
||||
|
||||
# Try to find as session or run
|
||||
session = persistence.load_session(id)
|
||||
run = persistence.load_run(id)
|
||||
|
||||
if not session and not run:
|
||||
# Try partial match
|
||||
runs = persistence.list_runs(limit=100)
|
||||
matches = [r for r in runs if r.run_id.startswith(id) or r.session_id.startswith(id)]
|
||||
if matches:
|
||||
run = matches[0]
|
||||
|
||||
if not session and not run:
|
||||
console.print(f"[red]No session or run found for: {id}[/red]")
|
||||
persistence.close()
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get runs for timeline
|
||||
if session:
|
||||
runs = persistence.list_runs(session_id=id, limit=limit)
|
||||
else:
|
||||
runs = [run] if run else []
|
||||
|
||||
# Build timeline
|
||||
events = []
|
||||
for r in runs:
|
||||
events.append({
|
||||
"timestamp": r.created_at,
|
||||
"type": "run_created",
|
||||
"run_id": r.run_id[:8],
|
||||
"agent": r.agent_name,
|
||||
"input": r.input_content[:50],
|
||||
})
|
||||
if r.started_at:
|
||||
events.append({
|
||||
"timestamp": r.started_at,
|
||||
"type": "run_started",
|
||||
"run_id": r.run_id[:8],
|
||||
})
|
||||
if r.ended_at:
|
||||
events.append({
|
||||
"timestamp": r.ended_at,
|
||||
"type": f"run_{r.state.value}",
|
||||
"run_id": r.run_id[:8],
|
||||
})
|
||||
|
||||
# Sort by timestamp
|
||||
events.sort(key=lambda e: e["timestamp"])
|
||||
|
||||
persistence.close()
|
||||
|
||||
# Print timeline
|
||||
console.print(f"\n[bold]Event Timeline for {id[:8]}[/bold]\n")
|
||||
|
||||
for event in events[-limit:]:
|
||||
ts = time.strftime("%H:%M:%S", time.localtime(event["timestamp"]))
|
||||
event_type = event["type"]
|
||||
|
||||
type_colors = {
|
||||
"run_created": "cyan",
|
||||
"run_started": "yellow",
|
||||
"run_succeeded": "green",
|
||||
"run_failed": "red",
|
||||
"run_cancelled": "dim",
|
||||
}
|
||||
color = type_colors.get(event_type, "white")
|
||||
|
||||
line = f"[dim]{ts}[/dim] [{color}]{event_type:15}[/{color}]"
|
||||
if "run_id" in event:
|
||||
line += f" run={event['run_id']}"
|
||||
if "agent" in event:
|
||||
line += f" agent={event['agent']}"
|
||||
if "input" in event:
|
||||
line += f" \"{event['input']}...\""
|
||||
|
||||
console.print(line)
|
||||
|
||||
if follow:
|
||||
console.print("\n[dim]Following... (Ctrl+C to stop)[/dim]")
|
||||
# In a real implementation, this would poll for new events
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def create_queue_watch_command():
|
||||
"""Create the queue watch command."""
|
||||
if not TYPER_AVAILABLE:
|
||||
return None
|
||||
|
||||
def queue_watch(
|
||||
state: Optional[str] = typer.Option(None, "--state", "-s"),
|
||||
agent: Optional[str] = typer.Option(None, "--agent", "-a"),
|
||||
run_id: Optional[str] = typer.Option(None, "--run", "-r"),
|
||||
jsonl: bool = typer.Option(False, "--jsonl", "-j"),
|
||||
interval: float = typer.Option(1.0, "--interval", "-i"),
|
||||
):
|
||||
"""Watch queue events in real time."""
|
||||
from ..queue import QueuePersistence, RunState
|
||||
|
||||
persistence = QueuePersistence()
|
||||
persistence.initialize()
|
||||
|
||||
last_seen = {}
|
||||
|
||||
try:
|
||||
while True:
|
||||
runs = persistence.list_runs(limit=100)
|
||||
|
||||
# Filter
|
||||
if state:
|
||||
try:
|
||||
state_filter = RunState(state.lower())
|
||||
runs = [r for r in runs if r.state == state_filter]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if agent:
|
||||
runs = [r for r in runs if agent.lower() in r.agent_name.lower()]
|
||||
|
||||
if run_id:
|
||||
runs = [r for r in runs if r.run_id.startswith(run_id)]
|
||||
|
||||
# Check for changes
|
||||
for run in runs:
|
||||
key = run.run_id
|
||||
current_state = run.state.value
|
||||
|
||||
if key not in last_seen or last_seen[key] != current_state:
|
||||
last_seen[key] = current_state
|
||||
|
||||
if jsonl:
|
||||
event = {
|
||||
"timestamp": time.time(),
|
||||
"run_id": run.run_id,
|
||||
"agent": run.agent_name,
|
||||
"state": current_state,
|
||||
}
|
||||
print(json.dumps(event))
|
||||
else:
|
||||
ts = time.strftime("%H:%M:%S")
|
||||
state_colors = {
|
||||
"queued": "yellow",
|
||||
"running": "cyan",
|
||||
"succeeded": "green",
|
||||
"failed": "red",
|
||||
"cancelled": "dim",
|
||||
}
|
||||
color = state_colors.get(current_state, "white")
|
||||
console.print(
|
||||
f"[dim]{ts}[/dim] [{color}]{current_state:10}[/{color}] "
|
||||
f"{run.run_id[:8]} {run.agent_name}"
|
||||
)
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Stopped watching[/dim]")
|
||||
finally:
|
||||
persistence.close()
|
||||
|
||||
return queue_watch
|
||||
|
||||
|
||||
def create_doctor_command():
|
||||
"""Create the doctor command for TUI diagnostics."""
|
||||
if not TYPER_AVAILABLE:
|
||||
return None
|
||||
|
||||
def doctor_tui():
|
||||
"""Validate TUI dependencies, DB schema, and wiring."""
|
||||
checks = []
|
||||
|
||||
# Check Textual
|
||||
try:
|
||||
import textual
|
||||
checks.append(("Textual", "✓", f"v{textual.__version__}", "green"))
|
||||
except ImportError:
|
||||
checks.append(("Textual", "✗", "Not installed", "red"))
|
||||
|
||||
# Check Rich
|
||||
try:
|
||||
import rich
|
||||
checks.append(("Rich", "✓", f"v{rich.__version__}", "green"))
|
||||
except ImportError:
|
||||
checks.append(("Rich", "✗", "Not installed", "red"))
|
||||
|
||||
# Check Typer
|
||||
try:
|
||||
import typer
|
||||
checks.append(("Typer", "✓", f"v{typer.__version__}", "green"))
|
||||
except ImportError:
|
||||
checks.append(("Typer", "✗", "Not installed", "red"))
|
||||
|
||||
# Check queue modules
|
||||
try:
|
||||
from ..queue import QueueManager, QueueScheduler, QueuePersistence
|
||||
checks.append(("Queue System", "✓", "All modules loaded", "green"))
|
||||
except ImportError as e:
|
||||
checks.append(("Queue System", "✗", str(e), "red"))
|
||||
|
||||
# Check TUI modules
|
||||
try:
|
||||
from .orchestrator import TuiOrchestrator
|
||||
checks.append(("TUI Orchestrator", "✓", "Loaded", "green"))
|
||||
except ImportError as e:
|
||||
checks.append(("TUI Orchestrator", "✗", str(e), "red"))
|
||||
|
||||
# Check events
|
||||
try:
|
||||
from .events import TUIEvent, TUIEventType
|
||||
event_count = len(TUIEventType)
|
||||
checks.append(("TUI Events", "✓", f"{event_count} event types", "green"))
|
||||
except ImportError as e:
|
||||
checks.append(("TUI Events", "✗", str(e), "red"))
|
||||
|
||||
# Check DB
|
||||
try:
|
||||
from ..queue import QueuePersistence
|
||||
p = QueuePersistence()
|
||||
p.initialize()
|
||||
stats = p.get_stats()
|
||||
p.close()
|
||||
checks.append(("Database", "✓", f"{stats.total_runs} runs in DB", "green"))
|
||||
except Exception as e:
|
||||
checks.append(("Database", "✗", str(e), "red"))
|
||||
|
||||
# Check slash commands
|
||||
try:
|
||||
from ..slash_commands import SlashCommandRegistry
|
||||
registry = SlashCommandRegistry()
|
||||
cmd_count = len(registry.commands)
|
||||
checks.append(("Slash Commands", "✓", f"{cmd_count} commands", "green"))
|
||||
except Exception as e:
|
||||
checks.append(("Slash Commands", "⚠", str(e), "yellow"))
|
||||
|
||||
# Print results
|
||||
console.print("\n[bold]PraisonAI TUI Diagnostics[/bold]\n")
|
||||
|
||||
table = Table(show_header=True)
|
||||
table.add_column("Component")
|
||||
table.add_column("Status")
|
||||
table.add_column("Details")
|
||||
|
||||
all_ok = True
|
||||
for name, status, details, color in checks:
|
||||
table.add_row(name, f"[{color}]{status}[/{color}]", details)
|
||||
if status == "✗":
|
||||
all_ok = False
|
||||
|
||||
console.print(table)
|
||||
|
||||
if all_ok:
|
||||
console.print("\n[green]All checks passed![/green]")
|
||||
else:
|
||||
console.print("\n[red]Some checks failed. Run 'pip install praisonai[tui]' to install missing dependencies.[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
return doctor_tui
|
||||
|
||||
|
||||
def register_debug_commands(app):
|
||||
"""Register debug commands with the main CLI app."""
|
||||
if not TYPER_AVAILABLE:
|
||||
return
|
||||
|
||||
debug_app = create_debug_app()
|
||||
watch_cmd = create_queue_watch_command()
|
||||
doctor_cmd = create_doctor_command()
|
||||
|
||||
if debug_app:
|
||||
app.add_typer(debug_app, name="tui")
|
||||
|
||||
if watch_cmd:
|
||||
# Add to queue subcommand
|
||||
pass # Will be added via queue app
|
||||
|
||||
if doctor_cmd:
|
||||
# Add doctor --tui option
|
||||
pass # Will be added via main app
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Custom events for PraisonAI TUI.
|
||||
|
||||
Defines event types for communication between TUI components.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional
|
||||
import time
|
||||
|
||||
|
||||
class TUIEventType(str, Enum):
|
||||
"""Types of TUI events."""
|
||||
# User input events
|
||||
MESSAGE_SUBMITTED = "message_submitted"
|
||||
COMMAND_EXECUTED = "command_executed"
|
||||
|
||||
# Queue events
|
||||
RUN_QUEUED = "run_queued"
|
||||
RUN_STARTED = "run_started"
|
||||
RUN_COMPLETED = "run_completed"
|
||||
RUN_FAILED = "run_failed"
|
||||
RUN_CANCELLED = "run_cancelled"
|
||||
|
||||
# Output events
|
||||
OUTPUT_CHUNK = "output_chunk"
|
||||
OUTPUT_COMPLETE = "output_complete"
|
||||
|
||||
# Tool events
|
||||
TOOL_CALL_STARTED = "tool_call_started"
|
||||
TOOL_CALL_COMPLETED = "tool_call_completed"
|
||||
TOOL_APPROVAL_REQUIRED = "tool_approval_required"
|
||||
|
||||
# Session events
|
||||
SESSION_STARTED = "session_started"
|
||||
SESSION_SAVED = "session_saved"
|
||||
SESSION_LOADED = "session_loaded"
|
||||
|
||||
# UI events
|
||||
SCREEN_CHANGED = "screen_changed"
|
||||
FOCUS_CHANGED = "focus_changed"
|
||||
ERROR_OCCURRED = "error_occurred"
|
||||
STATUS_UPDATED = "status_updated"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TUIEvent:
|
||||
"""A TUI event."""
|
||||
event_type: TUIEventType
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
data: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Optional identifiers
|
||||
run_id: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
agent_name: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def message_submitted(cls, content: str, **kwargs) -> "TUIEvent":
|
||||
"""Create a message submitted event."""
|
||||
return cls(
|
||||
event_type=TUIEventType.MESSAGE_SUBMITTED,
|
||||
data={"content": content, **kwargs}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def output_chunk(cls, run_id: str, content: str, **kwargs) -> "TUIEvent":
|
||||
"""Create an output chunk event."""
|
||||
return cls(
|
||||
event_type=TUIEventType.OUTPUT_CHUNK,
|
||||
run_id=run_id,
|
||||
data={"content": content, **kwargs}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def run_completed(cls, run_id: str, output: str, **kwargs) -> "TUIEvent":
|
||||
"""Create a run completed event."""
|
||||
return cls(
|
||||
event_type=TUIEventType.RUN_COMPLETED,
|
||||
run_id=run_id,
|
||||
data={"output": output, **kwargs}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def error(cls, message: str, **kwargs) -> "TUIEvent":
|
||||
"""Create an error event."""
|
||||
return cls(
|
||||
event_type=TUIEventType.ERROR_OCCURRED,
|
||||
data={"message": message, **kwargs}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def status_update(cls, status: str, **kwargs) -> "TUIEvent":
|
||||
"""Create a status update event."""
|
||||
return cls(
|
||||
event_type=TUIEventType.STATUS_UPDATED,
|
||||
data={"status": status, **kwargs}
|
||||
)
|
||||
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
Mock Provider for PraisonAI TUI Testing.
|
||||
|
||||
Provides deterministic, replayable responses for CI and headless testing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncIterator, Callable, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockResponse:
|
||||
"""A mock response configuration."""
|
||||
content: str
|
||||
chunks: List[str] = field(default_factory=list)
|
||||
delay_per_chunk: float = 0.05
|
||||
tool_calls: List[Dict[str, Any]] = field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
tokens: int = 0
|
||||
cost: float = 0.0
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.chunks and self.content:
|
||||
# Split content into word chunks
|
||||
words = self.content.split()
|
||||
self.chunks = []
|
||||
for i in range(0, len(words), 3):
|
||||
chunk = " ".join(words[i:i+3])
|
||||
if i > 0:
|
||||
chunk = " " + chunk
|
||||
self.chunks.append(chunk)
|
||||
|
||||
if not self.tokens:
|
||||
self.tokens = len(self.content.split()) * 2 # Rough estimate
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockProviderConfig:
|
||||
"""Configuration for mock provider."""
|
||||
seed: int = 42
|
||||
default_delay: float = 0.05
|
||||
simulate_errors: bool = False
|
||||
error_rate: float = 0.1
|
||||
record_mode: bool = False
|
||||
playback_file: Optional[str] = None
|
||||
|
||||
# Canned responses by input pattern
|
||||
responses: Dict[str, MockResponse] = field(default_factory=dict)
|
||||
|
||||
# Default response if no pattern matches
|
||||
default_response: Optional[MockResponse] = None
|
||||
|
||||
|
||||
class MockProvider:
|
||||
"""
|
||||
Mock LLM provider for testing.
|
||||
|
||||
Features:
|
||||
- Deterministic responses based on seed
|
||||
- Streaming simulation
|
||||
- Tool call simulation
|
||||
- Error injection
|
||||
- Record/playback mode
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[MockProviderConfig] = None):
|
||||
self.config = config or MockProviderConfig()
|
||||
self._rng = random.Random(self.config.seed)
|
||||
self._recordings: List[Dict[str, Any]] = []
|
||||
self._playback_index = 0
|
||||
|
||||
# Load playback file if specified
|
||||
if self.config.playback_file:
|
||||
self._load_playback()
|
||||
|
||||
# Setup default responses
|
||||
self._setup_defaults()
|
||||
|
||||
def _setup_defaults(self) -> None:
|
||||
"""Setup default canned responses."""
|
||||
defaults = {
|
||||
"hello": MockResponse(
|
||||
content="Hello! I'm a mock AI assistant. How can I help you today?",
|
||||
tokens=20,
|
||||
cost=0.0001,
|
||||
),
|
||||
"help": MockResponse(
|
||||
content="I can help you with various tasks. Just ask me anything!",
|
||||
tokens=15,
|
||||
cost=0.00008,
|
||||
),
|
||||
"test": MockResponse(
|
||||
content="This is a test response. Everything is working correctly.",
|
||||
tokens=12,
|
||||
cost=0.00006,
|
||||
),
|
||||
"error": MockResponse(
|
||||
content="",
|
||||
error="Simulated error for testing",
|
||||
),
|
||||
"tool": MockResponse(
|
||||
content="I'll use a tool to help with that.",
|
||||
tool_calls=[{
|
||||
"id": "call_mock_001",
|
||||
"name": "mock_tool",
|
||||
"arguments": {"query": "test"},
|
||||
}],
|
||||
tokens=25,
|
||||
cost=0.00012,
|
||||
),
|
||||
}
|
||||
|
||||
for pattern, response in defaults.items():
|
||||
if pattern not in self.config.responses:
|
||||
self.config.responses[pattern] = response
|
||||
|
||||
if not self.config.default_response:
|
||||
self.config.default_response = MockResponse(
|
||||
content="I understand your request. Here's a mock response for testing purposes. "
|
||||
"This response is generated deterministically based on the input.",
|
||||
tokens=30,
|
||||
cost=0.00015,
|
||||
)
|
||||
|
||||
def _load_playback(self) -> None:
|
||||
"""Load recordings for playback."""
|
||||
if not self.config.playback_file:
|
||||
return
|
||||
|
||||
path = Path(self.config.playback_file)
|
||||
if path.exists():
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
self._recordings.append(json.loads(line))
|
||||
|
||||
def _save_recording(self, input_content: str, response: MockResponse) -> None:
|
||||
"""Save a recording."""
|
||||
if not self.config.record_mode or not self.config.playback_file:
|
||||
return
|
||||
|
||||
record = {
|
||||
"timestamp": time.time(),
|
||||
"input": input_content,
|
||||
"response": {
|
||||
"content": response.content,
|
||||
"chunks": response.chunks,
|
||||
"tool_calls": response.tool_calls,
|
||||
"tokens": response.tokens,
|
||||
"cost": response.cost,
|
||||
}
|
||||
}
|
||||
|
||||
path = Path(self.config.playback_file)
|
||||
with open(path, "a") as f:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
|
||||
def _get_response(self, input_content: str) -> MockResponse:
|
||||
"""Get response for input."""
|
||||
# Check playback mode
|
||||
if self._recordings and self._playback_index < len(self._recordings):
|
||||
record = self._recordings[self._playback_index]
|
||||
self._playback_index += 1
|
||||
return MockResponse(**record["response"])
|
||||
|
||||
# Check for pattern match
|
||||
input_lower = input_content.lower()
|
||||
for pattern, response in self.config.responses.items():
|
||||
if pattern in input_lower:
|
||||
return response
|
||||
|
||||
# Simulate random error
|
||||
if self.config.simulate_errors and self._rng.random() < self.config.error_rate:
|
||||
return MockResponse(content="", error="Random simulated error")
|
||||
|
||||
# Generate deterministic response based on input hash
|
||||
input_hash = hashlib.md5(input_content.encode()).hexdigest()
|
||||
seed = int(input_hash[:8], 16)
|
||||
rng = random.Random(seed)
|
||||
|
||||
# Generate varied response
|
||||
templates = [
|
||||
"I've processed your request about '{topic}'. Here's what I found...",
|
||||
"Regarding '{topic}', I can provide the following information...",
|
||||
"Thank you for asking about '{topic}'. Let me explain...",
|
||||
"Based on your query about '{topic}', here's my response...",
|
||||
]
|
||||
|
||||
topic = input_content[:30] if len(input_content) > 30 else input_content
|
||||
template = rng.choice(templates)
|
||||
content = template.format(topic=topic)
|
||||
|
||||
# Add some mock content
|
||||
content += " This is a deterministic mock response generated for testing. "
|
||||
content += f"Input hash: {input_hash[:8]}. "
|
||||
content += "The response is reproducible given the same input."
|
||||
|
||||
return MockResponse(
|
||||
content=content,
|
||||
tokens=len(content.split()) * 2,
|
||||
cost=len(content) * 0.000001,
|
||||
)
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
input_content: str,
|
||||
model: str = "mock-model",
|
||||
stream: bool = True,
|
||||
on_chunk: Optional[Callable[[str], None]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a mock response.
|
||||
|
||||
Args:
|
||||
input_content: The input prompt
|
||||
model: Model name (ignored for mock)
|
||||
stream: Whether to stream the response
|
||||
on_chunk: Callback for streaming chunks
|
||||
|
||||
Returns:
|
||||
Response dict with content, tokens, cost
|
||||
"""
|
||||
response = self._get_response(input_content)
|
||||
|
||||
# Record if in record mode
|
||||
if self.config.record_mode:
|
||||
self._save_recording(input_content, response)
|
||||
|
||||
# Check for error
|
||||
if response.error:
|
||||
raise Exception(response.error)
|
||||
|
||||
# Stream chunks
|
||||
if stream and on_chunk:
|
||||
for chunk in response.chunks:
|
||||
await asyncio.sleep(response.delay_per_chunk)
|
||||
on_chunk(chunk)
|
||||
else:
|
||||
# Non-streaming delay
|
||||
await asyncio.sleep(len(response.chunks) * response.delay_per_chunk)
|
||||
|
||||
return {
|
||||
"content": response.content,
|
||||
"tokens": response.tokens,
|
||||
"cost": response.cost,
|
||||
"tool_calls": response.tool_calls,
|
||||
"model": model,
|
||||
}
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
input_content: str,
|
||||
model: str = "mock-model",
|
||||
) -> AsyncIterator[str]:
|
||||
"""
|
||||
Stream a mock response.
|
||||
|
||||
Yields chunks of the response.
|
||||
"""
|
||||
response = self._get_response(input_content)
|
||||
|
||||
if response.error:
|
||||
raise Exception(response.error)
|
||||
|
||||
for chunk in response.chunks:
|
||||
await asyncio.sleep(response.delay_per_chunk)
|
||||
yield chunk
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset provider state."""
|
||||
self._rng = random.Random(self.config.seed)
|
||||
self._playback_index = 0
|
||||
|
||||
|
||||
class MockExecutor:
|
||||
"""
|
||||
Mock executor for queue worker testing.
|
||||
|
||||
Simulates agent execution with configurable behavior.
|
||||
"""
|
||||
|
||||
def __init__(self, provider: Optional[MockProvider] = None):
|
||||
self.provider = provider or MockProvider()
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
input_content: str,
|
||||
agent_config: Dict[str, Any],
|
||||
on_output: Optional[Callable[[str], None]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a mock agent run."""
|
||||
model = agent_config.get("model", "mock-model")
|
||||
|
||||
result = await self.provider.generate(
|
||||
input_content=input_content,
|
||||
model=model,
|
||||
stream=True,
|
||||
on_chunk=on_output,
|
||||
)
|
||||
|
||||
return {
|
||||
"output": result["content"],
|
||||
"metrics": {
|
||||
"tokens": result["tokens"],
|
||||
"cost": result["cost"],
|
||||
},
|
||||
"tool_calls": result.get("tool_calls", []),
|
||||
}
|
||||
|
||||
|
||||
# Convenience function for tests
|
||||
def create_mock_provider(
|
||||
seed: int = 42,
|
||||
responses: Optional[Dict[str, str]] = None,
|
||||
) -> MockProvider:
|
||||
"""Create a mock provider with optional custom responses."""
|
||||
config = MockProviderConfig(seed=seed)
|
||||
|
||||
if responses:
|
||||
for pattern, content in responses.items():
|
||||
config.responses[pattern] = MockResponse(content=content)
|
||||
|
||||
return MockProvider(config)
|
||||
@@ -0,0 +1,652 @@
|
||||
"""
|
||||
TUI Orchestrator for PraisonAI.
|
||||
|
||||
Provides unified event handling for both interactive TUI and headless simulation modes.
|
||||
Inspired by gemini-cli's event-driven architecture and codex-cli's state management.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, List, Optional, TextIO
|
||||
import sys
|
||||
|
||||
from .events import TUIEvent, TUIEventType
|
||||
from ..queue import QueueManager, QueueConfig, QueuedRun, RunState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OutputMode(str, Enum):
|
||||
"""Output mode for headless simulation."""
|
||||
PRETTY = "pretty"
|
||||
JSONL = "jsonl"
|
||||
SILENT = "silent"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UIStateModel:
|
||||
"""
|
||||
In-memory UI state model.
|
||||
|
||||
Mirrors the state that would be displayed in the TUI,
|
||||
enabling headless simulation and snapshot generation.
|
||||
"""
|
||||
# Session info
|
||||
session_id: str = ""
|
||||
workspace: str = ""
|
||||
|
||||
# Model/config
|
||||
model: str = "gpt-4o-mini"
|
||||
|
||||
# Chat history
|
||||
messages: List[Dict[str, Any]] = field(default_factory=list)
|
||||
max_messages: int = 1000
|
||||
|
||||
# Current streaming state
|
||||
current_run_id: Optional[str] = None
|
||||
streaming_content: str = ""
|
||||
is_processing: bool = False
|
||||
|
||||
# Queue state
|
||||
queued_runs: List[Dict[str, Any]] = field(default_factory=list)
|
||||
running_runs: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# Tool calls
|
||||
pending_tool_calls: List[Dict[str, Any]] = field(default_factory=list)
|
||||
recent_tool_calls: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# Metrics
|
||||
total_tokens: int = 0
|
||||
total_cost: float = 0.0
|
||||
|
||||
# Focus/screen state (for simulation)
|
||||
current_screen: str = "main"
|
||||
focused_widget: str = "composer"
|
||||
|
||||
# Events log (for trace/replay)
|
||||
events: List[Dict[str, Any]] = field(default_factory=list)
|
||||
max_events: int = 10000
|
||||
|
||||
def add_message(self, role: str, content: str, **kwargs) -> None:
|
||||
"""Add a message to history."""
|
||||
msg = {
|
||||
"role": role,
|
||||
"content": content,
|
||||
"timestamp": time.time(),
|
||||
**kwargs
|
||||
}
|
||||
self.messages.append(msg)
|
||||
if len(self.messages) > self.max_messages:
|
||||
self.messages = self.messages[-self.max_messages:]
|
||||
|
||||
def add_event(self, event: TUIEvent) -> None:
|
||||
"""Add an event to the log."""
|
||||
evt = {
|
||||
"type": event.event_type.value,
|
||||
"timestamp": event.timestamp,
|
||||
"run_id": event.run_id,
|
||||
"session_id": event.session_id,
|
||||
"agent_name": event.agent_name,
|
||||
"data": event.data,
|
||||
}
|
||||
self.events.append(evt)
|
||||
if len(self.events) > self.max_events:
|
||||
self.events = self.events[-self.max_events:]
|
||||
|
||||
def to_snapshot(self) -> Dict[str, Any]:
|
||||
"""Generate a snapshot of current state."""
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"model": self.model,
|
||||
"current_screen": self.current_screen,
|
||||
"focused_widget": self.focused_widget,
|
||||
"is_processing": self.is_processing,
|
||||
"current_run_id": self.current_run_id,
|
||||
"streaming_content_length": len(self.streaming_content),
|
||||
"message_count": len(self.messages),
|
||||
"last_messages": self.messages[-5:] if self.messages else [],
|
||||
"queued_count": len(self.queued_runs),
|
||||
"running_count": len(self.running_runs),
|
||||
"pending_tool_calls": len(self.pending_tool_calls),
|
||||
"total_tokens": self.total_tokens,
|
||||
"total_cost": self.total_cost,
|
||||
}
|
||||
|
||||
def render_snapshot_pretty(self) -> str:
|
||||
"""Render a pretty text snapshot like TUI would show."""
|
||||
lines = []
|
||||
|
||||
# Status bar
|
||||
status = f"◉ PraisonAI │ Session: {self.session_id[:8] if self.session_id else 'new'}"
|
||||
status += f" │ Model: {self.model}"
|
||||
if self.total_tokens > 0:
|
||||
status += f" │ Tokens: {self.total_tokens:,}"
|
||||
if self.total_cost > 0:
|
||||
status += f" │ ${self.total_cost:.4f}"
|
||||
if self.is_processing:
|
||||
status += " │ ⟳ Processing..."
|
||||
lines.append("─" * 60)
|
||||
lines.append(status)
|
||||
lines.append("─" * 60)
|
||||
|
||||
# Chat messages (last 5)
|
||||
lines.append("\n[Chat History]")
|
||||
for msg in self.messages[-5:]:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")[:100]
|
||||
if len(msg.get("content", "")) > 100:
|
||||
content += "..."
|
||||
lines.append(f" {role.upper()}: {content}")
|
||||
|
||||
# Streaming content
|
||||
if self.streaming_content:
|
||||
lines.append(f"\n[Streaming] ({len(self.streaming_content)} chars)")
|
||||
preview = self.streaming_content[-200:]
|
||||
if len(self.streaming_content) > 200:
|
||||
preview = "..." + preview
|
||||
lines.append(f" {preview}")
|
||||
|
||||
# Queue status
|
||||
lines.append(f"\n[Queue] Queued: {len(self.queued_runs)} │ Running: {len(self.running_runs)}")
|
||||
|
||||
# Tool calls
|
||||
if self.pending_tool_calls:
|
||||
lines.append(f"\n[Pending Approvals] {len(self.pending_tool_calls)}")
|
||||
for tc in self.pending_tool_calls[:3]:
|
||||
lines.append(f" ⚠ {tc.get('tool_name', 'unknown')}")
|
||||
|
||||
# Screen/focus
|
||||
lines.append(f"\n[UI] Screen: {self.current_screen} │ Focus: {self.focused_widget}")
|
||||
lines.append("─" * 60)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class TuiOrchestrator:
|
||||
"""
|
||||
Unified orchestrator for TUI and headless modes.
|
||||
|
||||
Subscribes to the event bus and maintains an in-memory UI state model.
|
||||
Can drive Textual widgets (interactive) or output snapshots (headless).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
queue_manager: Optional[QueueManager] = None,
|
||||
queue_config: Optional[QueueConfig] = None,
|
||||
output_mode: OutputMode = OutputMode.PRETTY,
|
||||
output_stream: Optional[TextIO] = None,
|
||||
jsonl_path: Optional[str] = None,
|
||||
debug: bool = False,
|
||||
):
|
||||
self.queue_config = queue_config or QueueConfig()
|
||||
self.queue_manager = queue_manager
|
||||
self.output_mode = output_mode
|
||||
self.output_stream = output_stream or sys.stdout
|
||||
self.jsonl_path = jsonl_path
|
||||
self.debug = debug
|
||||
|
||||
# State
|
||||
self.state = UIStateModel()
|
||||
self._event_callbacks: List[Callable[[TUIEvent], None]] = []
|
||||
self._jsonl_file: Optional[TextIO] = None
|
||||
self._running = False
|
||||
|
||||
# Trace ID for this orchestrator session
|
||||
self.trace_id = str(uuid.uuid4())[:8]
|
||||
|
||||
async def start(self, session_id: Optional[str] = None, recover: bool = True) -> None:
|
||||
"""Start the orchestrator."""
|
||||
self.state.session_id = session_id or str(uuid.uuid4())[:8]
|
||||
self._running = True
|
||||
|
||||
# Open JSONL file if specified
|
||||
if self.jsonl_path:
|
||||
self._jsonl_file = open(self.jsonl_path, "a")
|
||||
|
||||
# Initialize queue manager if not provided
|
||||
if not self.queue_manager:
|
||||
self.queue_manager = QueueManager(
|
||||
config=self.queue_config,
|
||||
on_output=self._handle_output,
|
||||
on_complete=self._handle_complete,
|
||||
on_error=self._handle_error,
|
||||
)
|
||||
await self.queue_manager.start(recover=recover)
|
||||
self.queue_manager.set_session(self.state.session_id)
|
||||
|
||||
self._emit_event(TUIEvent(
|
||||
event_type=TUIEventType.SESSION_STARTED,
|
||||
session_id=self.state.session_id,
|
||||
data={"trace_id": self.trace_id}
|
||||
))
|
||||
|
||||
if self.debug:
|
||||
self._log_debug(f"Orchestrator started: session={self.state.session_id}, trace={self.trace_id}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the orchestrator."""
|
||||
self._running = False
|
||||
|
||||
if self.queue_manager:
|
||||
await self.queue_manager.stop()
|
||||
|
||||
if self._jsonl_file:
|
||||
self._jsonl_file.close()
|
||||
self._jsonl_file = None
|
||||
|
||||
if self.debug:
|
||||
self._log_debug("Orchestrator stopped")
|
||||
|
||||
def add_event_callback(self, callback: Callable[[TUIEvent], None]) -> None:
|
||||
"""Add an event callback."""
|
||||
self._event_callbacks.append(callback)
|
||||
|
||||
def _emit_event(self, event: TUIEvent) -> None:
|
||||
"""Emit an event to all listeners."""
|
||||
# Add to state log
|
||||
self.state.add_event(event)
|
||||
|
||||
# Write to JSONL if enabled
|
||||
if self._jsonl_file:
|
||||
self._write_jsonl(event)
|
||||
|
||||
# Call callbacks
|
||||
for callback in self._event_callbacks:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception as e:
|
||||
logger.error(f"Event callback error: {e}")
|
||||
|
||||
# Output based on mode
|
||||
if self.output_mode == OutputMode.JSONL:
|
||||
self._print_jsonl(event)
|
||||
elif self.output_mode == OutputMode.PRETTY and self.debug:
|
||||
self._print_event_pretty(event)
|
||||
|
||||
def _write_jsonl(self, event: TUIEvent) -> None:
|
||||
"""Write event to JSONL file."""
|
||||
if not self._jsonl_file:
|
||||
return
|
||||
|
||||
record = {
|
||||
"timestamp": event.timestamp,
|
||||
"trace_id": self.trace_id,
|
||||
"session_id": event.session_id or self.state.session_id,
|
||||
"event_type": event.event_type.value,
|
||||
"run_id": event.run_id,
|
||||
"agent_name": event.agent_name,
|
||||
"data": event.data,
|
||||
}
|
||||
self._jsonl_file.write(json.dumps(record, default=str) + "\n")
|
||||
self._jsonl_file.flush()
|
||||
|
||||
def _print_jsonl(self, event: TUIEvent) -> None:
|
||||
"""Print event as JSONL to output stream."""
|
||||
record = {
|
||||
"timestamp": event.timestamp,
|
||||
"event_type": event.event_type.value,
|
||||
"run_id": event.run_id,
|
||||
"data": event.data,
|
||||
}
|
||||
print(json.dumps(record, default=str), file=self.output_stream)
|
||||
|
||||
def _print_event_pretty(self, event: TUIEvent) -> None:
|
||||
"""Print event in pretty format."""
|
||||
ts = time.strftime("%H:%M:%S", time.localtime(event.timestamp))
|
||||
print(f"[{ts}] {event.event_type.value}", end="", file=self.output_stream)
|
||||
if event.run_id:
|
||||
print(f" run={event.run_id[:8]}", end="", file=self.output_stream)
|
||||
if event.data:
|
||||
data_preview = str(event.data)[:50]
|
||||
print(f" {data_preview}", end="", file=self.output_stream)
|
||||
print(file=self.output_stream)
|
||||
|
||||
def _log_debug(self, message: str) -> None:
|
||||
"""Log debug message."""
|
||||
if self.debug:
|
||||
ts = time.strftime("%H:%M:%S")
|
||||
print(f"[DEBUG {ts}] {message}", file=self.output_stream)
|
||||
|
||||
# Queue callbacks
|
||||
|
||||
async def _handle_output(self, run_id: str, chunk: str) -> None:
|
||||
"""Handle streaming output."""
|
||||
if run_id == self.state.current_run_id:
|
||||
self.state.streaming_content += chunk
|
||||
|
||||
self._emit_event(TUIEvent.output_chunk(run_id, chunk))
|
||||
|
||||
async def _handle_complete(self, run_id: str, run: QueuedRun) -> None:
|
||||
"""Handle run completion."""
|
||||
if run_id == self.state.current_run_id:
|
||||
# Add assistant message
|
||||
self.state.add_message(
|
||||
"assistant",
|
||||
run.output_content or self.state.streaming_content,
|
||||
run_id=run_id,
|
||||
agent_name=run.agent_name,
|
||||
)
|
||||
|
||||
# Update metrics
|
||||
if run.metrics:
|
||||
self.state.total_tokens += run.metrics.get("tokens", 0)
|
||||
self.state.total_cost += run.metrics.get("cost", 0.0)
|
||||
|
||||
# Clear streaming state
|
||||
self.state.current_run_id = None
|
||||
self.state.streaming_content = ""
|
||||
self.state.is_processing = False
|
||||
|
||||
# Update queue state
|
||||
self._update_queue_state()
|
||||
|
||||
self._emit_event(TUIEvent.run_completed(
|
||||
run_id,
|
||||
run.output_content or "",
|
||||
agent_name=run.agent_name,
|
||||
))
|
||||
|
||||
async def _handle_error(self, run_id: str, error: Exception) -> None:
|
||||
"""Handle run error."""
|
||||
if run_id == self.state.current_run_id:
|
||||
self.state.add_message(
|
||||
"system",
|
||||
f"Error: {error}",
|
||||
run_id=run_id,
|
||||
)
|
||||
self.state.current_run_id = None
|
||||
self.state.streaming_content = ""
|
||||
self.state.is_processing = False
|
||||
|
||||
self._update_queue_state()
|
||||
|
||||
self._emit_event(TUIEvent.error(str(error), run_id=run_id))
|
||||
|
||||
def _update_queue_state(self) -> None:
|
||||
"""Update queue state from manager."""
|
||||
if not self.queue_manager:
|
||||
return
|
||||
|
||||
runs = self.queue_manager.list_runs(limit=100)
|
||||
self.state.queued_runs = [
|
||||
r.to_dict() for r in runs if r.state == RunState.QUEUED
|
||||
]
|
||||
self.state.running_runs = [
|
||||
r.to_dict() for r in runs if r.state == RunState.RUNNING
|
||||
]
|
||||
|
||||
# Public API
|
||||
|
||||
async def submit_message(self, content: str, agent_name: str = "Assistant") -> str:
|
||||
"""Submit a message for processing."""
|
||||
# Add user message
|
||||
self.state.add_message("user", content)
|
||||
self.state.is_processing = True
|
||||
|
||||
self._emit_event(TUIEvent.message_submitted(content))
|
||||
|
||||
# Submit to queue
|
||||
run_id = await self.queue_manager.submit(
|
||||
input_content=content,
|
||||
agent_name=agent_name,
|
||||
config={"agent_config": {"name": agent_name, "model": self.state.model}}
|
||||
)
|
||||
|
||||
self.state.current_run_id = run_id
|
||||
self.state.streaming_content = ""
|
||||
|
||||
# Add placeholder for streaming
|
||||
self.state.add_message(
|
||||
"assistant",
|
||||
"",
|
||||
run_id=run_id,
|
||||
agent_name=agent_name,
|
||||
is_streaming=True,
|
||||
)
|
||||
|
||||
self._update_queue_state()
|
||||
|
||||
return run_id
|
||||
|
||||
async def cancel_run(self, run_id: Optional[str] = None) -> bool:
|
||||
"""Cancel a run."""
|
||||
target_id = run_id or self.state.current_run_id
|
||||
if not target_id:
|
||||
return False
|
||||
|
||||
result = await self.queue_manager.cancel(target_id)
|
||||
|
||||
if target_id == self.state.current_run_id:
|
||||
self.state.current_run_id = None
|
||||
self.state.streaming_content = ""
|
||||
self.state.is_processing = False
|
||||
|
||||
self._update_queue_state()
|
||||
|
||||
self._emit_event(TUIEvent(
|
||||
event_type=TUIEventType.RUN_CANCELLED,
|
||||
run_id=target_id,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
async def retry_run(self, run_id: str) -> Optional[str]:
|
||||
"""Retry a failed run."""
|
||||
new_id = await self.queue_manager.retry(run_id)
|
||||
self._update_queue_state()
|
||||
return new_id
|
||||
|
||||
def set_model(self, model: str) -> None:
|
||||
"""Set the current model."""
|
||||
self.state.model = model
|
||||
self._emit_event(TUIEvent.status_update(f"Model set to {model}"))
|
||||
|
||||
def navigate_screen(self, screen: str) -> None:
|
||||
"""Navigate to a screen (for simulation)."""
|
||||
self.state.current_screen = screen
|
||||
self._emit_event(TUIEvent(
|
||||
event_type=TUIEventType.SCREEN_CHANGED,
|
||||
data={"screen": screen}
|
||||
))
|
||||
|
||||
def set_focus(self, widget: str) -> None:
|
||||
"""Set focus to a widget (for simulation)."""
|
||||
self.state.focused_widget = widget
|
||||
self._emit_event(TUIEvent(
|
||||
event_type=TUIEventType.FOCUS_CHANGED,
|
||||
data={"widget": widget}
|
||||
))
|
||||
|
||||
def get_snapshot(self) -> Dict[str, Any]:
|
||||
"""Get current state snapshot."""
|
||||
return self.state.to_snapshot()
|
||||
|
||||
def render_snapshot(self) -> str:
|
||||
"""Render pretty snapshot."""
|
||||
return self.state.render_snapshot_pretty()
|
||||
|
||||
async def wait_for_idle(self, timeout: float = 60.0) -> bool:
|
||||
"""Wait until no runs are processing."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
if not self.state.is_processing and not self.state.running_runs:
|
||||
return True
|
||||
await asyncio.sleep(0.1)
|
||||
return False
|
||||
|
||||
async def wait_for_run(self, run_id: str, timeout: float = 60.0) -> bool:
|
||||
"""Wait for a specific run to complete."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
run = self.queue_manager.get_run(run_id)
|
||||
if run and run.state.is_terminal():
|
||||
return True
|
||||
await asyncio.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulationStep:
|
||||
"""A step in a simulation script."""
|
||||
action: str # "submit", "cancel", "retry", "navigate", "focus", "wait", "approve", "deny"
|
||||
args: Dict[str, Any] = field(default_factory=dict)
|
||||
expected: Optional[Dict[str, Any]] = None # For assertion mode
|
||||
|
||||
|
||||
class SimulationRunner:
|
||||
"""
|
||||
Runs simulation scripts for headless TUI testing.
|
||||
|
||||
Script format (YAML):
|
||||
```yaml
|
||||
session_id: test-session
|
||||
model: gpt-4o-mini
|
||||
steps:
|
||||
- action: submit
|
||||
args:
|
||||
content: "Hello, world!"
|
||||
- action: wait
|
||||
args:
|
||||
condition: idle
|
||||
timeout: 30
|
||||
- action: navigate
|
||||
args:
|
||||
screen: queue
|
||||
- action: cancel
|
||||
args:
|
||||
run_id: current
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
orchestrator: TuiOrchestrator,
|
||||
assert_mode: bool = False,
|
||||
):
|
||||
self.orchestrator = orchestrator
|
||||
self.assert_mode = assert_mode
|
||||
self.assertions_passed = 0
|
||||
self.assertions_failed = 0
|
||||
self.errors: List[str] = []
|
||||
|
||||
async def run_script(self, script: Dict[str, Any]) -> bool:
|
||||
"""Run a simulation script."""
|
||||
# Initialize session
|
||||
session_id = script.get("session_id")
|
||||
await self.orchestrator.start(session_id=session_id)
|
||||
|
||||
if "model" in script:
|
||||
self.orchestrator.set_model(script["model"])
|
||||
|
||||
# Run steps
|
||||
steps = script.get("steps", [])
|
||||
for i, step_data in enumerate(steps):
|
||||
step = SimulationStep(
|
||||
action=step_data.get("action", ""),
|
||||
args=step_data.get("args", {}),
|
||||
expected=step_data.get("expected"),
|
||||
)
|
||||
|
||||
try:
|
||||
await self._run_step(step, i)
|
||||
except Exception as e:
|
||||
self.errors.append(f"Step {i} ({step.action}): {e}")
|
||||
if self.assert_mode:
|
||||
break
|
||||
|
||||
await self.orchestrator.stop()
|
||||
|
||||
return len(self.errors) == 0
|
||||
|
||||
async def _run_step(self, step: SimulationStep, index: int) -> None:
|
||||
"""Run a single simulation step."""
|
||||
action = step.action.lower()
|
||||
args = step.args
|
||||
|
||||
if action == "submit":
|
||||
content = args.get("content", "")
|
||||
agent = args.get("agent", "Assistant")
|
||||
await self.orchestrator.submit_message(content, agent)
|
||||
|
||||
elif action == "cancel":
|
||||
run_id = args.get("run_id")
|
||||
if run_id == "current":
|
||||
run_id = None
|
||||
await self.orchestrator.cancel_run(run_id)
|
||||
|
||||
elif action == "retry":
|
||||
run_id = args.get("run_id", "")
|
||||
await self.orchestrator.retry_run(run_id)
|
||||
|
||||
elif action == "navigate":
|
||||
screen = args.get("screen", "main")
|
||||
self.orchestrator.navigate_screen(screen)
|
||||
|
||||
elif action == "focus":
|
||||
widget = args.get("widget", "composer")
|
||||
self.orchestrator.set_focus(widget)
|
||||
|
||||
elif action == "wait":
|
||||
condition = args.get("condition", "idle")
|
||||
timeout = args.get("timeout", 30.0)
|
||||
|
||||
if condition == "idle":
|
||||
success = await self.orchestrator.wait_for_idle(timeout)
|
||||
elif condition == "run":
|
||||
run_id = args.get("run_id", "")
|
||||
success = await self.orchestrator.wait_for_run(run_id, timeout)
|
||||
else:
|
||||
await asyncio.sleep(timeout)
|
||||
success = True
|
||||
|
||||
if not success and self.assert_mode:
|
||||
raise TimeoutError(f"Wait condition '{condition}' timed out")
|
||||
|
||||
elif action == "model":
|
||||
model = args.get("model", "gpt-4o-mini")
|
||||
self.orchestrator.set_model(model)
|
||||
|
||||
elif action == "snapshot":
|
||||
snapshot = self.orchestrator.render_snapshot()
|
||||
print(snapshot)
|
||||
|
||||
elif action == "sleep":
|
||||
duration = args.get("seconds", 1.0)
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
# Check assertions
|
||||
if self.assert_mode and step.expected:
|
||||
self._check_assertions(step.expected, index)
|
||||
|
||||
def _check_assertions(self, expected: Dict[str, Any], step_index: int) -> None:
|
||||
"""Check assertions against current state."""
|
||||
snapshot = self.orchestrator.get_snapshot()
|
||||
|
||||
for key, expected_value in expected.items():
|
||||
actual_value = snapshot.get(key)
|
||||
|
||||
if actual_value != expected_value:
|
||||
self.assertions_failed += 1
|
||||
self.errors.append(
|
||||
f"Step {step_index}: Expected {key}={expected_value}, got {actual_value}"
|
||||
)
|
||||
else:
|
||||
self.assertions_passed += 1
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""Get simulation summary."""
|
||||
return {
|
||||
"assertions_passed": self.assertions_passed,
|
||||
"assertions_failed": self.assertions_failed,
|
||||
"errors": self.errors,
|
||||
"success": len(self.errors) == 0,
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
TUI Screens for PraisonAI.
|
||||
|
||||
Screen definitions for different views in the TUI application.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .main import MainScreen
|
||||
from .queue import QueueScreen
|
||||
from .settings import SettingsScreen
|
||||
from .session import SessionScreen
|
||||
|
||||
_lazy_cache = {}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load screens."""
|
||||
global _lazy_cache
|
||||
|
||||
if name in _lazy_cache:
|
||||
return _lazy_cache[name]
|
||||
|
||||
if name == "MainScreen":
|
||||
from .main import MainScreen
|
||||
_lazy_cache[name] = MainScreen
|
||||
return MainScreen
|
||||
elif name == "QueueScreen":
|
||||
from .queue import QueueScreen
|
||||
_lazy_cache[name] = QueueScreen
|
||||
return QueueScreen
|
||||
elif name == "SettingsScreen":
|
||||
from .settings import SettingsScreen
|
||||
_lazy_cache[name] = SettingsScreen
|
||||
return SettingsScreen
|
||||
elif name == "SessionScreen":
|
||||
from .session import SessionScreen
|
||||
_lazy_cache[name] = SessionScreen
|
||||
return SessionScreen
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MainScreen",
|
||||
"QueueScreen",
|
||||
"SettingsScreen",
|
||||
"SessionScreen",
|
||||
]
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Main Screen for PraisonAI TUI.
|
||||
|
||||
The primary chat interface screen.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
from textual.screen import Screen
|
||||
from textual.containers import Horizontal, Vertical, Container
|
||||
from textual.widgets import Static, Footer, Header
|
||||
from textual.binding import Binding
|
||||
from textual.message import Message
|
||||
TEXTUAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
TEXTUAL_AVAILABLE = False
|
||||
Screen = object
|
||||
Message = object
|
||||
|
||||
|
||||
if TEXTUAL_AVAILABLE:
|
||||
from ..widgets.chat import ChatWidget, ChatMessage
|
||||
from ..widgets.composer import ComposerWidget
|
||||
from ..widgets.status import StatusWidget, StatusInfo
|
||||
from ..widgets.queue_panel import QueuePanelWidget
|
||||
from ..widgets.tool_panel import ToolPanelWidget
|
||||
|
||||
class MainScreen(Screen):
|
||||
"""
|
||||
Main chat screen.
|
||||
|
||||
Layout:
|
||||
- Status bar (top)
|
||||
- Chat history (main area)
|
||||
- Tools panel (right sidebar)
|
||||
- Queue panel (right sidebar)
|
||||
- Composer (bottom)
|
||||
- Footer with keybindings
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+q", "quit", "Quit", show=True),
|
||||
Binding("ctrl+c", "cancel", "Cancel", show=True),
|
||||
Binding("ctrl+l", "clear", "Clear", show=True),
|
||||
Binding("f1", "help", "Help", show=True),
|
||||
Binding("f2", "toggle_queue", "Queue", show=True),
|
||||
Binding("f3", "settings", "Settings", show=True),
|
||||
Binding("f5", "clear_chat", "Clear Chat", show=True),
|
||||
Binding("tab", "focus_next", "Next", show=False),
|
||||
Binding("escape", "cancel_input", "Cancel", show=False),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
MainScreen {
|
||||
layout: grid;
|
||||
grid-size: 2;
|
||||
grid-columns: 3fr 1fr;
|
||||
grid-rows: 1 1fr auto 1;
|
||||
}
|
||||
|
||||
MainScreen #status-bar {
|
||||
column-span: 2;
|
||||
}
|
||||
|
||||
MainScreen #main-area {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
MainScreen #sidebar {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
MainScreen #composer-area {
|
||||
column-span: 2;
|
||||
height: auto;
|
||||
max-height: 10;
|
||||
}
|
||||
|
||||
MainScreen Footer {
|
||||
column-span: 2;
|
||||
}
|
||||
|
||||
MainScreen .sidebar-panel {
|
||||
height: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
class MessageSubmitted(Message):
|
||||
"""Event when a message is submitted."""
|
||||
def __init__(self, content: str):
|
||||
self.content = content
|
||||
super().__init__()
|
||||
|
||||
class CommandExecuted(Message):
|
||||
"""Event when a command is executed."""
|
||||
def __init__(self, command: str, args: str):
|
||||
self.command = command
|
||||
self.args = args
|
||||
super().__init__()
|
||||
|
||||
class CancelRequested(Message):
|
||||
"""Event when cancel is requested."""
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
show_queue: bool = True,
|
||||
show_tools: bool = True,
|
||||
name: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
classes: Optional[str] = None,
|
||||
):
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self._show_queue = show_queue
|
||||
self._show_tools = show_tools
|
||||
|
||||
def compose(self):
|
||||
"""Compose the screen."""
|
||||
yield StatusWidget(id="status-bar")
|
||||
|
||||
yield ChatWidget(id="chat-widget")
|
||||
|
||||
with Vertical(id="sidebar"):
|
||||
if self._show_tools:
|
||||
yield ToolPanelWidget(id="tool-panel", classes="sidebar-panel")
|
||||
if self._show_queue:
|
||||
yield QueuePanelWidget(id="queue-panel", classes="sidebar-panel")
|
||||
|
||||
yield ComposerWidget(id="composer")
|
||||
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Handle mount."""
|
||||
# Focus composer
|
||||
composer = self.query_one("#composer", ComposerWidget)
|
||||
composer.focus_input()
|
||||
|
||||
def on_composer_widget_submitted(self, event: ComposerWidget.Submitted) -> None:
|
||||
"""Handle message submission."""
|
||||
if event.is_command:
|
||||
# Parse command
|
||||
parts = event.content[1:].split(maxsplit=1)
|
||||
command = parts[0] if parts else ""
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
self.post_message(self.CommandExecuted(command, args))
|
||||
else:
|
||||
self.post_message(self.MessageSubmitted(event.content))
|
||||
|
||||
def action_quit(self) -> None:
|
||||
"""Quit the application."""
|
||||
self.app.exit()
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
"""Cancel current operation."""
|
||||
self.post_message(self.CancelRequested())
|
||||
|
||||
def action_clear(self) -> None:
|
||||
"""Clear screen."""
|
||||
pass
|
||||
|
||||
def action_help(self) -> None:
|
||||
"""Show help."""
|
||||
self.app.push_screen("help")
|
||||
|
||||
def action_toggle_queue(self) -> None:
|
||||
"""Toggle queue panel visibility."""
|
||||
try:
|
||||
queue_panel = self.query_one("#queue-panel", QueuePanelWidget)
|
||||
queue_panel.display = not queue_panel.display
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def action_settings(self) -> None:
|
||||
"""Show settings."""
|
||||
self.app.push_screen("settings")
|
||||
|
||||
def action_clear_chat(self) -> None:
|
||||
"""Clear chat history."""
|
||||
chat = self.query_one("#chat-widget", ChatWidget)
|
||||
self.app.call_later(chat.clear)
|
||||
|
||||
def action_cancel_input(self) -> None:
|
||||
"""Cancel current input."""
|
||||
composer = self.query_one("#composer", ComposerWidget)
|
||||
composer.action_cancel()
|
||||
|
||||
# Public methods for updating UI
|
||||
|
||||
async def add_user_message(self, content: str) -> None:
|
||||
"""Add a user message to chat."""
|
||||
chat = self.query_one("#chat-widget", ChatWidget)
|
||||
await chat.add_message(ChatMessage(
|
||||
role="user",
|
||||
content=content,
|
||||
))
|
||||
|
||||
async def add_assistant_message(
|
||||
self,
|
||||
content: str,
|
||||
run_id: Optional[str] = None,
|
||||
agent_name: Optional[str] = None,
|
||||
streaming: bool = False,
|
||||
) -> None:
|
||||
"""Add an assistant message to chat."""
|
||||
chat = self.query_one("#chat-widget", ChatWidget)
|
||||
await chat.add_message(ChatMessage(
|
||||
role="assistant",
|
||||
content=content,
|
||||
run_id=run_id,
|
||||
agent_name=agent_name,
|
||||
is_streaming=streaming,
|
||||
))
|
||||
|
||||
async def update_streaming(self, run_id: str, content: str) -> None:
|
||||
"""Update streaming message."""
|
||||
chat = self.query_one("#chat-widget", ChatWidget)
|
||||
await chat.update_streaming(run_id, content)
|
||||
|
||||
async def complete_streaming(self, run_id: str, content: str) -> None:
|
||||
"""Complete streaming message."""
|
||||
chat = self.query_one("#chat-widget", ChatWidget)
|
||||
await chat.complete_streaming(run_id, content)
|
||||
|
||||
def update_status(self, info: StatusInfo) -> None:
|
||||
"""Update status bar."""
|
||||
status = self.query_one("#status-bar", StatusWidget)
|
||||
status.update_info(info)
|
||||
|
||||
def set_processing(self, processing: bool) -> None:
|
||||
"""Set processing state."""
|
||||
composer = self.query_one("#composer", ComposerWidget)
|
||||
composer.set_processing(processing)
|
||||
|
||||
status = self.query_one("#status-bar", StatusWidget)
|
||||
status.is_processing = processing
|
||||
|
||||
else:
|
||||
class MainScreen:
|
||||
"""Placeholder when Textual is not available."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(
|
||||
"Textual is required for TUI. Install with: pip install praisonai[tui]"
|
||||
)
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Queue Screen for PraisonAI TUI.
|
||||
|
||||
Queue management interface.
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
|
||||
try:
|
||||
from textual.screen import Screen
|
||||
from textual.containers import Vertical, Horizontal
|
||||
from textual.widgets import Static, Footer, DataTable, Button
|
||||
from textual.binding import Binding
|
||||
from textual.message import Message
|
||||
from rich.text import Text
|
||||
TEXTUAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
TEXTUAL_AVAILABLE = False
|
||||
Screen = object
|
||||
Message = object
|
||||
|
||||
|
||||
if TEXTUAL_AVAILABLE:
|
||||
class QueueScreen(Screen):
|
||||
"""
|
||||
Queue management screen.
|
||||
|
||||
Displays full queue with management options.
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "back", "Back", show=True),
|
||||
Binding("c", "cancel", "Cancel", show=True),
|
||||
Binding("r", "retry", "Retry", show=True),
|
||||
Binding("x", "clear", "Clear Queue", show=True),
|
||||
Binding("enter", "select", "Select", show=False),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
QueueScreen {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
QueueScreen #queue-header {
|
||||
height: 3;
|
||||
background: $primary;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
QueueScreen DataTable {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
QueueScreen #queue-actions {
|
||||
height: 3;
|
||||
padding: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
class RunCancelled(Message):
|
||||
def __init__(self, run_id: str):
|
||||
self.run_id = run_id
|
||||
super().__init__()
|
||||
|
||||
class RunRetried(Message):
|
||||
def __init__(self, run_id: str):
|
||||
self.run_id = run_id
|
||||
super().__init__()
|
||||
|
||||
class QueueCleared(Message):
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
classes: Optional[str] = None,
|
||||
):
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self._selected_run_id: Optional[str] = None
|
||||
|
||||
def compose(self):
|
||||
"""Compose the screen."""
|
||||
yield Static("Queue Management", id="queue-header")
|
||||
yield DataTable(id="queue-table")
|
||||
with Horizontal(id="queue-actions"):
|
||||
yield Button("Cancel [C]", id="btn-cancel", variant="error")
|
||||
yield Button("Retry [R]", id="btn-retry", variant="warning")
|
||||
yield Button("Clear [X]", id="btn-clear", variant="default")
|
||||
yield Button("Back [Esc]", id="btn-back", variant="primary")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Handle mount."""
|
||||
table = self.query_one("#queue-table", DataTable)
|
||||
table.add_columns(
|
||||
"ID", "Agent", "Input", "State", "Priority",
|
||||
"Wait Time", "Duration"
|
||||
)
|
||||
table.cursor_type = "row"
|
||||
|
||||
def update_runs(self, runs: List[dict]) -> None:
|
||||
"""Update the runs display."""
|
||||
table = self.query_one("#queue-table", DataTable)
|
||||
table.clear()
|
||||
|
||||
for run in runs:
|
||||
state = run.get("state", "unknown")
|
||||
state_style = {
|
||||
"queued": "yellow",
|
||||
"running": "green",
|
||||
"succeeded": "cyan",
|
||||
"failed": "red",
|
||||
"cancelled": "dim",
|
||||
}.get(state, "")
|
||||
|
||||
state_text = Text(state, style=state_style)
|
||||
|
||||
input_preview = run.get("input", "")[:40]
|
||||
if len(run.get("input", "")) > 40:
|
||||
input_preview += "..."
|
||||
|
||||
table.add_row(
|
||||
run.get("run_id", "")[:8],
|
||||
run.get("agent_name", ""),
|
||||
input_preview,
|
||||
state_text,
|
||||
run.get("priority", "normal"),
|
||||
run.get("wait_time", "-"),
|
||||
run.get("duration", "-"),
|
||||
key=run.get("run_id"),
|
||||
)
|
||||
|
||||
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||
"""Handle row selection."""
|
||||
if event.row_key:
|
||||
self._selected_run_id = str(event.row_key.value)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button press."""
|
||||
if event.button.id == "btn-cancel":
|
||||
self.action_cancel()
|
||||
elif event.button.id == "btn-retry":
|
||||
self.action_retry()
|
||||
elif event.button.id == "btn-clear":
|
||||
self.action_clear()
|
||||
elif event.button.id == "btn-back":
|
||||
self.action_back()
|
||||
|
||||
def action_back(self) -> None:
|
||||
"""Go back to main screen."""
|
||||
self.app.pop_screen()
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
"""Cancel selected run."""
|
||||
if self._selected_run_id:
|
||||
self.post_message(self.RunCancelled(self._selected_run_id))
|
||||
|
||||
def action_retry(self) -> None:
|
||||
"""Retry selected run."""
|
||||
if self._selected_run_id:
|
||||
self.post_message(self.RunRetried(self._selected_run_id))
|
||||
|
||||
def action_clear(self) -> None:
|
||||
"""Clear the queue."""
|
||||
self.post_message(self.QueueCleared())
|
||||
|
||||
else:
|
||||
class QueueScreen:
|
||||
"""Placeholder when Textual is not available."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(
|
||||
"Textual is required for TUI. Install with: pip install praisonai[tui]"
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Session Screen for PraisonAI TUI.
|
||||
|
||||
Session browser and management.
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
|
||||
try:
|
||||
from textual.screen import Screen
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Static, Footer, DataTable, Button
|
||||
from textual.binding import Binding
|
||||
from textual.message import Message
|
||||
from rich.text import Text
|
||||
TEXTUAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
TEXTUAL_AVAILABLE = False
|
||||
Screen = object
|
||||
Message = object
|
||||
|
||||
|
||||
if TEXTUAL_AVAILABLE:
|
||||
class SessionScreen(Screen):
|
||||
"""
|
||||
Session browser screen.
|
||||
|
||||
Displays recent sessions and allows resuming.
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "back", "Back", show=True),
|
||||
Binding("enter", "resume", "Resume", show=True),
|
||||
Binding("d", "delete", "Delete", show=True),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
SessionScreen {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
SessionScreen #session-header {
|
||||
height: 3;
|
||||
background: $primary;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
SessionScreen DataTable {
|
||||
height: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
class SessionSelected(Message):
|
||||
def __init__(self, session_id: str):
|
||||
self.session_id = session_id
|
||||
super().__init__()
|
||||
|
||||
class SessionDeleted(Message):
|
||||
def __init__(self, session_id: str):
|
||||
self.session_id = session_id
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
classes: Optional[str] = None,
|
||||
):
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self._selected_session_id: Optional[str] = None
|
||||
|
||||
def compose(self):
|
||||
"""Compose the screen."""
|
||||
yield Static("Sessions", id="session-header")
|
||||
yield DataTable(id="session-table")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Handle mount."""
|
||||
table = self.query_one("#session-table", DataTable)
|
||||
table.add_columns("ID", "Created", "Updated", "Runs")
|
||||
table.cursor_type = "row"
|
||||
|
||||
def update_sessions(self, sessions: List[dict]) -> None:
|
||||
"""Update the sessions display."""
|
||||
table = self.query_one("#session-table", DataTable)
|
||||
table.clear()
|
||||
|
||||
for session in sessions:
|
||||
table.add_row(
|
||||
session.get("session_id", "")[:8],
|
||||
session.get("created_at", "-"),
|
||||
session.get("updated_at", "-"),
|
||||
str(session.get("run_count", 0)),
|
||||
key=session.get("session_id"),
|
||||
)
|
||||
|
||||
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||
"""Handle row selection."""
|
||||
if event.row_key:
|
||||
self._selected_session_id = str(event.row_key.value)
|
||||
|
||||
def action_back(self) -> None:
|
||||
"""Go back."""
|
||||
self.app.pop_screen()
|
||||
|
||||
def action_resume(self) -> None:
|
||||
"""Resume selected session."""
|
||||
if self._selected_session_id:
|
||||
self.post_message(self.SessionSelected(self._selected_session_id))
|
||||
self.app.pop_screen()
|
||||
|
||||
def action_delete(self) -> None:
|
||||
"""Delete selected session."""
|
||||
if self._selected_session_id:
|
||||
self.post_message(self.SessionDeleted(self._selected_session_id))
|
||||
|
||||
else:
|
||||
class SessionScreen:
|
||||
"""Placeholder when Textual is not available."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(
|
||||
"Textual is required for TUI. Install with: pip install praisonai[tui]"
|
||||
)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Settings Screen for PraisonAI TUI.
|
||||
|
||||
Configuration interface.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
from textual.screen import Screen
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Static, Footer, Input, Select, Switch, Button
|
||||
from textual.binding import Binding
|
||||
from textual.message import Message
|
||||
TEXTUAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
TEXTUAL_AVAILABLE = False
|
||||
Screen = object
|
||||
Message = object
|
||||
|
||||
|
||||
if TEXTUAL_AVAILABLE:
|
||||
class SettingsScreen(Screen):
|
||||
"""
|
||||
Settings screen.
|
||||
|
||||
Allows configuration of:
|
||||
- Model selection
|
||||
- Queue settings
|
||||
- Display preferences
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "back", "Back", show=True),
|
||||
Binding("s", "save", "Save", show=True),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
SettingsScreen {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
SettingsScreen #settings-header {
|
||||
height: 3;
|
||||
background: $primary;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
SettingsScreen .setting-row {
|
||||
height: 3;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
SettingsScreen .setting-label {
|
||||
width: 20;
|
||||
}
|
||||
|
||||
SettingsScreen Input {
|
||||
width: 40;
|
||||
}
|
||||
"""
|
||||
|
||||
class SettingsSaved(Message):
|
||||
def __init__(self, settings: dict):
|
||||
self.settings = settings
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
current_settings: Optional[dict] = None,
|
||||
name: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
classes: Optional[str] = None,
|
||||
):
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self._settings = current_settings or {}
|
||||
|
||||
def compose(self):
|
||||
"""Compose the screen."""
|
||||
yield Static("Settings", id="settings-header")
|
||||
|
||||
with Vertical(id="settings-form"):
|
||||
# Model setting
|
||||
yield Static("Model:", classes="setting-label")
|
||||
yield Input(
|
||||
value=self._settings.get("model", "gpt-4o-mini"),
|
||||
id="setting-model",
|
||||
placeholder="Model name"
|
||||
)
|
||||
|
||||
# Max concurrent runs
|
||||
yield Static("Max Concurrent:", classes="setting-label")
|
||||
yield Input(
|
||||
value=str(self._settings.get("max_concurrent", 4)),
|
||||
id="setting-max-concurrent",
|
||||
placeholder="4"
|
||||
)
|
||||
|
||||
# Auto-save interval
|
||||
yield Static("Autosave (sec):", classes="setting-label")
|
||||
yield Input(
|
||||
value=str(self._settings.get("autosave_interval", 30)),
|
||||
id="setting-autosave",
|
||||
placeholder="30"
|
||||
)
|
||||
|
||||
# Buttons
|
||||
yield Button("Save", id="btn-save", variant="primary")
|
||||
yield Button("Cancel", id="btn-cancel", variant="default")
|
||||
|
||||
yield Footer()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button press."""
|
||||
if event.button.id == "btn-save":
|
||||
self.action_save()
|
||||
elif event.button.id == "btn-cancel":
|
||||
self.action_back()
|
||||
|
||||
def action_back(self) -> None:
|
||||
"""Go back."""
|
||||
self.app.pop_screen()
|
||||
|
||||
def action_save(self) -> None:
|
||||
"""Save settings."""
|
||||
try:
|
||||
model_input = self.query_one("#setting-model", Input)
|
||||
max_concurrent_input = self.query_one("#setting-max-concurrent", Input)
|
||||
autosave_input = self.query_one("#setting-autosave", Input)
|
||||
|
||||
settings = {
|
||||
"model": model_input.value,
|
||||
"max_concurrent": int(max_concurrent_input.value or "4"),
|
||||
"autosave_interval": int(autosave_input.value or "30"),
|
||||
}
|
||||
|
||||
self.post_message(self.SettingsSaved(settings))
|
||||
self.app.pop_screen()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
else:
|
||||
class SettingsScreen:
|
||||
"""Placeholder when Textual is not available."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(
|
||||
"Textual is required for TUI. Install with: pip install praisonai[tui]"
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
TUI Widgets for PraisonAI.
|
||||
|
||||
Reusable UI components for the TUI application.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .chat import ChatWidget
|
||||
from .composer import ComposerWidget
|
||||
from .status import StatusWidget
|
||||
from .queue_panel import QueuePanelWidget
|
||||
from .tool_panel import ToolPanelWidget
|
||||
|
||||
_lazy_cache = {}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load widgets."""
|
||||
global _lazy_cache
|
||||
|
||||
if name in _lazy_cache:
|
||||
return _lazy_cache[name]
|
||||
|
||||
if name == "ChatWidget":
|
||||
from .chat import ChatWidget
|
||||
_lazy_cache[name] = ChatWidget
|
||||
return ChatWidget
|
||||
elif name == "ComposerWidget":
|
||||
from .composer import ComposerWidget
|
||||
_lazy_cache[name] = ComposerWidget
|
||||
return ComposerWidget
|
||||
elif name == "StatusWidget":
|
||||
from .status import StatusWidget
|
||||
_lazy_cache[name] = StatusWidget
|
||||
return StatusWidget
|
||||
elif name == "QueuePanelWidget":
|
||||
from .queue_panel import QueuePanelWidget
|
||||
_lazy_cache[name] = QueuePanelWidget
|
||||
return QueuePanelWidget
|
||||
elif name == "ToolPanelWidget":
|
||||
from .tool_panel import ToolPanelWidget
|
||||
_lazy_cache[name] = ToolPanelWidget
|
||||
return ToolPanelWidget
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ChatWidget",
|
||||
"ComposerWidget",
|
||||
"StatusWidget",
|
||||
"QueuePanelWidget",
|
||||
"ToolPanelWidget",
|
||||
]
|
||||
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Chat Widget for PraisonAI TUI.
|
||||
|
||||
Displays chat history with streaming support.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
import time
|
||||
|
||||
try:
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static, RichLog
|
||||
from textual.containers import ScrollableContainer, Vertical
|
||||
from textual.reactive import reactive
|
||||
from textual.message import Message
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
TEXTUAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
TEXTUAL_AVAILABLE = False
|
||||
Widget = object
|
||||
Message = object
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatMessage:
|
||||
"""A single chat message."""
|
||||
role: str # "user", "assistant", "system", "tool"
|
||||
content: str
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
run_id: Optional[str] = None
|
||||
agent_name: Optional[str] = None
|
||||
is_streaming: bool = False
|
||||
|
||||
@property
|
||||
def display_role(self) -> str:
|
||||
"""Get display name for role."""
|
||||
if self.role == "user":
|
||||
return "You"
|
||||
elif self.role == "assistant":
|
||||
return self.agent_name or "Assistant"
|
||||
elif self.role == "system":
|
||||
return "System"
|
||||
elif self.role == "tool":
|
||||
return "Tool"
|
||||
return self.role.title()
|
||||
|
||||
|
||||
if TEXTUAL_AVAILABLE:
|
||||
class ChatWidget(ScrollableContainer):
|
||||
"""
|
||||
Widget for displaying chat history.
|
||||
|
||||
Features:
|
||||
- Streaming message updates
|
||||
- Markdown rendering
|
||||
- Auto-scroll to bottom
|
||||
- Message history management
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ChatWidget {
|
||||
height: 100%;
|
||||
border: solid $primary;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
ChatWidget .message-user {
|
||||
background: $primary-darken-2;
|
||||
margin: 1 0;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
ChatWidget .message-assistant {
|
||||
background: $surface-darken-1;
|
||||
margin: 1 0;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
ChatWidget .message-system {
|
||||
background: $warning-darken-3;
|
||||
margin: 1 0;
|
||||
padding: 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
ChatWidget .message-streaming {
|
||||
border: dashed $accent;
|
||||
}
|
||||
"""
|
||||
|
||||
class MessageAdded(Message):
|
||||
"""Event when a message is added."""
|
||||
def __init__(self, message: ChatMessage):
|
||||
self.message = message
|
||||
super().__init__()
|
||||
|
||||
class StreamingUpdate(Message):
|
||||
"""Event when streaming content updates."""
|
||||
def __init__(self, run_id: str, content: str):
|
||||
self.run_id = run_id
|
||||
self.content = content
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_messages: int = 1000,
|
||||
name: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
classes: Optional[str] = None,
|
||||
):
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self._messages: List[ChatMessage] = []
|
||||
self._max_messages = max_messages
|
||||
self._streaming_widgets: dict = {}
|
||||
|
||||
def compose(self):
|
||||
"""Compose the widget."""
|
||||
yield Vertical(id="chat-messages")
|
||||
|
||||
async def add_message(self, message: ChatMessage) -> None:
|
||||
"""Add a message to the chat."""
|
||||
self._messages.append(message)
|
||||
|
||||
# Trim old messages if needed
|
||||
if len(self._messages) > self._max_messages:
|
||||
self._messages = self._messages[-self._max_messages:]
|
||||
|
||||
# Create message widget
|
||||
await self._render_message(message)
|
||||
|
||||
# Auto-scroll to bottom
|
||||
self.scroll_end(animate=False)
|
||||
|
||||
self.post_message(self.MessageAdded(message))
|
||||
|
||||
async def _render_message(self, message: ChatMessage) -> None:
|
||||
"""Render a single message."""
|
||||
container = self.query_one("#chat-messages", Vertical)
|
||||
|
||||
# Create role label
|
||||
role_style = {
|
||||
"user": "bold cyan",
|
||||
"assistant": "bold green",
|
||||
"system": "bold yellow",
|
||||
"tool": "bold magenta",
|
||||
}.get(message.role, "bold")
|
||||
|
||||
role_text = Text(f"{message.display_role}:", style=role_style)
|
||||
|
||||
# Create content
|
||||
try:
|
||||
content = Markdown(message.content)
|
||||
except Exception:
|
||||
content = Text(message.content)
|
||||
|
||||
# Create panel
|
||||
css_class = f"message-{message.role}"
|
||||
if message.is_streaming:
|
||||
css_class += " message-streaming"
|
||||
|
||||
widget_id = f"msg-{message.run_id or id(message)}"
|
||||
|
||||
panel = Static(
|
||||
Panel(content, title=str(role_text), border_style=role_style),
|
||||
id=widget_id,
|
||||
classes=css_class,
|
||||
)
|
||||
|
||||
await container.mount(panel)
|
||||
|
||||
if message.is_streaming:
|
||||
self._streaming_widgets[message.run_id] = widget_id
|
||||
|
||||
async def update_streaming(self, run_id: str, content: str) -> None:
|
||||
"""Update a streaming message."""
|
||||
if run_id not in self._streaming_widgets:
|
||||
return
|
||||
|
||||
widget_id = self._streaming_widgets[run_id]
|
||||
|
||||
try:
|
||||
widget = self.query_one(f"#{widget_id}", Static)
|
||||
|
||||
# Find the message
|
||||
for msg in self._messages:
|
||||
if msg.run_id == run_id:
|
||||
msg.content = content
|
||||
break
|
||||
|
||||
# Update content
|
||||
try:
|
||||
rendered = Markdown(content + " ▌")
|
||||
except Exception:
|
||||
rendered = Text(content + " ▌")
|
||||
|
||||
widget.update(Panel(rendered, title="Assistant", border_style="bold green"))
|
||||
|
||||
# Auto-scroll
|
||||
self.scroll_end(animate=False)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def complete_streaming(self, run_id: str, final_content: str) -> None:
|
||||
"""Complete a streaming message."""
|
||||
if run_id not in self._streaming_widgets:
|
||||
return
|
||||
|
||||
widget_id = self._streaming_widgets.pop(run_id)
|
||||
|
||||
try:
|
||||
widget = self.query_one(f"#{widget_id}", Static)
|
||||
|
||||
# Update message
|
||||
for msg in self._messages:
|
||||
if msg.run_id == run_id:
|
||||
msg.content = final_content
|
||||
msg.is_streaming = False
|
||||
break
|
||||
|
||||
# Update content without cursor
|
||||
try:
|
||||
rendered = Markdown(final_content)
|
||||
except Exception:
|
||||
rendered = Text(final_content)
|
||||
|
||||
widget.update(Panel(rendered, title="Assistant", border_style="bold green"))
|
||||
widget.remove_class("message-streaming")
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def clear(self) -> None:
|
||||
"""Clear all messages."""
|
||||
self._messages.clear()
|
||||
self._streaming_widgets.clear()
|
||||
|
||||
container = self.query_one("#chat-messages", Vertical)
|
||||
await container.remove_children()
|
||||
|
||||
@property
|
||||
def messages(self) -> List[ChatMessage]:
|
||||
"""Get all messages."""
|
||||
return self._messages.copy()
|
||||
|
||||
@property
|
||||
def message_count(self) -> int:
|
||||
"""Get message count."""
|
||||
return len(self._messages)
|
||||
|
||||
else:
|
||||
class ChatWidget:
|
||||
"""Placeholder when Textual is not available."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(
|
||||
"Textual is required for TUI. Install with: pip install praisonai[tui]"
|
||||
)
|
||||
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
Composer Widget for PraisonAI TUI.
|
||||
|
||||
Input area for composing messages with slash command support.
|
||||
"""
|
||||
|
||||
from typing import Callable, List, Optional
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import TextArea, Input
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.binding import Binding
|
||||
from textual import events
|
||||
from rich.text import Text
|
||||
TEXTUAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
TEXTUAL_AVAILABLE = False
|
||||
Widget = object
|
||||
Message = object
|
||||
|
||||
|
||||
if TEXTUAL_AVAILABLE:
|
||||
class ComposerWidget(Vertical):
|
||||
"""
|
||||
Input composer for chat messages.
|
||||
|
||||
Features:
|
||||
- Multi-line input
|
||||
- Slash command detection
|
||||
- @ mention support
|
||||
- History navigation
|
||||
- Send on Enter or Ctrl+Enter
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+enter", "submit", "Send", show=True),
|
||||
Binding("escape", "cancel", "Cancel", show=False),
|
||||
Binding("up", "history_prev", "Previous", show=False),
|
||||
Binding("down", "history_next", "Next", show=False),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ComposerWidget {
|
||||
height: auto;
|
||||
max-height: 10;
|
||||
border: solid $primary;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
ComposerWidget TextArea {
|
||||
height: auto;
|
||||
min-height: 3;
|
||||
max-height: 8;
|
||||
}
|
||||
|
||||
ComposerWidget .hint {
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
}
|
||||
"""
|
||||
|
||||
class Submitted(Message):
|
||||
"""Event when message is submitted."""
|
||||
def __init__(self, content: str, is_command: bool = False):
|
||||
self.content = content
|
||||
self.is_command = is_command
|
||||
super().__init__()
|
||||
|
||||
class CommandDetected(Message):
|
||||
"""Event when a slash command is detected."""
|
||||
def __init__(self, command: str, args: str):
|
||||
self.command = command
|
||||
self.args = args
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
placeholder: str = "Type your message... (Ctrl+Enter to send, /help for commands)",
|
||||
multiline: bool = True,
|
||||
name: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
classes: Optional[str] = None,
|
||||
):
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self._placeholder = placeholder
|
||||
self._multiline = multiline
|
||||
self._history: List[str] = []
|
||||
self._history_index = -1
|
||||
self._current_input = ""
|
||||
self._is_processing = False
|
||||
|
||||
def compose(self):
|
||||
"""Compose the widget."""
|
||||
yield TextArea(
|
||||
"",
|
||||
id="composer-input",
|
||||
language=None,
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Handle mount event."""
|
||||
text_area = self.query_one("#composer-input", TextArea)
|
||||
text_area.focus()
|
||||
|
||||
async def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||||
"""Handle text changes."""
|
||||
content = event.text_area.text
|
||||
|
||||
# Detect slash commands
|
||||
if content.startswith("/"):
|
||||
parts = content[1:].split(maxsplit=1)
|
||||
if parts:
|
||||
command = parts[0]
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
self.post_message(self.CommandDetected(command, args))
|
||||
|
||||
def on_key(self, event: events.Key) -> None:
|
||||
"""Handle key events."""
|
||||
# Submit on Enter (single line mode) or Ctrl+Enter
|
||||
if event.key == "enter" and not self._multiline:
|
||||
event.prevent_default()
|
||||
self.action_submit()
|
||||
elif event.key == "ctrl+enter":
|
||||
event.prevent_default()
|
||||
self.action_submit()
|
||||
|
||||
def action_submit(self) -> None:
|
||||
"""Submit the current input."""
|
||||
if self._is_processing:
|
||||
return
|
||||
|
||||
text_area = self.query_one("#composer-input", TextArea)
|
||||
content = text_area.text.strip()
|
||||
|
||||
if not content:
|
||||
return
|
||||
|
||||
# Add to history
|
||||
if content and (not self._history or self._history[-1] != content):
|
||||
self._history.append(content)
|
||||
self._history_index = -1
|
||||
|
||||
# Check if it's a command
|
||||
is_command = content.startswith("/")
|
||||
|
||||
# Clear input
|
||||
text_area.clear()
|
||||
|
||||
# Emit event
|
||||
self.post_message(self.Submitted(content, is_command))
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
"""Cancel current input."""
|
||||
text_area = self.query_one("#composer-input", TextArea)
|
||||
text_area.clear()
|
||||
self._history_index = -1
|
||||
|
||||
def action_history_prev(self) -> None:
|
||||
"""Navigate to previous history item."""
|
||||
if not self._history:
|
||||
return
|
||||
|
||||
text_area = self.query_one("#composer-input", TextArea)
|
||||
|
||||
if self._history_index == -1:
|
||||
self._current_input = text_area.text
|
||||
self._history_index = len(self._history) - 1
|
||||
elif self._history_index > 0:
|
||||
self._history_index -= 1
|
||||
|
||||
text_area.clear()
|
||||
text_area.insert(self._history[self._history_index])
|
||||
|
||||
def action_history_next(self) -> None:
|
||||
"""Navigate to next history item."""
|
||||
if self._history_index == -1:
|
||||
return
|
||||
|
||||
text_area = self.query_one("#composer-input", TextArea)
|
||||
|
||||
if self._history_index < len(self._history) - 1:
|
||||
self._history_index += 1
|
||||
text_area.clear()
|
||||
text_area.insert(self._history[self._history_index])
|
||||
else:
|
||||
self._history_index = -1
|
||||
text_area.clear()
|
||||
text_area.insert(self._current_input)
|
||||
|
||||
def set_processing(self, processing: bool) -> None:
|
||||
"""Set processing state."""
|
||||
self._is_processing = processing
|
||||
text_area = self.query_one("#composer-input", TextArea)
|
||||
text_area.disabled = processing
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Get current text."""
|
||||
text_area = self.query_one("#composer-input", TextArea)
|
||||
return text_area.text
|
||||
|
||||
@text.setter
|
||||
def text(self, value: str) -> None:
|
||||
"""Set current text."""
|
||||
text_area = self.query_one("#composer-input", TextArea)
|
||||
text_area.clear()
|
||||
text_area.insert(value)
|
||||
|
||||
def focus_input(self) -> None:
|
||||
"""Focus the input area."""
|
||||
text_area = self.query_one("#composer-input", TextArea)
|
||||
text_area.focus()
|
||||
|
||||
else:
|
||||
class ComposerWidget:
|
||||
"""Placeholder when Textual is not available."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(
|
||||
"Textual is required for TUI. Install with: pip install praisonai[tui]"
|
||||
)
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Queue Panel Widget for PraisonAI TUI.
|
||||
|
||||
Displays queue status and allows queue management.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
try:
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static, DataTable, Button
|
||||
from textual.containers import Vertical
|
||||
from textual.reactive import reactive
|
||||
from textual.message import Message
|
||||
from rich.text import Text
|
||||
from rich.panel import Panel
|
||||
TEXTUAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
TEXTUAL_AVAILABLE = False
|
||||
Widget = object
|
||||
Message = object
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueueItem:
|
||||
"""A queue item for display."""
|
||||
run_id: str
|
||||
agent_name: str
|
||||
input_preview: str
|
||||
state: str
|
||||
priority: str
|
||||
wait_time: str
|
||||
|
||||
|
||||
if TEXTUAL_AVAILABLE:
|
||||
class QueuePanelWidget(Vertical):
|
||||
"""
|
||||
Queue panel widget.
|
||||
|
||||
Displays:
|
||||
- Queue summary (queued/running counts)
|
||||
- List of queued runs
|
||||
- Cancel/retry buttons
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
QueuePanelWidget {
|
||||
height: 100%;
|
||||
border: solid $secondary;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
QueuePanelWidget .queue-header {
|
||||
height: 1;
|
||||
background: $secondary;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
QueuePanelWidget DataTable {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
QueuePanelWidget .queue-item-queued {
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
QueuePanelWidget .queue-item-running {
|
||||
color: $success;
|
||||
}
|
||||
|
||||
QueuePanelWidget .queue-item-failed {
|
||||
color: $error;
|
||||
}
|
||||
"""
|
||||
|
||||
class RunSelected(Message):
|
||||
"""Event when a run is selected."""
|
||||
def __init__(self, run_id: str):
|
||||
self.run_id = run_id
|
||||
super().__init__()
|
||||
|
||||
class CancelRequested(Message):
|
||||
"""Event when cancel is requested."""
|
||||
def __init__(self, run_id: str):
|
||||
self.run_id = run_id
|
||||
super().__init__()
|
||||
|
||||
class RetryRequested(Message):
|
||||
"""Event when retry is requested."""
|
||||
def __init__(self, run_id: str):
|
||||
self.run_id = run_id
|
||||
super().__init__()
|
||||
|
||||
queued_count: reactive[int] = reactive(0)
|
||||
running_count: reactive[int] = reactive(0)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
classes: Optional[str] = None,
|
||||
):
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self._items: List[QueueItem] = []
|
||||
self._selected_run_id: Optional[str] = None
|
||||
|
||||
def compose(self):
|
||||
"""Compose the widget."""
|
||||
yield Static("Queue", id="queue-title", classes="queue-header")
|
||||
yield DataTable(id="queue-table")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Handle mount."""
|
||||
table = self.query_one("#queue-table", DataTable)
|
||||
table.add_columns("ID", "Agent", "Input", "State", "Priority")
|
||||
table.cursor_type = "row"
|
||||
self._update_header()
|
||||
|
||||
def watch_queued_count(self, value: int) -> None:
|
||||
self._update_header()
|
||||
|
||||
def watch_running_count(self, value: int) -> None:
|
||||
self._update_header()
|
||||
|
||||
def _update_header(self) -> None:
|
||||
"""Update the header text."""
|
||||
try:
|
||||
header = self.query_one("#queue-title", Static)
|
||||
text = Text()
|
||||
text.append("Queue ", style="bold")
|
||||
text.append(f"⏳ {self.queued_count} queued", style="yellow")
|
||||
text.append(" │ ", style="dim")
|
||||
text.append(f"▶ {self.running_count} running", style="green")
|
||||
header.update(text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_items(self, items: List[QueueItem]) -> None:
|
||||
"""Update the queue items."""
|
||||
self._items = items
|
||||
|
||||
table = self.query_one("#queue-table", DataTable)
|
||||
table.clear()
|
||||
|
||||
for item in items:
|
||||
# Style based on state
|
||||
state_style = {
|
||||
"queued": "yellow",
|
||||
"running": "green",
|
||||
"failed": "red",
|
||||
"cancelled": "dim",
|
||||
"succeeded": "cyan",
|
||||
}.get(item.state.lower(), "")
|
||||
|
||||
state_text = Text(item.state, style=state_style)
|
||||
|
||||
# Truncate input preview
|
||||
input_preview = item.input_preview[:30]
|
||||
if len(item.input_preview) > 30:
|
||||
input_preview += "..."
|
||||
|
||||
table.add_row(
|
||||
item.run_id[:8],
|
||||
item.agent_name,
|
||||
input_preview,
|
||||
state_text,
|
||||
item.priority,
|
||||
key=item.run_id,
|
||||
)
|
||||
|
||||
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||
"""Handle row selection."""
|
||||
if event.row_key:
|
||||
self._selected_run_id = str(event.row_key.value)
|
||||
self.post_message(self.RunSelected(self._selected_run_id))
|
||||
|
||||
def cancel_selected(self) -> None:
|
||||
"""Cancel the selected run."""
|
||||
if self._selected_run_id:
|
||||
self.post_message(self.CancelRequested(self._selected_run_id))
|
||||
|
||||
def retry_selected(self) -> None:
|
||||
"""Retry the selected run."""
|
||||
if self._selected_run_id:
|
||||
self.post_message(self.RetryRequested(self._selected_run_id))
|
||||
|
||||
@property
|
||||
def selected_run_id(self) -> Optional[str]:
|
||||
"""Get the selected run ID."""
|
||||
return self._selected_run_id
|
||||
|
||||
else:
|
||||
class QueuePanelWidget:
|
||||
"""Placeholder when Textual is not available."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(
|
||||
"Textual is required for TUI. Install with: pip install praisonai[tui]"
|
||||
)
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Status Widget for PraisonAI TUI.
|
||||
|
||||
Displays status bar with session info, model, tokens, and cost.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
try:
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
from textual.containers import Horizontal
|
||||
from textual.reactive import reactive
|
||||
from rich.text import Text
|
||||
from rich.table import Table
|
||||
TEXTUAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
TEXTUAL_AVAILABLE = False
|
||||
Widget = object
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatusInfo:
|
||||
"""Status information."""
|
||||
session_id: str = ""
|
||||
model: str = "gpt-4o-mini"
|
||||
total_tokens: int = 0
|
||||
total_cost: float = 0.0
|
||||
queued_count: int = 0
|
||||
running_count: int = 0
|
||||
is_processing: bool = False
|
||||
status_message: str = ""
|
||||
|
||||
|
||||
if TEXTUAL_AVAILABLE:
|
||||
class StatusWidget(Static):
|
||||
"""
|
||||
Status bar widget.
|
||||
|
||||
Displays:
|
||||
- Session ID
|
||||
- Current model
|
||||
- Token usage
|
||||
- Cost
|
||||
- Queue status
|
||||
- Processing indicator
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
StatusWidget {
|
||||
height: 1;
|
||||
background: $primary;
|
||||
color: $text;
|
||||
padding: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
session_id: reactive[str] = reactive("")
|
||||
model: reactive[str] = reactive("gpt-4o-mini")
|
||||
total_tokens: reactive[int] = reactive(0)
|
||||
total_cost: reactive[float] = reactive(0.0)
|
||||
queued_count: reactive[int] = reactive(0)
|
||||
running_count: reactive[int] = reactive(0)
|
||||
is_processing: reactive[bool] = reactive(False)
|
||||
status_message: reactive[str] = reactive("")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
classes: Optional[str] = None,
|
||||
):
|
||||
super().__init__("", name=name, id=id, classes=classes)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Handle mount."""
|
||||
self._update_display()
|
||||
|
||||
def watch_session_id(self, value: str) -> None:
|
||||
self._update_display()
|
||||
|
||||
def watch_model(self, value: str) -> None:
|
||||
self._update_display()
|
||||
|
||||
def watch_total_tokens(self, value: int) -> None:
|
||||
self._update_display()
|
||||
|
||||
def watch_total_cost(self, value: float) -> None:
|
||||
self._update_display()
|
||||
|
||||
def watch_queued_count(self, value: int) -> None:
|
||||
self._update_display()
|
||||
|
||||
def watch_running_count(self, value: int) -> None:
|
||||
self._update_display()
|
||||
|
||||
def watch_is_processing(self, value: bool) -> None:
|
||||
self._update_display()
|
||||
|
||||
def watch_status_message(self, value: str) -> None:
|
||||
self._update_display()
|
||||
|
||||
def _update_display(self) -> None:
|
||||
"""Update the status display."""
|
||||
parts = []
|
||||
|
||||
# App name
|
||||
parts.append(("◉ PraisonAI", "bold cyan"))
|
||||
|
||||
# Session
|
||||
if self.session_id:
|
||||
parts.append((f"Session: {self.session_id[:8]}", ""))
|
||||
|
||||
# Model
|
||||
parts.append((f"Model: {self.model}", ""))
|
||||
|
||||
# Tokens
|
||||
if self.total_tokens > 0:
|
||||
parts.append((f"Tokens: {self.total_tokens:,}", ""))
|
||||
|
||||
# Cost
|
||||
if self.total_cost > 0:
|
||||
parts.append((f"${self.total_cost:.4f}", "green"))
|
||||
|
||||
# Queue status
|
||||
if self.queued_count > 0 or self.running_count > 0:
|
||||
queue_text = f"⏳ {self.queued_count} queued"
|
||||
if self.running_count > 0:
|
||||
queue_text += f" │ ▶ {self.running_count} running"
|
||||
parts.append((queue_text, "yellow"))
|
||||
|
||||
# Processing indicator
|
||||
if self.is_processing:
|
||||
parts.append(("⟳ Processing...", "bold magenta"))
|
||||
|
||||
# Status message
|
||||
if self.status_message:
|
||||
parts.append((self.status_message, "italic"))
|
||||
|
||||
# Build text
|
||||
text = Text()
|
||||
for i, (content, style) in enumerate(parts):
|
||||
if i > 0:
|
||||
text.append(" │ ", style="dim")
|
||||
text.append(content, style=style)
|
||||
|
||||
self.update(text)
|
||||
|
||||
def update_info(self, info: StatusInfo) -> None:
|
||||
"""Update all status info at once."""
|
||||
self.session_id = info.session_id
|
||||
self.model = info.model
|
||||
self.total_tokens = info.total_tokens
|
||||
self.total_cost = info.total_cost
|
||||
self.queued_count = info.queued_count
|
||||
self.running_count = info.running_count
|
||||
self.is_processing = info.is_processing
|
||||
self.status_message = info.status_message
|
||||
|
||||
else:
|
||||
class StatusWidget:
|
||||
"""Placeholder when Textual is not available."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(
|
||||
"Textual is required for TUI. Install with: pip install praisonai[tui]"
|
||||
)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
Tool Panel Widget for PraisonAI TUI.
|
||||
|
||||
Displays tool execution status and approval dialogs.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
import time
|
||||
|
||||
try:
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static, ListView, ListItem
|
||||
from textual.containers import Vertical
|
||||
from textual.reactive import reactive
|
||||
from textual.message import Message
|
||||
from rich.text import Text
|
||||
from rich.panel import Panel
|
||||
TEXTUAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
TEXTUAL_AVAILABLE = False
|
||||
Widget = object
|
||||
Message = object
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCall:
|
||||
"""A tool call for display."""
|
||||
call_id: str
|
||||
tool_name: str
|
||||
args_preview: str
|
||||
status: str # "pending", "running", "completed", "failed", "approved", "rejected"
|
||||
result_preview: Optional[str] = None
|
||||
started_at: float = field(default_factory=time.time)
|
||||
ended_at: Optional[float] = None
|
||||
requires_approval: bool = False
|
||||
|
||||
|
||||
if TEXTUAL_AVAILABLE:
|
||||
class ToolPanelWidget(Vertical):
|
||||
"""
|
||||
Tool panel widget.
|
||||
|
||||
Displays:
|
||||
- Recent tool calls
|
||||
- Tool execution status
|
||||
- Approval requests
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ToolPanelWidget {
|
||||
height: 100%;
|
||||
border: solid $accent;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
ToolPanelWidget .tool-header {
|
||||
height: 1;
|
||||
background: $accent;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
ToolPanelWidget .tool-pending {
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
ToolPanelWidget .tool-running {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
ToolPanelWidget .tool-completed {
|
||||
color: $success;
|
||||
}
|
||||
|
||||
ToolPanelWidget .tool-failed {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
ToolPanelWidget .tool-approval {
|
||||
background: $warning-darken-2;
|
||||
border: solid $warning;
|
||||
padding: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
class ApprovalResponse(Message):
|
||||
"""Event when user responds to approval request."""
|
||||
def __init__(self, call_id: str, approved: bool):
|
||||
self.call_id = call_id
|
||||
self.approved = approved
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_items: int = 50,
|
||||
name: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
classes: Optional[str] = None,
|
||||
):
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self._calls: List[ToolCall] = []
|
||||
self._max_items = max_items
|
||||
self._pending_approval: Optional[ToolCall] = None
|
||||
|
||||
def compose(self):
|
||||
"""Compose the widget."""
|
||||
yield Static("Tools", id="tool-title", classes="tool-header")
|
||||
yield Vertical(id="tool-list")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Handle mount."""
|
||||
self._update_display()
|
||||
|
||||
def add_call(self, call: ToolCall) -> None:
|
||||
"""Add a tool call."""
|
||||
self._calls.append(call)
|
||||
|
||||
# Trim old calls
|
||||
if len(self._calls) > self._max_items:
|
||||
self._calls = self._calls[-self._max_items:]
|
||||
|
||||
# Check for approval
|
||||
if call.requires_approval and call.status == "pending":
|
||||
self._pending_approval = call
|
||||
|
||||
self._update_display()
|
||||
|
||||
def update_call(self, call_id: str, status: str, result: Optional[str] = None) -> None:
|
||||
"""Update a tool call status."""
|
||||
for call in self._calls:
|
||||
if call.call_id == call_id:
|
||||
call.status = status
|
||||
if result:
|
||||
call.result_preview = result[:100]
|
||||
if status in ("completed", "failed"):
|
||||
call.ended_at = time.time()
|
||||
break
|
||||
|
||||
# Clear pending approval if this was it
|
||||
if self._pending_approval and self._pending_approval.call_id == call_id:
|
||||
self._pending_approval = None
|
||||
|
||||
self._update_display()
|
||||
|
||||
def _update_display(self) -> None:
|
||||
"""Update the display."""
|
||||
try:
|
||||
container = self.query_one("#tool-list", Vertical)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# Clear existing
|
||||
container.remove_children()
|
||||
|
||||
# Show pending approval first
|
||||
if self._pending_approval:
|
||||
approval_text = Text()
|
||||
approval_text.append("⚠ Approval Required\n", style="bold yellow")
|
||||
approval_text.append(f"Tool: {self._pending_approval.tool_name}\n")
|
||||
approval_text.append(f"Args: {self._pending_approval.args_preview}\n")
|
||||
approval_text.append("[Y] Approve [N] Reject", style="dim")
|
||||
|
||||
container.mount(Static(
|
||||
Panel(approval_text, border_style="yellow"),
|
||||
classes="tool-approval"
|
||||
))
|
||||
|
||||
# Show recent calls
|
||||
for call in reversed(self._calls[-10:]):
|
||||
if call == self._pending_approval:
|
||||
continue
|
||||
|
||||
# Status icon
|
||||
status_icons = {
|
||||
"pending": "⏳",
|
||||
"running": "⟳",
|
||||
"completed": "✓",
|
||||
"failed": "✗",
|
||||
"approved": "✓",
|
||||
"rejected": "✗",
|
||||
}
|
||||
icon = status_icons.get(call.status, "?")
|
||||
|
||||
# Status style
|
||||
status_styles = {
|
||||
"pending": "yellow",
|
||||
"running": "cyan",
|
||||
"completed": "green",
|
||||
"failed": "red",
|
||||
"approved": "green",
|
||||
"rejected": "red",
|
||||
}
|
||||
style = status_styles.get(call.status, "")
|
||||
|
||||
text = Text()
|
||||
text.append(f"{icon} ", style=style)
|
||||
text.append(call.tool_name, style="bold")
|
||||
|
||||
if call.result_preview:
|
||||
text.append(f" → {call.result_preview[:30]}...", style="dim")
|
||||
|
||||
container.mount(Static(text, classes=f"tool-{call.status}"))
|
||||
|
||||
def handle_approval_key(self, key: str) -> bool:
|
||||
"""
|
||||
Handle approval key press.
|
||||
|
||||
Returns True if handled.
|
||||
"""
|
||||
if not self._pending_approval:
|
||||
return False
|
||||
|
||||
if key.lower() == "y":
|
||||
self.post_message(self.ApprovalResponse(
|
||||
self._pending_approval.call_id,
|
||||
approved=True
|
||||
))
|
||||
self.update_call(self._pending_approval.call_id, "approved")
|
||||
return True
|
||||
elif key.lower() == "n":
|
||||
self.post_message(self.ApprovalResponse(
|
||||
self._pending_approval.call_id,
|
||||
approved=False
|
||||
))
|
||||
self.update_call(self._pending_approval.call_id, "rejected")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def has_pending_approval(self) -> bool:
|
||||
"""Check if there's a pending approval."""
|
||||
return self._pending_approval is not None
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all tool calls."""
|
||||
self._calls.clear()
|
||||
self._pending_approval = None
|
||||
self._update_display()
|
||||
|
||||
else:
|
||||
class ToolPanelWidget:
|
||||
"""Placeholder when Textual is not available."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(
|
||||
"Textual is required for TUI. Install with: pip install praisonai[tui]"
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Legacy compatibility adapter for PraisonAI CLI.
|
||||
|
||||
Provides backward compatibility with the original argparse-based CLI.
|
||||
Maps legacy usage patterns to Typer commands or existing feature handlers.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
# Legacy commands that should be routed to existing handlers
|
||||
LEGACY_COMMANDS = {
|
||||
'chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research',
|
||||
'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools',
|
||||
'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile',
|
||||
'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy',
|
||||
'templates', 'recipe', 'endpoints', 'audio', 'embed', 'images', 'moderate',
|
||||
'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants',
|
||||
'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos',
|
||||
'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api',
|
||||
'doctor', 'registry', 'package', 'install', 'uninstall', 'acp', 'debug',
|
||||
'lsp', 'diag', 'persistence',
|
||||
}
|
||||
|
||||
# Typer commands that have been implemented
|
||||
TYPER_COMMANDS = {
|
||||
'config', 'traces', 'env', 'session', 'completion', 'version',
|
||||
'debug', 'lsp', 'diag', 'doctor', 'acp', 'mcp', 'serve', 'schedule', 'run',
|
||||
'tui', 'queue', # TUI and queue management commands
|
||||
}
|
||||
|
||||
|
||||
def is_legacy_invocation(argv: List[str]) -> bool:
|
||||
"""
|
||||
Check if the command line represents a legacy invocation.
|
||||
|
||||
Legacy patterns:
|
||||
- praisonai "prompt" (direct prompt)
|
||||
- praisonai agents.yaml (file path)
|
||||
- praisonai --legacy-flag value (legacy-only flags)
|
||||
- praisonai <legacy_command> (commands not yet in Typer)
|
||||
"""
|
||||
if not argv:
|
||||
return False
|
||||
|
||||
# Global Typer options that should NOT trigger legacy mode
|
||||
typer_global_options = {
|
||||
'--json', '--output-format', '-o', '--no-color', '--quiet', '-q',
|
||||
'--verbose', '-v', '--screen-reader', '--help', '-h', '--version', '-V',
|
||||
}
|
||||
|
||||
# Filter out global options to find the actual command
|
||||
filtered_argv = []
|
||||
skip_next = False
|
||||
for i, arg in enumerate(argv):
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if arg in typer_global_options:
|
||||
# Skip options that take a value
|
||||
if arg in ('--output-format', '-o'):
|
||||
skip_next = True
|
||||
continue
|
||||
filtered_argv.append(arg)
|
||||
|
||||
first_arg = filtered_argv[0] if filtered_argv else ""
|
||||
|
||||
# Check for legacy-only flags (not in Typer)
|
||||
legacy_flags = [
|
||||
'--framework', '--ui', '--auto', '--init', '--deploy', '--schedule',
|
||||
'--provider', '--model', '--llm', '--hf', '--ollama', '--dataset',
|
||||
'--realtime', '--call', '--public', '--merge', '--claudecode',
|
||||
'--file', '--url', '--goal', '--auto-analyze', '--research',
|
||||
'--query-rewrite', '--expand-prompt', '--tools', '--no-tools',
|
||||
'--save', '--web-search', '--web-fetch', '--prompt-caching',
|
||||
'--planning', '--memory', '--user-id', '--auto-save', '--history',
|
||||
'--workflow', '--guardrail', '--metrics', '--image', '--telemetry',
|
||||
'--mcp', '--fast-context', '--handoff', '--auto-memory', '--todo',
|
||||
'--router', '--flow-display', '--n8n', '--serve', '--port', '--host',
|
||||
'--interactive', '--chat-mode', '-p', '--autonomy', '--trust',
|
||||
'--sandbox', '--external-agent', '--compare', '--interval', '--timeout',
|
||||
'--max-cost', '--rpm', '--tpm', '--temperature',
|
||||
]
|
||||
|
||||
for arg in argv:
|
||||
if arg in legacy_flags:
|
||||
return True
|
||||
|
||||
# Check if first arg is a legacy command not in Typer
|
||||
if first_arg in LEGACY_COMMANDS and first_arg not in TYPER_COMMANDS:
|
||||
return True
|
||||
|
||||
# Check if first arg looks like a prompt (not a command, not a flag)
|
||||
if first_arg and not first_arg.startswith('-'):
|
||||
# If it's not a known Typer command, treat as legacy
|
||||
if first_arg not in TYPER_COMMANDS:
|
||||
# Could be a file path or prompt
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def route_to_legacy(argv: List[str]) -> int:
|
||||
"""
|
||||
Route to the legacy argparse CLI.
|
||||
|
||||
Returns:
|
||||
Exit code from legacy CLI
|
||||
"""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
|
||||
# Restore sys.argv for argparse
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
result = praison.main()
|
||||
return 0 if result is None else (1 if result is False else 0)
|
||||
except SystemExit as e:
|
||||
return e.code if isinstance(e.code, int) else 0
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
def translate_to_typer(argv: List[str]) -> Tuple[List[str], bool]:
|
||||
"""
|
||||
Attempt to translate legacy argv to Typer format.
|
||||
|
||||
Returns:
|
||||
Tuple of (translated_argv, was_translated)
|
||||
"""
|
||||
if not argv:
|
||||
return argv, False
|
||||
|
||||
first_arg = argv[0]
|
||||
|
||||
# Direct mapping for commands that exist in both
|
||||
if first_arg in TYPER_COMMANDS:
|
||||
return argv, False # Already Typer-compatible
|
||||
|
||||
# Translate common patterns
|
||||
translations = {
|
||||
# Legacy flag patterns
|
||||
'--version': ['version', 'show'],
|
||||
'-V': ['version', 'show'],
|
||||
}
|
||||
|
||||
if first_arg in translations:
|
||||
return translations[first_arg] + argv[1:], True
|
||||
|
||||
return argv, False
|
||||
|
||||
|
||||
def main_with_legacy_support():
|
||||
"""
|
||||
Main entry point with legacy support.
|
||||
|
||||
Routes to Typer CLI for new commands, legacy argparse CLI for old commands.
|
||||
"""
|
||||
argv = sys.argv[1:]
|
||||
|
||||
# Handle empty args - show help via Typer
|
||||
if not argv:
|
||||
try:
|
||||
from praisonai.cli.app import app
|
||||
app()
|
||||
return
|
||||
except SystemExit:
|
||||
return
|
||||
|
||||
# Check for version flags first (handle specially)
|
||||
if '--version' in argv or '-V' in argv:
|
||||
from praisonai.version import __version__
|
||||
print(f"PraisonAI version {__version__}")
|
||||
return
|
||||
|
||||
# IMPORTANT: Check for legacy invocation FIRST before trying Typer
|
||||
# This ensures legacy commands like 'chat' and flags like '--framework' work
|
||||
if is_legacy_invocation(argv):
|
||||
exit_code = route_to_legacy(argv)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# Global Typer options that should NOT trigger legacy mode
|
||||
typer_global_options = {
|
||||
'--json', '--output-format', '-o', '--no-color', '--quiet', '-q',
|
||||
'--verbose', '-v', '--screen-reader', '--help', '-h',
|
||||
}
|
||||
|
||||
# Find the actual command (skip global options)
|
||||
actual_command = None
|
||||
skip_next = False
|
||||
for arg in argv:
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if arg in typer_global_options:
|
||||
if arg in ('--output-format', '-o'):
|
||||
skip_next = True
|
||||
continue
|
||||
if not arg.startswith('-'):
|
||||
actual_command = arg
|
||||
break
|
||||
|
||||
# Route to Typer for known Typer commands
|
||||
if actual_command in TYPER_COMMANDS:
|
||||
try:
|
||||
from praisonai.cli.app import app
|
||||
sys.argv = ['praisonai'] + argv
|
||||
app()
|
||||
return
|
||||
except SystemExit as e:
|
||||
sys.exit(e.code if isinstance(e.code, int) else 0)
|
||||
|
||||
# Check for help flags - route to Typer for help
|
||||
if '--help' in argv or '-h' in argv:
|
||||
try:
|
||||
from praisonai.cli.app import app
|
||||
sys.argv = ['praisonai'] + argv
|
||||
app()
|
||||
return
|
||||
except SystemExit:
|
||||
return
|
||||
|
||||
# Default: try Typer, fall back to legacy on error
|
||||
try:
|
||||
from praisonai.cli.app import app
|
||||
sys.argv = ['praisonai'] + argv
|
||||
app()
|
||||
except SystemExit as e:
|
||||
sys.exit(e.code if isinstance(e.code, int) else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main_with_legacy_support()
|
||||
@@ -254,6 +254,11 @@ class PraisonAI:
|
||||
invocation_cmd = "praisonai"
|
||||
version_string = f"PraisonAI version {__version__}"
|
||||
|
||||
# Handle -p/--prompt flag - treat as direct prompt
|
||||
if getattr(args, 'prompt_flag', None):
|
||||
args.direct_prompt = args.prompt_flag
|
||||
args.command = None
|
||||
|
||||
# Handle --interactive flag - start interactive TUI mode
|
||||
if getattr(args, 'interactive', False):
|
||||
self._start_interactive_mode(args)
|
||||
@@ -261,7 +266,7 @@ class PraisonAI:
|
||||
|
||||
# Handle --chat-mode flag - run single prompt in interactive style (non-interactive)
|
||||
if getattr(args, 'chat_mode', False):
|
||||
prompt = args.command or getattr(args, 'direct_prompt', None)
|
||||
prompt = args.command or getattr(args, 'direct_prompt', None) or getattr(args, 'prompt_flag', None)
|
||||
if prompt:
|
||||
return self._run_chat_mode(prompt, args)
|
||||
|
||||
@@ -698,7 +703,7 @@ class PraisonAI:
|
||||
return default_args
|
||||
|
||||
# Define special commands
|
||||
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor', 'registry', 'package', 'install', 'uninstall']
|
||||
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor', 'registry', 'package', 'install', 'uninstall', 'acp', 'debug', 'lsp', 'diag']
|
||||
|
||||
parser = argparse.ArgumentParser(prog="praisonai", description="praisonAI command-line interface")
|
||||
parser.add_argument("--framework", choices=["crewai", "autogen", "praisonai"], help="Specify the framework")
|
||||
@@ -818,6 +823,9 @@ class PraisonAI:
|
||||
parser.add_argument("--interactive", "-i", action="store_true", help="Start interactive terminal mode with slash commands")
|
||||
parser.add_argument("--chat-mode", "--chat", action="store_true", dest="chat_mode", help="Run single prompt in interactive style (non-interactive, for testing)")
|
||||
|
||||
# Direct prompt flag - alternative to positional command
|
||||
parser.add_argument("-p", "--prompt", type=str, dest="prompt_flag", help="Direct prompt to execute (alternative to positional argument)")
|
||||
|
||||
# Autonomy Mode - control AI action approval
|
||||
parser.add_argument("--autonomy", type=str, choices=["suggest", "auto_edit", "full_auto"], help="Set autonomy mode for AI actions")
|
||||
|
||||
@@ -898,6 +906,30 @@ class PraisonAI:
|
||||
self._handle_serve_command(args, unknown_args)
|
||||
sys.exit(0)
|
||||
|
||||
# Handle ACP command - Agent Client Protocol server for IDE integration
|
||||
if args.command == 'acp':
|
||||
from praisonai.cli.features.acp import run_acp_command
|
||||
exit_code = run_acp_command(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# Handle debug command - Debug and test interactive flows
|
||||
if args.command == 'debug':
|
||||
from praisonai.cli.features.debug import run_debug_command
|
||||
exit_code = run_debug_command(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# Handle lsp command - LSP service lifecycle
|
||||
if args.command == 'lsp':
|
||||
from praisonai.cli.features.lsp_cli import run_lsp_command
|
||||
exit_code = run_lsp_command(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# Handle diag command - Diagnostics export
|
||||
if args.command == 'diag':
|
||||
from praisonai.cli.features.diag import run_diag_command
|
||||
exit_code = run_diag_command(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# Handle both command and flag versions for call
|
||||
if args.command == 'call' or args.call:
|
||||
if not CALL_MODULE_AVAILABLE:
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
PraisonAI CLI State Module.
|
||||
|
||||
Provides state management for run IDs, trace IDs, agent IDs, and sessions.
|
||||
"""
|
||||
|
||||
from .identifiers import (
|
||||
generate_run_id,
|
||||
generate_trace_id,
|
||||
generate_agent_id,
|
||||
RunContext,
|
||||
get_current_context,
|
||||
set_current_context,
|
||||
)
|
||||
from .sessions import (
|
||||
SessionManager,
|
||||
SessionMetadata,
|
||||
get_session_manager,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'generate_run_id',
|
||||
'generate_trace_id',
|
||||
'generate_agent_id',
|
||||
'RunContext',
|
||||
'get_current_context',
|
||||
'set_current_context',
|
||||
'SessionManager',
|
||||
'SessionMetadata',
|
||||
'get_session_manager',
|
||||
]
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Identifier generation for PraisonAI CLI.
|
||||
|
||||
Provides stable, unique identifiers for runs, traces, and agents.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
def generate_run_id() -> str:
|
||||
"""
|
||||
Generate a unique run ID.
|
||||
|
||||
Format: run_YYYYMMDD_HHMMSS_<random>
|
||||
Example: run_20241231_143022_a1b2c3
|
||||
"""
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
random_suffix = secrets.token_hex(3) # 6 characters
|
||||
return f"run_{timestamp}_{random_suffix}"
|
||||
|
||||
|
||||
def generate_trace_id() -> str:
|
||||
"""
|
||||
Generate a unique trace ID.
|
||||
|
||||
Format: trace_<random>
|
||||
Example: trace_a1b2c3d4e5f6
|
||||
"""
|
||||
return f"trace_{secrets.token_hex(6)}"
|
||||
|
||||
|
||||
def generate_agent_id(agent_name: str, run_id: str, index: int = 0) -> str:
|
||||
"""
|
||||
Generate a deterministic agent ID.
|
||||
|
||||
The ID is deterministic based on agent name, run ID, and index,
|
||||
ensuring consistent IDs across the same run.
|
||||
|
||||
Format: agent_<hash>_<index>
|
||||
Example: agent_a1b2c3_001
|
||||
|
||||
Args:
|
||||
agent_name: Name of the agent
|
||||
run_id: Current run ID
|
||||
index: Agent index within the run
|
||||
|
||||
Returns:
|
||||
Deterministic agent ID
|
||||
"""
|
||||
# Create deterministic hash from agent name and run ID
|
||||
hash_input = f"{agent_name}:{run_id}:{index}"
|
||||
hash_value = hashlib.sha256(hash_input.encode()).hexdigest()[:6]
|
||||
return f"agent_{hash_value}_{index:03d}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunContext:
|
||||
"""
|
||||
Context for a single run.
|
||||
|
||||
Holds all identifiers and metadata for the current execution.
|
||||
"""
|
||||
run_id: str = field(default_factory=generate_run_id)
|
||||
trace_id: str = field(default_factory=generate_trace_id)
|
||||
start_time: datetime = field(default_factory=datetime.utcnow)
|
||||
|
||||
# Agent tracking
|
||||
agents: Dict[str, str] = field(default_factory=dict) # name -> agent_id
|
||||
_agent_counter: int = field(default=0, repr=False)
|
||||
|
||||
# Metadata
|
||||
config_summary: Dict[str, str] = field(default_factory=dict)
|
||||
workspace: Optional[str] = None
|
||||
|
||||
def get_agent_id(self, agent_name: str) -> str:
|
||||
"""
|
||||
Get or create an agent ID for the given agent name.
|
||||
|
||||
Args:
|
||||
agent_name: Name of the agent
|
||||
|
||||
Returns:
|
||||
Agent ID (creates new one if not exists)
|
||||
"""
|
||||
if agent_name not in self.agents:
|
||||
agent_id = generate_agent_id(agent_name, self.run_id, self._agent_counter)
|
||||
self.agents[agent_name] = agent_id
|
||||
self._agent_counter += 1
|
||||
return self.agents[agent_name]
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"run_id": self.run_id,
|
||||
"trace_id": self.trace_id,
|
||||
"start_time": self.start_time.isoformat(),
|
||||
"agents": self.agents.copy(),
|
||||
"config_summary": self.config_summary.copy(),
|
||||
"workspace": self.workspace,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict) -> "RunContext":
|
||||
"""Create from dictionary."""
|
||||
ctx = cls(
|
||||
run_id=data.get("run_id", generate_run_id()),
|
||||
trace_id=data.get("trace_id", generate_trace_id()),
|
||||
workspace=data.get("workspace"),
|
||||
)
|
||||
ctx.agents = data.get("agents", {})
|
||||
ctx.config_summary = data.get("config_summary", {})
|
||||
ctx._agent_counter = len(ctx.agents)
|
||||
|
||||
start_time = data.get("start_time")
|
||||
if start_time:
|
||||
try:
|
||||
ctx.start_time = datetime.fromisoformat(start_time)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
# Thread-local storage for current context
|
||||
_context_local = threading.local()
|
||||
|
||||
|
||||
def get_current_context() -> Optional[RunContext]:
|
||||
"""Get the current run context for this thread."""
|
||||
return getattr(_context_local, "context", None)
|
||||
|
||||
|
||||
def set_current_context(context: Optional[RunContext]) -> None:
|
||||
"""Set the current run context for this thread."""
|
||||
_context_local.context = context
|
||||
|
||||
|
||||
def create_context(workspace: Optional[str] = None) -> RunContext:
|
||||
"""
|
||||
Create a new run context and set it as current.
|
||||
|
||||
Args:
|
||||
workspace: Optional workspace path
|
||||
|
||||
Returns:
|
||||
New RunContext instance
|
||||
"""
|
||||
context = RunContext(workspace=workspace or os.getcwd())
|
||||
set_current_context(context)
|
||||
return context
|
||||
|
||||
|
||||
def clear_context() -> None:
|
||||
"""Clear the current run context."""
|
||||
set_current_context(None)
|
||||
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Session management for PraisonAI CLI.
|
||||
|
||||
Provides persistence and resume functionality for sessions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..configuration.paths import get_sessions_dir, ensure_config_dirs
|
||||
from .identifiers import RunContext
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionMetadata:
|
||||
"""Metadata for a session."""
|
||||
session_id: str
|
||||
run_id: str
|
||||
trace_id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
name: Optional[str] = None
|
||||
workspace: Optional[str] = None
|
||||
config_summary: Dict[str, Any] = field(default_factory=dict)
|
||||
event_count: int = 0
|
||||
status: str = "active" # active, completed, error
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"run_id": self.run_id,
|
||||
"trace_id": self.trace_id,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"name": self.name,
|
||||
"workspace": self.workspace,
|
||||
"config_summary": self.config_summary,
|
||||
"event_count": self.event_count,
|
||||
"status": self.status,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "SessionMetadata":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
session_id=data["session_id"],
|
||||
run_id=data["run_id"],
|
||||
trace_id=data["trace_id"],
|
||||
created_at=datetime.fromisoformat(data["created_at"]),
|
||||
updated_at=datetime.fromisoformat(data["updated_at"]),
|
||||
name=data.get("name"),
|
||||
workspace=data.get("workspace"),
|
||||
config_summary=data.get("config_summary", {}),
|
||||
event_count=data.get("event_count", 0),
|
||||
status=data.get("status", "active"),
|
||||
)
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""
|
||||
Manages session persistence and retrieval.
|
||||
|
||||
Sessions are stored in ~/.praison/sessions/<session_id>/
|
||||
Each session contains:
|
||||
- metadata.json: Session metadata
|
||||
- events.jsonl: Stream of events (NDJSON)
|
||||
"""
|
||||
|
||||
def __init__(self, sessions_dir: Optional[Path] = None):
|
||||
self.sessions_dir = sessions_dir or get_sessions_dir()
|
||||
ensure_config_dirs()
|
||||
|
||||
def _get_session_dir(self, session_id: str) -> Path:
|
||||
"""Get the directory for a session."""
|
||||
return self.sessions_dir / session_id
|
||||
|
||||
def _get_metadata_path(self, session_id: str) -> Path:
|
||||
"""Get the metadata file path for a session."""
|
||||
return self._get_session_dir(session_id) / "metadata.json"
|
||||
|
||||
def _get_events_path(self, session_id: str) -> Path:
|
||||
"""Get the events file path for a session."""
|
||||
return self._get_session_dir(session_id) / "events.jsonl"
|
||||
|
||||
def create(self, context: RunContext, name: Optional[str] = None) -> SessionMetadata:
|
||||
"""
|
||||
Create a new session from a run context.
|
||||
|
||||
Args:
|
||||
context: Run context to create session from
|
||||
name: Optional session name
|
||||
|
||||
Returns:
|
||||
Session metadata
|
||||
"""
|
||||
session_id = context.run_id # Use run_id as session_id
|
||||
session_dir = self._get_session_dir(session_id)
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
now = datetime.utcnow()
|
||||
metadata = SessionMetadata(
|
||||
session_id=session_id,
|
||||
run_id=context.run_id,
|
||||
trace_id=context.trace_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
name=name,
|
||||
workspace=context.workspace,
|
||||
config_summary=context.config_summary,
|
||||
)
|
||||
|
||||
# Save metadata
|
||||
self._save_metadata(metadata)
|
||||
|
||||
# Create empty events file
|
||||
self._get_events_path(session_id).touch()
|
||||
|
||||
return metadata
|
||||
|
||||
def _save_metadata(self, metadata: SessionMetadata) -> None:
|
||||
"""Save session metadata."""
|
||||
path = self._get_metadata_path(metadata.session_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(metadata.to_dict(), f, indent=2)
|
||||
|
||||
def _load_metadata(self, session_id: str) -> Optional[SessionMetadata]:
|
||||
"""Load session metadata."""
|
||||
path = self._get_metadata_path(session_id)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return SessionMetadata.from_dict(data)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return None
|
||||
|
||||
def append_event(self, session_id: str, event: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Append an event to a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
event: Event data to append
|
||||
"""
|
||||
events_path = self._get_events_path(session_id)
|
||||
if not events_path.parent.exists():
|
||||
return
|
||||
|
||||
with open(events_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event, default=str) + "\n")
|
||||
|
||||
# Update metadata
|
||||
metadata = self._load_metadata(session_id)
|
||||
if metadata:
|
||||
metadata.event_count += 1
|
||||
metadata.updated_at = datetime.utcnow()
|
||||
self._save_metadata(metadata)
|
||||
|
||||
def get(self, session_id: str) -> Optional[SessionMetadata]:
|
||||
"""Get session metadata by ID."""
|
||||
return self._load_metadata(session_id)
|
||||
|
||||
def list(self, limit: int = 50) -> List[SessionMetadata]:
|
||||
"""
|
||||
List all sessions, sorted by updated_at descending.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of sessions to return
|
||||
|
||||
Returns:
|
||||
List of session metadata
|
||||
"""
|
||||
sessions = []
|
||||
|
||||
if not self.sessions_dir.exists():
|
||||
return sessions
|
||||
|
||||
for session_dir in self.sessions_dir.iterdir():
|
||||
if session_dir.is_dir():
|
||||
metadata = self._load_metadata(session_dir.name)
|
||||
if metadata:
|
||||
sessions.append(metadata)
|
||||
|
||||
# Sort by updated_at descending
|
||||
sessions.sort(key=lambda s: s.updated_at, reverse=True)
|
||||
|
||||
return sessions[:limit]
|
||||
|
||||
def delete(self, session_id: str) -> bool:
|
||||
"""
|
||||
Delete a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to delete
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
session_dir = self._get_session_dir(session_id)
|
||||
if not session_dir.exists():
|
||||
return False
|
||||
|
||||
shutil.rmtree(session_dir)
|
||||
return True
|
||||
|
||||
def get_events(self, session_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all events for a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
|
||||
Returns:
|
||||
List of events
|
||||
"""
|
||||
events_path = self._get_events_path(session_id)
|
||||
if not events_path.exists():
|
||||
return []
|
||||
|
||||
events = []
|
||||
with open(events_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return events
|
||||
|
||||
def export(self, session_id: str, format: str = "md") -> Optional[str]:
|
||||
"""
|
||||
Export a session to a string.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
format: Export format ("md" or "json")
|
||||
|
||||
Returns:
|
||||
Exported content or None if not found
|
||||
"""
|
||||
metadata = self._load_metadata(session_id)
|
||||
if not metadata:
|
||||
return None
|
||||
|
||||
events = self.get_events(session_id)
|
||||
|
||||
if format == "json":
|
||||
return json.dumps({
|
||||
"metadata": metadata.to_dict(),
|
||||
"events": events,
|
||||
}, indent=2, default=str)
|
||||
|
||||
# Markdown format
|
||||
lines = [
|
||||
f"# Session: {metadata.name or metadata.session_id}",
|
||||
"",
|
||||
f"- **Run ID**: {metadata.run_id}",
|
||||
f"- **Trace ID**: {metadata.trace_id}",
|
||||
f"- **Created**: {metadata.created_at.isoformat()}",
|
||||
f"- **Status**: {metadata.status}",
|
||||
"",
|
||||
"## Events",
|
||||
"",
|
||||
]
|
||||
|
||||
for event in events:
|
||||
event_type = event.get("event", "unknown")
|
||||
timestamp = event.get("timestamp", "")
|
||||
message = event.get("message", "")
|
||||
|
||||
lines.append(f"### {event_type} ({timestamp})")
|
||||
if message:
|
||||
lines.append(f"\n{message}")
|
||||
|
||||
data = event.get("data", {})
|
||||
if data:
|
||||
lines.append("\n```json")
|
||||
lines.append(json.dumps(data, indent=2, default=str))
|
||||
lines.append("```")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def update_status(self, session_id: str, status: str) -> None:
|
||||
"""Update session status."""
|
||||
metadata = self._load_metadata(session_id)
|
||||
if metadata:
|
||||
metadata.status = status
|
||||
metadata.updated_at = datetime.utcnow()
|
||||
self._save_metadata(metadata)
|
||||
|
||||
|
||||
# Global session manager
|
||||
_session_manager: Optional[SessionManager] = None
|
||||
|
||||
|
||||
def get_session_manager() -> SessionManager:
|
||||
"""Get the global session manager."""
|
||||
global _session_manager
|
||||
if _session_manager is None:
|
||||
_session_manager = SessionManager()
|
||||
return _session_manager
|
||||
@@ -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.8.3 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==2.9.2 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -6,13 +6,17 @@ that may change between versions. It handles:
|
||||
- EXPIRY_TIME / storage_expiry_time constant (renamed/removed in various versions)
|
||||
- BaseStorageClient class location (moved from storage_clients.base to data.base)
|
||||
- BaseDataLayer abstract methods (close() added in 2.9.4, may be removed later)
|
||||
- LocalFileStorageClient for default element persistence
|
||||
|
||||
Usage:
|
||||
from praisonai.ui.chainlit_compat import get_expiry_seconds, BaseStorageClient
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional, Type
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Type, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default expiry time in seconds (1 hour) - used if Chainlit doesn't provide one
|
||||
DEFAULT_EXPIRY_SECONDS = 3600
|
||||
@@ -144,6 +148,91 @@ except ImportError:
|
||||
BaseStorageClient = None
|
||||
|
||||
|
||||
class LocalFileStorageClient:
|
||||
"""
|
||||
A simple local file storage client for persisting elements to disk.
|
||||
|
||||
This provides a default storage implementation when no cloud storage
|
||||
(S3, Azure, etc.) is configured. Files are stored in the CHAINLIT_APP_ROOT/.files directory.
|
||||
"""
|
||||
|
||||
def __init__(self, storage_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the local file storage client.
|
||||
|
||||
Args:
|
||||
storage_dir: Directory to store files. Defaults to CHAINLIT_APP_ROOT/.files
|
||||
"""
|
||||
if storage_dir:
|
||||
self.storage_dir = storage_dir
|
||||
else:
|
||||
chainlit_root = os.environ.get("CHAINLIT_APP_ROOT", os.path.join(os.path.expanduser("~"), ".praison"))
|
||||
self.storage_dir = os.path.join(chainlit_root, ".files")
|
||||
|
||||
os.makedirs(self.storage_dir, exist_ok=True)
|
||||
logger.debug(f"LocalFileStorageClient initialized with storage_dir: {self.storage_dir}")
|
||||
|
||||
async def upload_file(
|
||||
self,
|
||||
object_key: str,
|
||||
data: Union[bytes, str],
|
||||
mime: str = "application/octet-stream",
|
||||
overwrite: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Upload a file to local storage.
|
||||
|
||||
Args:
|
||||
object_key: The key/path for the file
|
||||
data: The file content (bytes or string)
|
||||
mime: MIME type of the file
|
||||
overwrite: Whether to overwrite existing files
|
||||
|
||||
Returns:
|
||||
Dict with object_key and url
|
||||
"""
|
||||
import aiofiles
|
||||
|
||||
# Create full path
|
||||
file_path = os.path.join(self.storage_dir, object_key)
|
||||
|
||||
# Create parent directories if needed
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
|
||||
# Check if file exists and overwrite is False
|
||||
if not overwrite and os.path.exists(file_path):
|
||||
logger.warning(f"File {object_key} already exists and overwrite=False")
|
||||
return {"object_key": object_key, "url": f"file://{file_path}"}
|
||||
|
||||
# Write the file
|
||||
try:
|
||||
if isinstance(data, str):
|
||||
async with aiofiles.open(file_path, 'w') as f:
|
||||
await f.write(data)
|
||||
else:
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(data)
|
||||
|
||||
logger.debug(f"Uploaded file to {file_path}")
|
||||
return {"object_key": object_key, "url": f"file://{file_path}"}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload file {object_key}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def create_local_storage_client(storage_dir: Optional[str] = None) -> LocalFileStorageClient:
|
||||
"""
|
||||
Create a LocalFileStorageClient instance.
|
||||
|
||||
Args:
|
||||
storage_dir: Optional directory for file storage
|
||||
|
||||
Returns:
|
||||
LocalFileStorageClient instance
|
||||
"""
|
||||
return LocalFileStorageClient(storage_dir=storage_dir)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'get_expiry_seconds',
|
||||
'get_base_storage_client',
|
||||
@@ -151,5 +240,7 @@ __all__ = [
|
||||
'base_data_layer_has_close',
|
||||
'EXPIRY_TIME',
|
||||
'BaseStorageClient',
|
||||
'LocalFileStorageClient',
|
||||
'create_local_storage_client',
|
||||
'DEFAULT_EXPIRY_SECONDS',
|
||||
]
|
||||
@@ -62,12 +62,36 @@ def load_setting(key: str) -> str:
|
||||
cl_data._data_layer = db_manager
|
||||
|
||||
def load_custom_tools():
|
||||
"""Load custom tools from tools.py if it exists"""
|
||||
"""
|
||||
Load custom tools from tools.py if it exists.
|
||||
|
||||
Resolution order:
|
||||
1. PRAISONAI_TOOLS_PATH environment variable (file or directory)
|
||||
2. ./tools.py in current working directory
|
||||
3. No tools (silent, no warning)
|
||||
"""
|
||||
custom_tools = {}
|
||||
|
||||
# Determine tools path
|
||||
tools_path = os.getenv("PRAISONAI_TOOLS_PATH")
|
||||
if tools_path:
|
||||
if not os.path.exists(tools_path):
|
||||
logger.warning(f"PRAISONAI_TOOLS_PATH set but path does not exist: {tools_path}")
|
||||
return custom_tools
|
||||
else:
|
||||
# Check current working directory for tools.py
|
||||
cwd_tools = os.path.join(os.getcwd(), "tools.py")
|
||||
if os.path.exists(cwd_tools):
|
||||
tools_path = cwd_tools
|
||||
else:
|
||||
# No tools configured - this is fine, return silently
|
||||
logger.debug("No tools.py found in current directory (this is normal)")
|
||||
return custom_tools
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("tools", "tools.py")
|
||||
if spec is None:
|
||||
logger.debug("tools.py not found in current directory")
|
||||
spec = importlib.util.spec_from_file_location("tools", tools_path)
|
||||
if spec is None or spec.loader is None:
|
||||
logger.debug(f"Could not load tools from {tools_path}")
|
||||
return custom_tools
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
@@ -122,9 +146,13 @@ def load_custom_tools():
|
||||
custom_tools[name] = tool_def
|
||||
logger.info(f"Loaded custom tool: {name}")
|
||||
|
||||
logger.info(f"Loaded {len(custom_tools)} custom tools from tools.py")
|
||||
if custom_tools:
|
||||
logger.info(f"Loaded {len(custom_tools)} custom tools from {tools_path}")
|
||||
except FileNotFoundError:
|
||||
# File doesn't exist - this is fine, no warning needed
|
||||
logger.debug(f"Tools file not found: {tools_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading custom tools: {e}")
|
||||
logger.warning(f"Error loading custom tools from {tools_path}: {e}")
|
||||
|
||||
return custom_tools
|
||||
|
||||
@@ -203,24 +231,16 @@ expected_password = os.getenv("CHAINLIT_PASSWORD", "admin")
|
||||
if expected_username == "admin" and expected_password == "admin":
|
||||
logger.warning("⚠️ Using default admin credentials. Set CHAINLIT_USERNAME and CHAINLIT_PASSWORD environment variables for production.")
|
||||
|
||||
def auth_callback(u: str, p: str):
|
||||
if (u, p) == (expected_username, expected_password):
|
||||
return cl.User(identifier=expected_username, metadata={"role": "ADMIN", "provider": "credentials"})
|
||||
return None
|
||||
@cl.password_auth_callback
|
||||
def auth_callback(username: str, password: str):
|
||||
logger.debug(f"Auth attempt: username='{username}', expected='{expected_username}'")
|
||||
if (username, password) == (expected_username, expected_password):
|
||||
logger.info(f"Login successful for user: {username}")
|
||||
return cl.User(identifier=username, metadata={"role": "admin", "provider": "credentials"})
|
||||
else:
|
||||
logger.warning(f"Login failed for user: {username}")
|
||||
return None
|
||||
|
||||
def oauth_callback(
|
||||
provider_id: str,
|
||||
token: str,
|
||||
raw_user_data: Dict[str, str],
|
||||
default_user: cl.User,
|
||||
) -> Optional[cl.User]:
|
||||
return default_user
|
||||
|
||||
if AUTH_PASSWORD_ENABLED:
|
||||
auth_callback = cl.password_auth_callback(auth_callback)
|
||||
|
||||
if AUTH_OAUTH_ENABLED:
|
||||
oauth_callback = cl.oauth_callback(oauth_callback)
|
||||
|
||||
async def send_count():
|
||||
await cl.Message(
|
||||
@@ -299,7 +319,6 @@ User Question: {message.content}
|
||||
|
||||
message_history.append({"role": "user", "content": user_message})
|
||||
msg = cl.Message(content="")
|
||||
await msg.send()
|
||||
|
||||
completion_params = {
|
||||
"model": model_name,
|
||||
@@ -329,6 +348,7 @@ User Question: {message.content}
|
||||
full_response = ""
|
||||
tool_calls = []
|
||||
current_tool_call = None
|
||||
msg_sent = False
|
||||
|
||||
async for part in response:
|
||||
if 'choices' in part and len(part['choices']) > 0:
|
||||
@@ -336,6 +356,10 @@ User Question: {message.content}
|
||||
|
||||
if 'content' in delta and delta['content'] is not None:
|
||||
token = delta['content']
|
||||
# Send message on first token to avoid delay
|
||||
if not msg_sent:
|
||||
await msg.send()
|
||||
msg_sent = True
|
||||
await msg.stream_token(token)
|
||||
full_response += token
|
||||
|
||||
@@ -362,6 +386,10 @@ User Question: {message.content}
|
||||
if current_tool_call:
|
||||
tool_calls.append(current_tool_call)
|
||||
|
||||
# Ensure message is sent even if no content (e.g., tool calls only)
|
||||
if not msg_sent:
|
||||
await msg.send()
|
||||
|
||||
logger.debug(f"Full response: {full_response}")
|
||||
logger.debug(f"Tool calls: {tool_calls}")
|
||||
message_history.append({"role": "assistant", "content": full_response})
|
||||
|
||||
@@ -398,7 +398,6 @@ Context:
|
||||
message_history.append({"role": "user", "content": user_message})
|
||||
|
||||
msg = cl.Message(content="")
|
||||
await msg.send()
|
||||
|
||||
# Use PraisonAI Agents if available, otherwise fallback to litellm
|
||||
if PRAISONAI_AGENTS_AVAILABLE:
|
||||
@@ -433,41 +432,47 @@ Then use the Claude Code tool to handle those requests.
|
||||
|
||||
For informational questions, explanations, or general conversations, respond normally without using Claude Code."""
|
||||
|
||||
# Create agent
|
||||
# Create agent with streaming enabled
|
||||
agent = Agent(
|
||||
name="PraisonAI Assistant",
|
||||
instructions=instructions,
|
||||
llm=model_name,
|
||||
tools=available_tools if available_tools else None
|
||||
tools=available_tools if available_tools else None,
|
||||
stream=True
|
||||
)
|
||||
|
||||
# Execute agent with streaming
|
||||
full_response = ""
|
||||
msg_sent = False
|
||||
|
||||
# Use agent's streaming capabilities if available
|
||||
try:
|
||||
# For now, use synchronous execution and stream the result
|
||||
# TODO: Implement proper streaming when PraisonAI agents support it
|
||||
result = agent.start(user_message)
|
||||
# Use async chat for proper streaming
|
||||
result = await agent.achat(user_message)
|
||||
|
||||
# Stream the response character by character for better UX
|
||||
# Get the response text
|
||||
if hasattr(result, 'raw'):
|
||||
response_text = result.raw
|
||||
else:
|
||||
response_text = str(result)
|
||||
|
||||
for char in response_text:
|
||||
await msg.stream_token(char)
|
||||
full_response += char
|
||||
# Small delay to make streaming visible
|
||||
await asyncio.sleep(0.01)
|
||||
# Send message on first content
|
||||
if not msg_sent:
|
||||
await msg.send()
|
||||
msg_sent = True
|
||||
|
||||
# Stream in word chunks for better UX (not char-by-char which is too slow)
|
||||
words = response_text.split(' ')
|
||||
for i, word in enumerate(words):
|
||||
token = word + (' ' if i < len(words) - 1 else '')
|
||||
await msg.stream_token(token)
|
||||
full_response += token
|
||||
|
||||
except Exception as e:
|
||||
error_response = f"Error executing agent: {str(e)}"
|
||||
for char in error_response:
|
||||
await msg.stream_token(char)
|
||||
full_response += char
|
||||
await asyncio.sleep(0.01)
|
||||
if not msg_sent:
|
||||
await msg.send()
|
||||
await msg.stream_token(error_response)
|
||||
full_response = error_response
|
||||
|
||||
msg.content = full_response
|
||||
await msg.update()
|
||||
@@ -475,6 +480,7 @@ For informational questions, explanations, or general conversations, respond nor
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to use PraisonAI Agents: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
await msg.send()
|
||||
await msg.stream_token(error_msg)
|
||||
msg.content = error_msg
|
||||
await msg.update()
|
||||
@@ -515,6 +521,7 @@ async def handle_with_litellm(user_message, model_name, message_history, msg, im
|
||||
full_response = ""
|
||||
tool_calls = []
|
||||
current_tool_call = None
|
||||
msg_sent = False
|
||||
|
||||
async for part in response:
|
||||
logger.debug(f"LLM part: {part}")
|
||||
@@ -523,6 +530,10 @@ async def handle_with_litellm(user_message, model_name, message_history, msg, im
|
||||
|
||||
if 'content' in delta and delta['content'] is not None:
|
||||
token = delta['content']
|
||||
# Send message on first token
|
||||
if not msg_sent:
|
||||
await msg.send()
|
||||
msg_sent = True
|
||||
await msg.stream_token(token)
|
||||
full_response += token
|
||||
|
||||
@@ -549,6 +560,10 @@ async def handle_with_litellm(user_message, model_name, message_history, msg, im
|
||||
if current_tool_call:
|
||||
tool_calls.append(current_tool_call)
|
||||
|
||||
# Ensure message is sent even if no content (tool calls only)
|
||||
if not msg_sent:
|
||||
await msg.send()
|
||||
|
||||
logger.debug(f"Full response: {full_response}")
|
||||
logger.debug(f"Tool calls: {tool_calls}")
|
||||
message_history.append({"role": "assistant", "content": full_response})
|
||||
|
||||
@@ -10,6 +10,7 @@ from sql_alchemy import SQLAlchemyDataLayer
|
||||
import chainlit.data as cl_data
|
||||
from chainlit.types import ThreadDict
|
||||
from database_config import get_database_url_with_sqlite_override
|
||||
from praisonai.ui.chainlit_compat import create_local_storage_client
|
||||
|
||||
def ensure_directories():
|
||||
"""Ensure required directories exist"""
|
||||
@@ -68,8 +69,11 @@ class DatabaseManager(SQLAlchemyDataLayer):
|
||||
self.db_path = os.path.join(chainlit_root, "database.sqlite")
|
||||
self.conninfo = f"sqlite+aiosqlite:///{self.db_path}"
|
||||
|
||||
# Initialize SQLAlchemyDataLayer with the connection info
|
||||
super().__init__(conninfo=self.conninfo)
|
||||
# Create local file storage client for element persistence
|
||||
storage_client = create_local_storage_client()
|
||||
|
||||
# Initialize SQLAlchemyDataLayer with the connection info and storage client
|
||||
super().__init__(conninfo=self.conninfo, storage_provider=storage_client)
|
||||
|
||||
async def create_schema_async(self):
|
||||
"""Create the database schema for PostgreSQL"""
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.8.3"
|
||||
__version__ = "2.9.2"
|
||||
@@ -12,11 +12,13 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=0.5.2",
|
||||
"praisonaiagents>=0.5.3",
|
||||
"python-dotenv>=0.19.0",
|
||||
"instructor>=1.3.3",
|
||||
"PyYAML>=6.0",
|
||||
"mcp>=1.6.0",
|
||||
"typer>=0.9.0",
|
||||
"textual>=0.47.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -26,7 +28,7 @@ praisonai-call = "praisonai.api.call:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
ui = [
|
||||
"chainlit==2.5.5",
|
||||
"chainlit>=2.8.5,<2.9.0",
|
||||
"sqlalchemy>=2.0.36",
|
||||
"aiosqlite>=0.20.0",
|
||||
"greenlet>=3.0.3",
|
||||
@@ -45,7 +47,7 @@ openai = ["langchain-openai>=0.2.1,<0.3.0"]
|
||||
anthropic = ["langchain-anthropic>=0.3.0"]
|
||||
cohere = ["langchain-cohere>=0.3.0,<0.4.0"]
|
||||
chat = [
|
||||
"chainlit==2.5.5",
|
||||
"chainlit>=2.8.5,<2.9.0",
|
||||
"litellm>=1.72.6",
|
||||
"aiosqlite>=0.20.0",
|
||||
"greenlet>=3.0.3",
|
||||
@@ -57,7 +59,7 @@ chat = [
|
||||
"pydantic<=2.10.1"
|
||||
]
|
||||
code = [
|
||||
"chainlit==2.5.5",
|
||||
"chainlit>=2.8.5,<2.9.0",
|
||||
"litellm>=1.72.6",
|
||||
"aiosqlite>=0.20.0",
|
||||
"greenlet>=3.0.3",
|
||||
@@ -68,7 +70,7 @@ code = [
|
||||
"pydantic<=2.10.1"
|
||||
]
|
||||
realtime = [
|
||||
"chainlit==2.5.5",
|
||||
"chainlit>=2.8.5,<2.9.0",
|
||||
"litellm>=1.72.6",
|
||||
"aiosqlite>=0.20.0",
|
||||
"greenlet>=3.0.3",
|
||||
@@ -100,6 +102,18 @@ autogen-v4 = [
|
||||
"praisonai-tools>=0.1.0",
|
||||
"crewai"
|
||||
]
|
||||
acp = [
|
||||
"agent-client-protocol>=0.7.0",
|
||||
]
|
||||
lite = [
|
||||
# Minimal installation without TUI, litellm - just core agent functionality
|
||||
# Use: pip install praisonai[lite] for lightweight deployments
|
||||
]
|
||||
dev = [
|
||||
"textual-dev>=1.0.0",
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://docs.praison.ai"
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
#!/bin/bash
|
||||
# PraisonAI TUI Real-Key Verification Script
|
||||
#
|
||||
# This script verifies the TUI and Queue system works with real API keys.
|
||||
# It includes safety gates and secret masking.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/verify_tui_real_keys.sh
|
||||
#
|
||||
# Required environment variables:
|
||||
# OPENAI_API_KEY - OpenAI API key
|
||||
#
|
||||
# Optional:
|
||||
# PRAISONAI_REAL_LLM=1 - Required to run real LLM tests
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " PraisonAI TUI + Queue System Verification"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# Function to mask secrets in output
|
||||
mask_secrets() {
|
||||
sed -E 's/(sk-[a-zA-Z0-9]{20})[a-zA-Z0-9]+/\1***/g' | \
|
||||
sed -E 's/(key_[a-zA-Z0-9]{20})[a-zA-Z0-9]+/\1***/g' | \
|
||||
sed -E 's/(tvly-[a-zA-Z0-9]{20})[a-zA-Z0-9]+/\1***/g' | \
|
||||
sed -E 's/(AIza[a-zA-Z0-9]{20})[a-zA-Z0-9]+/\1***/g'
|
||||
}
|
||||
|
||||
# Check environment variables
|
||||
echo -e "\n${YELLOW}[1/7] Checking environment variables...${NC}"
|
||||
|
||||
check_env() {
|
||||
local var_name=$1
|
||||
local value="${!var_name}"
|
||||
if [ -n "$value" ]; then
|
||||
local masked=$(echo "$value" | head -c 10)
|
||||
echo -e " ${GREEN}✓${NC} $var_name: ${masked}***"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} $var_name: not set"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
OPENAI_SET=false
|
||||
check_env "OPENAI_API_KEY" && OPENAI_SET=true
|
||||
check_env "ANTHROPIC_API_KEY" || true
|
||||
check_env "GOOGLE_API_KEY" || true
|
||||
check_env "TAVILY_API_KEY" || true
|
||||
|
||||
if [ "$OPENAI_SET" = false ]; then
|
||||
echo -e "\n${RED}Error: OPENAI_API_KEY is required${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Python imports
|
||||
echo -e "\n${YELLOW}[2/7] Checking Python imports...${NC}"
|
||||
|
||||
python3 -c "
|
||||
from praisonai.cli.features.queue import QueuedRun, RunState, RunPriority, QueueConfig
|
||||
from praisonai.cli.features.queue import QueueScheduler, QueuePersistence, QueueManager
|
||||
from praisonai.cli.features.tui import TUIEvent, TUIEventType
|
||||
from praisonai.cli.features.tui import TuiOrchestrator, UIStateModel, SimulationRunner
|
||||
from praisonai.cli.features.tui import MockProvider, MockProviderConfig
|
||||
print(' ✓ All imports successful')
|
||||
" 2>&1 | mask_secrets
|
||||
|
||||
# Run unit tests
|
||||
echo -e "\n${YELLOW}[3/7] Running unit tests...${NC}"
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
python3 -m pytest tests/unit/cli/queue/ tests/unit/cli/tui/ -v --tb=short --import-mode=importlib 2>&1 | \
|
||||
tail -20 | mask_secrets
|
||||
|
||||
# Test queue CLI commands
|
||||
echo -e "\n${YELLOW}[4/7] Testing queue CLI commands...${NC}"
|
||||
|
||||
python3 -c "
|
||||
from praisonai.cli.features.tui.cli import create_queue_app
|
||||
app = create_queue_app()
|
||||
print(' ✓ Queue CLI app created')
|
||||
print(' Commands:', [cmd.name for cmd in app.registered_commands])
|
||||
" 2>&1 | mask_secrets
|
||||
|
||||
# Test mock provider
|
||||
echo -e "\n${YELLOW}[5/7] Testing mock provider...${NC}"
|
||||
|
||||
python3 -c "
|
||||
import asyncio
|
||||
from praisonai.cli.features.tui.mock_provider import MockProvider, create_mock_provider
|
||||
|
||||
async def test():
|
||||
provider = create_mock_provider(seed=42)
|
||||
|
||||
# Test streaming
|
||||
chunks = []
|
||||
result = await provider.generate(
|
||||
'hello world',
|
||||
stream=True,
|
||||
on_chunk=lambda c: chunks.append(c)
|
||||
)
|
||||
|
||||
print(' ✓ Mock provider works')
|
||||
print(f' Content length: {len(result[\"content\"])} chars')
|
||||
print(f' Chunks: {len(chunks)}')
|
||||
print(f' Tokens: {result[\"tokens\"]}')
|
||||
print(f' Cost: \${result[\"cost\"]:.6f}')
|
||||
|
||||
asyncio.run(test())
|
||||
" 2>&1 | mask_secrets
|
||||
|
||||
# Test orchestrator
|
||||
echo -e "\n${YELLOW}[6/7] Testing TUI orchestrator...${NC}"
|
||||
|
||||
python3 -c "
|
||||
import asyncio
|
||||
from praisonai.cli.features.tui.orchestrator import TuiOrchestrator, OutputMode
|
||||
from praisonai.cli.features.queue import QueueConfig
|
||||
|
||||
async def test():
|
||||
config = QueueConfig(enable_persistence=False)
|
||||
orchestrator = TuiOrchestrator(
|
||||
queue_config=config,
|
||||
output_mode=OutputMode.SILENT,
|
||||
debug=False,
|
||||
)
|
||||
|
||||
await orchestrator.start(session_id='test-verify')
|
||||
|
||||
# Get snapshot
|
||||
snapshot = orchestrator.get_snapshot()
|
||||
print(' ✓ Orchestrator started')
|
||||
print(f' Session: {snapshot[\"session_id\"]}')
|
||||
print(f' Model: {snapshot[\"model\"]}')
|
||||
|
||||
await orchestrator.stop()
|
||||
print(' ✓ Orchestrator stopped cleanly')
|
||||
|
||||
asyncio.run(test())
|
||||
" 2>&1 | mask_secrets
|
||||
|
||||
# Real LLM test (gated)
|
||||
echo -e "\n${YELLOW}[7/7] Real LLM test...${NC}"
|
||||
|
||||
if [ "$PRAISONAI_REAL_LLM" = "1" ]; then
|
||||
echo " Running real LLM test (PRAISONAI_REAL_LLM=1)"
|
||||
|
||||
python3 -c "
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
# Safety: Use minimal tokens
|
||||
async def test():
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
instructions='Reply with exactly one word.',
|
||||
model='gpt-4o-mini',
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
result = agent.start('Say hello')
|
||||
print(f' ✓ Real LLM response: {result[:50]}...')
|
||||
|
||||
asyncio.run(test())
|
||||
" 2>&1 | mask_secrets
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Skipped (set PRAISONAI_REAL_LLM=1 to enable)"
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo -e "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo -e "${GREEN} Verification Complete!${NC}"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "All checks passed. The TUI + Queue system is working correctly."
|
||||
echo ""
|
||||
echo "To run with real LLM:"
|
||||
echo " PRAISONAI_REAL_LLM=1 ./scripts/verify_tui_real_keys.sh"
|
||||
echo ""
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration tests for ACP module."""
|
||||
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Real LLM integration tests for ACP.
|
||||
|
||||
These tests use actual API keys to verify end-to-end functionality.
|
||||
API keys should be set via environment variables.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Skip if no API key
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.environ.get("OPENAI_API_KEY"),
|
||||
reason="OPENAI_API_KEY not set"
|
||||
)
|
||||
|
||||
|
||||
class TestACPRealLLM:
|
||||
"""Real LLM tests for ACP server."""
|
||||
|
||||
@pytest.fixture
|
||||
def acp_server(self):
|
||||
"""Create ACP server with real agent."""
|
||||
from praisonai.acp.server import ACPServer
|
||||
from praisonai.acp.config import ACPConfig
|
||||
|
||||
config = ACPConfig(
|
||||
workspace=Path(tempfile.mkdtemp()),
|
||||
debug=True,
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
return ACPServer(config=config)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_real(self, acp_server):
|
||||
"""Test real initialize handshake."""
|
||||
result = await acp_server.initialize(
|
||||
protocol_version=1,
|
||||
client_capabilities={"fs": {"readTextFile": True}},
|
||||
client_info={"name": "test-harness", "version": "1.0"},
|
||||
)
|
||||
|
||||
assert result["protocolVersion"] == 1
|
||||
assert result["agentInfo"]["name"] == "praisonai"
|
||||
assert "agentCapabilities" in result
|
||||
|
||||
# Log for verification (masked)
|
||||
print(f"[TEST] Initialize successful: protocol={result['protocolVersion']}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_lifecycle(self, acp_server):
|
||||
"""Test complete session lifecycle."""
|
||||
# Initialize
|
||||
await acp_server.initialize(protocol_version=1)
|
||||
|
||||
# Create session
|
||||
session_result = await acp_server.new_session(
|
||||
cwd="/tmp/test",
|
||||
mcp_servers=[],
|
||||
)
|
||||
|
||||
session_id = session_result["sessionId"]
|
||||
assert session_id.startswith("sess_")
|
||||
|
||||
# Log for verification
|
||||
print(f"[TEST] Session created: {session_id[:20]}...")
|
||||
|
||||
# Set mode
|
||||
await acp_server.set_session_mode(
|
||||
mode_id="manual",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Cancel
|
||||
await acp_server.cancel(session_id=session_id)
|
||||
|
||||
print("[TEST] Session lifecycle complete")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_with_real_llm(self, acp_server):
|
||||
"""Test prompt with real LLM call."""
|
||||
# Initialize
|
||||
await acp_server.initialize(protocol_version=1)
|
||||
|
||||
# Create session
|
||||
session_result = await acp_server.new_session(
|
||||
cwd="/tmp/test",
|
||||
mcp_servers=[],
|
||||
)
|
||||
session_id = session_result["sessionId"]
|
||||
|
||||
# Send a simple prompt
|
||||
prompt_result = await acp_server.prompt(
|
||||
prompt=[{"type": "text", "text": "What is 2+2? Reply with just the number."}],
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Verify response
|
||||
assert "stopReason" in prompt_result
|
||||
assert prompt_result["stopReason"] in ["end_turn", "cancelled", "refusal"]
|
||||
|
||||
# Log for verification (no secrets)
|
||||
print(f"[TEST] Prompt completed: stopReason={prompt_result['stopReason']}")
|
||||
print(f"[TEST] Session: {session_id[:20]}...")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_resume(self, acp_server):
|
||||
"""Test session resume functionality."""
|
||||
# Initialize
|
||||
await acp_server.initialize(protocol_version=1)
|
||||
|
||||
# Create session
|
||||
session_result = await acp_server.new_session(
|
||||
cwd="/tmp/test",
|
||||
mcp_servers=[],
|
||||
)
|
||||
session_id = session_result["sessionId"]
|
||||
|
||||
# Add a message to the session
|
||||
acp_server._sessions[session_id].add_message("user", "Test message")
|
||||
acp_server._session_store.save(acp_server._sessions[session_id])
|
||||
|
||||
# Create new server instance (simulating restart)
|
||||
from praisonai.acp.config import ACPConfig
|
||||
config = ACPConfig(workspace=Path("/tmp/test"), debug=True)
|
||||
from praisonai.acp.server import ACPServer as ACPServerClass
|
||||
new_server = ACPServerClass(config=config)
|
||||
|
||||
# Resume session
|
||||
resume_result = await new_server.load_session(
|
||||
cwd="/tmp/test",
|
||||
mcp_servers=[],
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
assert resume_result is not None
|
||||
assert session_id in new_server._sessions
|
||||
|
||||
# Verify message was preserved
|
||||
loaded_session = new_server._sessions[session_id]
|
||||
assert len(loaded_session.messages) == 1
|
||||
assert loaded_session.messages[0]["content"] == "Test message"
|
||||
|
||||
print(f"[TEST] Session resume successful: {session_id[:20]}...")
|
||||
|
||||
|
||||
class TestACPPermissionGating:
|
||||
"""Tests for permission gating."""
|
||||
|
||||
def test_read_only_default(self):
|
||||
"""Verify read-only is default."""
|
||||
from praisonai.acp.config import ACPConfig
|
||||
|
||||
config = ACPConfig()
|
||||
assert config.read_only is True
|
||||
assert config.can_write() is False
|
||||
assert config.can_execute_shell() is False
|
||||
|
||||
def test_explicit_write_permission(self):
|
||||
"""Test explicit write permission."""
|
||||
from praisonai.acp.config import ACPConfig
|
||||
|
||||
config = ACPConfig(allow_write=True)
|
||||
assert config.can_write() is True
|
||||
|
||||
def test_workspace_boundary(self):
|
||||
"""Test workspace boundary enforcement."""
|
||||
from praisonai.acp.config import ACPConfig
|
||||
|
||||
config = ACPConfig(workspace="/tmp/test_workspace")
|
||||
|
||||
# Within workspace - allowed
|
||||
assert config.is_path_allowed(Path("/tmp/test_workspace/file.txt"))
|
||||
assert config.is_path_allowed(Path("/tmp/test_workspace/subdir/file.txt"))
|
||||
|
||||
# Outside workspace - not allowed
|
||||
assert not config.is_path_allowed(Path("/etc/passwd"))
|
||||
assert not config.is_path_allowed(Path("/var/log/system.log"))
|
||||
|
||||
|
||||
class TestACPTracing:
|
||||
"""Tests for tracing and attribution."""
|
||||
|
||||
def test_session_has_trace_ids(self):
|
||||
"""Verify sessions have trace IDs."""
|
||||
from praisonai.acp.session import ACPSession
|
||||
|
||||
session = ACPSession.create(
|
||||
workspace=Path("/tmp/test"),
|
||||
agent_id="test_agent",
|
||||
)
|
||||
|
||||
assert session.session_id is not None
|
||||
assert session.run_id is not None
|
||||
assert session.trace_id is not None
|
||||
assert session.agent_id == "test_agent"
|
||||
|
||||
# IDs should have proper prefixes
|
||||
assert session.session_id.startswith("sess_")
|
||||
assert session.run_id.startswith("run_")
|
||||
assert session.trace_id.startswith("trace_")
|
||||
|
||||
print(f"[TEST] Trace IDs: session={session.session_id[:15]}, run={session.run_id}, trace={session.trace_id}")
|
||||
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Integration tests for ACP stdio communication.
|
||||
|
||||
Tests the ACP server by spawning it as a subprocess and communicating
|
||||
via JSON-RPC over stdio.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestACPStdioIntegration:
|
||||
"""Integration tests for ACP stdio communication."""
|
||||
|
||||
@pytest.fixture
|
||||
def acp_process(self):
|
||||
"""Start ACP server as subprocess."""
|
||||
# Use the CLI module directly to avoid crewai import issues
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
sys.executable, "-c",
|
||||
"""
|
||||
import sys
|
||||
import asyncio
|
||||
from praisonai.acp.server import ACPServer
|
||||
from praisonai.acp.config import ACPConfig
|
||||
|
||||
async def run():
|
||||
config = ACPConfig(debug=True)
|
||||
server = ACPServer(config=config)
|
||||
|
||||
# Simple JSON-RPC handler for testing
|
||||
import json
|
||||
|
||||
while True:
|
||||
try:
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
break
|
||||
|
||||
request = json.loads(line.strip())
|
||||
method = request.get("method", "")
|
||||
params = request.get("params", {})
|
||||
req_id = request.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
result = await server.initialize(
|
||||
protocol_version=params.get("protocolVersion", 1),
|
||||
client_capabilities=params.get("clientCapabilities"),
|
||||
client_info=params.get("clientInfo"),
|
||||
)
|
||||
elif method == "session/new":
|
||||
result = await server.new_session(
|
||||
cwd=params.get("cwd", "."),
|
||||
mcp_servers=params.get("mcpServers", []),
|
||||
)
|
||||
elif method == "session/cancel":
|
||||
await server.cancel(session_id=params.get("sessionId", ""))
|
||||
result = None
|
||||
else:
|
||||
result = {"error": f"Unknown method: {method}"}
|
||||
|
||||
response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": result,
|
||||
}
|
||||
print(json.dumps(response), flush=True)
|
||||
except Exception as e:
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": None, "error": {"code": -32603, "message": str(e)}}), flush=True)
|
||||
|
||||
asyncio.run(run())
|
||||
"""
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
yield proc
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
|
||||
def send_request(self, proc, method: str, params: dict, req_id: int = 1) -> dict:
|
||||
"""Send JSON-RPC request and get response."""
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
proc.stdin.write(json.dumps(request) + "\n")
|
||||
proc.stdin.flush()
|
||||
|
||||
# Read response with timeout
|
||||
response_line = proc.stdout.readline()
|
||||
if response_line:
|
||||
return json.loads(response_line.strip())
|
||||
return None
|
||||
|
||||
def test_initialize(self, acp_process):
|
||||
"""Test initialize handshake."""
|
||||
response = self.send_request(
|
||||
acp_process,
|
||||
"initialize",
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"clientCapabilities": {"fs": {"readTextFile": True}},
|
||||
"clientInfo": {"name": "test-client", "version": "1.0"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response["jsonrpc"] == "2.0"
|
||||
assert response["id"] == 1
|
||||
assert "result" in response
|
||||
|
||||
result = response["result"]
|
||||
assert result["protocolVersion"] == 1
|
||||
assert "agentCapabilities" in result
|
||||
assert result["agentInfo"]["name"] == "praisonai"
|
||||
|
||||
def test_new_session(self, acp_process):
|
||||
"""Test session creation."""
|
||||
# First initialize
|
||||
self.send_request(
|
||||
acp_process,
|
||||
"initialize",
|
||||
{"protocolVersion": 1},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
# Then create session
|
||||
response = self.send_request(
|
||||
acp_process,
|
||||
"session/new",
|
||||
{
|
||||
"cwd": "/tmp/test",
|
||||
"mcpServers": [],
|
||||
},
|
||||
req_id=2,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response["id"] == 2
|
||||
assert "result" in response
|
||||
|
||||
result = response["result"]
|
||||
assert "sessionId" in result
|
||||
assert result["sessionId"].startswith("sess_")
|
||||
|
||||
def test_cancel_notification(self, acp_process):
|
||||
"""Test cancel notification."""
|
||||
# Initialize
|
||||
self.send_request(acp_process, "initialize", {"protocolVersion": 1}, req_id=1)
|
||||
|
||||
# Create session
|
||||
new_resp = self.send_request(
|
||||
acp_process,
|
||||
"session/new",
|
||||
{"cwd": "/tmp", "mcpServers": []},
|
||||
req_id=2,
|
||||
)
|
||||
session_id = new_resp["result"]["sessionId"]
|
||||
|
||||
# Send cancel (notification, but we're treating it as request for testing)
|
||||
response = self.send_request(
|
||||
acp_process,
|
||||
"session/cancel",
|
||||
{"sessionId": session_id},
|
||||
req_id=3,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response["id"] == 3
|
||||
|
||||
|
||||
class TestACPNoStdoutPollution:
|
||||
"""Tests to ensure ACP doesn't pollute stdout."""
|
||||
|
||||
def test_no_print_statements(self):
|
||||
"""Verify ACP modules don't have print statements that would pollute stdout."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
acp_dir = Path(__file__).parent.parent.parent.parent / "praisonai" / "acp"
|
||||
|
||||
for py_file in acp_dir.glob("*.py"):
|
||||
content = py_file.read_text()
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call):
|
||||
if isinstance(node.func, ast.Name) and node.func.id == "print":
|
||||
# Check if it's inside a function that's not for debugging
|
||||
pytest.fail(f"Found print() in {py_file.name} - stdout must be reserved for JSON-RPC")
|
||||
except SyntaxError:
|
||||
pass # Skip files with syntax errors
|
||||
|
||||
def test_logging_to_stderr(self):
|
||||
"""Verify logging is configured for stderr."""
|
||||
from praisonai.acp.server import _setup_stderr_logging
|
||||
import logging
|
||||
import sys
|
||||
|
||||
_setup_stderr_logging(debug=True)
|
||||
|
||||
logger = logging.getLogger("praisonai.acp")
|
||||
|
||||
# Check that handlers write to stderr
|
||||
for handler in logger.handlers:
|
||||
if hasattr(handler, 'stream'):
|
||||
assert handler.stream == sys.stderr, "Logger must write to stderr, not stdout"
|
||||
|
||||
|
||||
class TestACPInteractiveModeCompatibility:
|
||||
"""Tests for ACP and interactive mode compatibility."""
|
||||
|
||||
def test_acp_import_does_not_break_interactive(self):
|
||||
"""Verify importing ACP doesn't break interactive mode imports."""
|
||||
# This should not raise any errors
|
||||
from praisonai.acp import ACPConfig
|
||||
|
||||
# Interactive mode imports should still work
|
||||
from praisonai.cli.features.interactive_tui import InteractiveConfig
|
||||
|
||||
# Both should be usable
|
||||
acp_config = ACPConfig()
|
||||
interactive_config = InteractiveConfig()
|
||||
|
||||
assert acp_config is not None
|
||||
assert interactive_config is not None
|
||||
|
||||
def test_acp_lazy_import(self):
|
||||
"""Verify ACP uses lazy imports."""
|
||||
import time
|
||||
|
||||
start = time.time()
|
||||
import praisonai.acp as acp_module # noqa: F401
|
||||
import_time = time.time() - start
|
||||
|
||||
# Import should be fast (< 100ms)
|
||||
assert import_time < 0.1, f"ACP import too slow: {import_time}s"
|
||||
|
||||
def test_interactive_mode_without_acp_sdk(self):
|
||||
"""Verify interactive mode works even if ACP SDK is not installed."""
|
||||
# Mock the ACP SDK not being available
|
||||
import sys
|
||||
|
||||
# Save original
|
||||
original_modules = sys.modules.copy()
|
||||
|
||||
try:
|
||||
# Remove acp from modules if present
|
||||
if 'acp' in sys.modules:
|
||||
del sys.modules['acp']
|
||||
|
||||
# Interactive mode should still work
|
||||
from praisonai.cli.features.interactive_tui import InteractiveSession
|
||||
|
||||
session = InteractiveSession()
|
||||
assert session is not None
|
||||
finally:
|
||||
# Restore
|
||||
sys.modules.update(original_modules)
|
||||
|
||||
|
||||
class TestACPSessionPersistence:
|
||||
"""Tests for ACP session persistence."""
|
||||
|
||||
def test_session_save_and_load(self):
|
||||
"""Test session persistence across server restarts."""
|
||||
from praisonai.acp.session import ACPSession, SessionStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = SessionStore(storage_dir=Path(tmpdir))
|
||||
|
||||
# Create and save session
|
||||
session = ACPSession.create(workspace=Path("/tmp/test"))
|
||||
session.add_message("user", "Hello")
|
||||
session.add_message("assistant", "Hi there!")
|
||||
store.save(session)
|
||||
|
||||
# Create new store instance (simulating restart)
|
||||
store2 = SessionStore(storage_dir=Path(tmpdir))
|
||||
|
||||
# Load session
|
||||
loaded = store2.load(session.session_id)
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.session_id == session.session_id
|
||||
assert len(loaded.messages) == 2
|
||||
assert loaded.messages[0]["content"] == "Hello"
|
||||
|
||||
def test_resume_last_session(self):
|
||||
"""Test --resume --last functionality."""
|
||||
from praisonai.acp.session import ACPSession, SessionStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = SessionStore(storage_dir=Path(tmpdir))
|
||||
|
||||
# Create multiple sessions
|
||||
session1 = ACPSession.create(workspace=Path("/tmp/test1"))
|
||||
store.save(session1)
|
||||
|
||||
time.sleep(0.01) # Ensure different timestamps
|
||||
|
||||
session2 = ACPSession.create(workspace=Path("/tmp/test2"))
|
||||
session2.add_message("user", "Last session message")
|
||||
store.save(session2)
|
||||
|
||||
# Load last session
|
||||
last = store.load_last()
|
||||
|
||||
assert last is not None
|
||||
assert last.session_id == session2.session_id
|
||||
assert len(last.messages) == 1
|
||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user