Compare commits

...
8 Commits
Author SHA1 Message Date
Mervin Praison b686e5b00f Release v2.9.1 2025-12-31 16:42:54 +00:00
Mervin Praison d8d84ee3e3 Release v2.9.0 2025-12-31 15:49:07 +00:00
Mervin Praison 9f2c4d10b5 Release v2.8.9 2025-12-31 14:05:30 +00:00
Mervin Praison 42a86c831f Release v2.8.8 2025-12-31 12:46:10 +00:00
Mervin Praison 48443b11d6 Release v2.8.7 2025-12-31 12:28:44 +00:00
Mervin Praison 71745aa14a Release v2.8.6 2025-12-31 12:27:20 +00:00
Mervin Praison 4f309ec16d Release v2.8.5 2025-12-31 12:00:29 +00:00
Mervin Praison e48afc8993 Release v2.8.4 2025-12-31 11:39:37 +00:00
82 changed files with 12817 additions and 144 deletions

No files matched your search

+71
View File
@@ -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
)
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.8.3" \
"praisonai>=2.9.1" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.8.3" \
"praisonai>=2.9.1" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.8.3" \
"praisonai>=2.9.1" \
"praisonai[ui]" \
"praisonai[crewai]"
+137
View File
@@ -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
+27
View File
@@ -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
)
+45
View File
@@ -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())
+228
View File
@@ -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()
+130
View File
@@ -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())
+133
View File
@@ -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)
+32 -8
View File
@@ -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():
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.5.2"
version = "0.5.3"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+1
View File
@@ -0,0 +1 @@
chainlit.md
+137
View File
@@ -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",
)
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.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.1.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.9.1.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+8 -3
View File
@@ -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()
+54
View File
@@ -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"
)
+159
View File
@@ -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,
}
+587
View File
@@ -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))
+219
View File
@@ -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
+199
View File
@@ -0,0 +1,199 @@
"""
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
# 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 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()
+112
View File
@@ -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"
)
+210
View File
@@ -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)
+228
View File
@@ -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}")
+236
View File
@@ -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,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)
+235
View File
@@ -0,0 +1,235 @@
"""
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',
}
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()
+34 -2
View File
@@ -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
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==2.8.3 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.9.1 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+92 -1
View File
@@ -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',
]
+52 -24
View File
@@ -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})
+32 -17
View File
@@ -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})
+6 -2
View File
@@ -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
View File
@@ -1 +1 @@
__version__ = "2.8.3"
__version__ = "2.9.1"
+9 -5
View File
@@ -12,11 +12,12 @@ 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",
]
[project.scripts]
@@ -26,7 +27,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 +46,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 +58,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 +69,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 +101,9 @@ autogen-v4 = [
"praisonai-tools>=0.1.0",
"crewai"
]
acp = [
"agent-client-protocol>=0.7.0",
]
[project.urls]
Homepage = "https://docs.praison.ai"
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Unit tests for ACP module."""
+166
View File
@@ -0,0 +1,166 @@
"""Unit tests for ACP configuration."""
import os
import tempfile
from pathlib import Path
from praisonai.acp.config import ACPConfig
class TestACPConfig:
"""Tests for ACPConfig class."""
def test_default_config(self):
"""Test default configuration values."""
config = ACPConfig()
assert config.workspace == Path.cwd()
assert config.agent == "default"
assert config.agents_config is None
assert config.router_enabled is False
assert config.model is None
assert config.resume_session is None
assert config.resume_last is False
assert config.read_only is True
assert config.allow_write is False
assert config.allow_shell is False
assert config.allow_network is False
assert config.approval_mode == "manual"
assert config.debug is False
assert config.profile is None
def test_workspace_normalization(self):
"""Test workspace path normalization."""
config = ACPConfig(workspace=".")
assert config.workspace == Path.cwd()
config = ACPConfig(workspace="/tmp")
# Use resolve() to handle macOS symlinks (/tmp -> /private/tmp)
assert config.workspace == Path("/tmp").resolve()
def test_allowed_paths_default(self):
"""Test default allowed paths."""
config = ACPConfig()
assert config.workspace in config.allowed_paths
assert Path.home() / ".praison" in config.allowed_paths
def test_is_path_allowed(self):
"""Test path allowlist checking."""
config = ACPConfig(workspace="/tmp/test_workspace")
# Workspace itself should be allowed
assert config.is_path_allowed(Path("/tmp/test_workspace"))
# Subdirectories should be allowed
assert config.is_path_allowed(Path("/tmp/test_workspace/subdir"))
# Parent directories should NOT be allowed
assert not config.is_path_allowed(Path("/tmp"))
# Unrelated paths should NOT be allowed
assert not config.is_path_allowed(Path("/var/log"))
def test_can_write(self):
"""Test write permission checking."""
# Read-only mode
config = ACPConfig(read_only=True, allow_write=False)
assert not config.can_write()
# Allow write overrides read-only
config = ACPConfig(read_only=True, allow_write=True)
assert config.can_write()
# Not read-only
config = ACPConfig(read_only=False)
assert config.can_write()
def test_can_execute_shell(self):
"""Test shell execution permission checking."""
config = ACPConfig(allow_shell=False)
assert not config.can_execute_shell()
config = ACPConfig(allow_shell=True)
assert config.can_execute_shell()
def test_from_env(self, monkeypatch):
"""Test configuration from environment variables."""
monkeypatch.setenv("PRAISONAI_WORKSPACE", "/tmp/env_workspace")
monkeypatch.setenv("PRAISONAI_AGENT", "test_agent")
monkeypatch.setenv("PRAISONAI_MODEL", "gpt-4")
monkeypatch.setenv("PRAISONAI_DEBUG", "true")
monkeypatch.setenv("PRAISONAI_READ_ONLY", "false")
monkeypatch.setenv("PRAISONAI_APPROVAL_MODE", "auto")
config = ACPConfig.from_env()
# Use resolve() to handle macOS symlinks
assert config.workspace == Path("/tmp/env_workspace").resolve()
assert config.agent == "test_agent"
assert config.model == "gpt-4"
assert config.debug is True
assert config.read_only is False
assert config.approval_mode == "auto"
def test_from_file(self):
"""Test configuration from YAML file."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
f.write("""
acp:
workspace: /tmp/file_workspace
agent: file_agent
model: gpt-3.5-turbo
debug: true
read_only: false
approval_mode: scoped
""")
f.flush()
config = ACPConfig.from_file(Path(f.name))
# Use resolve() to handle macOS symlinks
assert config.workspace == Path("/tmp/file_workspace").resolve()
assert config.agent == "file_agent"
assert config.model == "gpt-3.5-turbo"
assert config.debug is True
assert config.read_only is False
assert config.approval_mode == "scoped"
os.unlink(f.name)
def test_from_file_missing(self):
"""Test configuration from non-existent file."""
config = ACPConfig.from_file(Path("/nonexistent/config.yaml"))
# Should return default config
assert config.agent == "default"
assert config.debug is False
def test_to_dict(self):
"""Test configuration serialization."""
config = ACPConfig(
workspace="/tmp/test",
agent="test_agent",
model="gpt-4",
debug=True,
)
data = config.to_dict()
# Use resolve() to handle macOS symlinks
assert data["workspace"] == str(Path("/tmp/test").resolve())
assert data["agent"] == "test_agent"
assert data["model"] == "gpt-4"
assert data["debug"] is True
def test_merge(self):
"""Test configuration merging."""
config1 = ACPConfig(agent="agent1", model="model1")
config2 = ACPConfig(agent="agent2", debug=True)
merged = ACPConfig.merge(config1, config2)
# Later config overrides
assert merged.agent == "agent2"
assert merged.model == "model1" # Not overridden
assert merged.debug is True
+244
View File
@@ -0,0 +1,244 @@
"""Unit tests for ACP server."""
import asyncio
from pathlib import Path
from praisonai.acp.config import ACPConfig
from praisonai.acp.server import ACPServer
class TestACPServer:
"""Tests for ACPServer class."""
def test_server_init(self):
"""Test server initialization."""
config = ACPConfig(workspace="/tmp/test", debug=True)
server = ACPServer(config=config)
assert server.config == config
assert server._sessions == {}
assert server._cancelled_sessions == set()
def test_server_init_default_config(self):
"""Test server initialization with default config."""
server = ACPServer()
assert server.config is not None
assert server.config.workspace == Path.cwd()
def test_initialize(self):
"""Test initialize handler."""
server = ACPServer()
result = asyncio.run(server.initialize(
protocol_version=1,
client_capabilities={"fs": {"readTextFile": True}},
client_info={"name": "test-client", "version": "1.0"},
))
assert result["protocolVersion"] == 1
assert "agentCapabilities" in result
assert result["agentCapabilities"]["loadSession"] is True
assert result["agentInfo"]["name"] == "praisonai"
def test_new_session(self):
"""Test new_session handler."""
server = ACPServer()
result = asyncio.run(server.new_session(
cwd="/tmp/test_workspace",
mcp_servers=[],
))
assert "sessionId" in result
assert result["sessionId"].startswith("sess_")
assert result["sessionId"] in server._sessions
def test_new_session_with_mcp_servers(self):
"""Test new_session with MCP servers."""
server = ACPServer()
mcp_servers = [
{"name": "test", "command": "/usr/bin/test", "args": []}
]
result = asyncio.run(server.new_session(
cwd="/tmp/test",
mcp_servers=mcp_servers,
))
session = server._sessions[result["sessionId"]]
assert session.mcp_servers == mcp_servers
def test_set_session_mode(self):
"""Test set_session_mode handler."""
server = ACPServer()
# Create a session first
new_result = asyncio.run(server.new_session(
cwd="/tmp/test",
mcp_servers=[],
))
session_id = new_result["sessionId"]
# Set mode
result = asyncio.run(server.set_session_mode(
mode_id="auto",
session_id=session_id,
))
assert result == {}
assert server._sessions[session_id].mode == "auto"
def test_cancel(self):
"""Test cancel handler."""
server = ACPServer()
# Create a session first
new_result = asyncio.run(server.new_session(
cwd="/tmp/test",
mcp_servers=[],
))
session_id = new_result["sessionId"]
# Cancel it
asyncio.run(server.cancel(session_id=session_id))
assert session_id in server._cancelled_sessions
def test_authenticate(self):
"""Test authenticate handler."""
server = ACPServer()
result = asyncio.run(server.authenticate(method_id="test"))
assert result == {}
def test_list_sessions(self):
"""Test list_sessions handler."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
from praisonai.acp.session import SessionStore
server = ACPServer()
# Use isolated session store
server._session_store = SessionStore(storage_dir=Path(tmpdir))
# Create some sessions
asyncio.run(server.new_session(cwd="/tmp/test1", mcp_servers=[]))
asyncio.run(server.new_session(cwd="/tmp/test2", mcp_servers=[]))
result = asyncio.run(server.list_sessions())
assert "sessions" in result
assert len(result["sessions"]) == 2
def test_fork_session(self):
"""Test fork_session handler."""
server = ACPServer()
# Create original session
original = asyncio.run(server.new_session(
cwd="/tmp/test",
mcp_servers=[],
))
original_id = original["sessionId"]
# Add a message to original
server._sessions[original_id].add_message("user", "Hello")
# Fork it
forked = asyncio.run(server.fork_session(
cwd="/tmp/test2",
session_id=original_id,
))
assert forked["sessionId"] != original_id
assert forked["sessionId"] in server._sessions
# Forked session should have the message
forked_session = server._sessions[forked["sessionId"]]
assert len(forked_session.messages) == 1
def test_extract_prompt_text(self):
"""Test prompt text extraction."""
server = ACPServer()
prompt = [
{"type": "text", "text": "Hello world"},
{"type": "text", "text": "How are you?"},
]
result = server._extract_prompt_text(prompt)
assert "Hello world" in result
assert "How are you?" in result
def test_extract_prompt_text_with_resource(self):
"""Test prompt text extraction with resource."""
server = ACPServer()
prompt = [
{"type": "text", "text": "Check this file:"},
{
"type": "resource",
"resource": {
"uri": "file:///tmp/test.py",
"text": "print('hello')",
}
},
]
result = server._extract_prompt_text(prompt)
assert "Check this file:" in result
assert "print('hello')" in result
assert "file:///tmp/test.py" in result
def test_get_available_modes(self):
"""Test available modes."""
server = ACPServer()
modes = server._get_available_modes()
assert modes is not None
assert len(modes) >= 2
mode_ids = [m["id"] for m in modes]
assert "manual" in mode_ids
assert "auto" in mode_ids
class TestACPServerPrompt:
"""Tests for ACPServer prompt handling."""
def test_prompt_cancelled(self):
"""Test prompt with cancelled session."""
server = ACPServer()
# Create session
new_result = asyncio.run(server.new_session(
cwd="/tmp/test",
mcp_servers=[],
))
session_id = new_result["sessionId"]
# Mark as cancelled
server._cancelled_sessions.add(session_id)
# Try to prompt
result = asyncio.run(server.prompt(
prompt=[{"type": "text", "text": "Hello"}],
session_id=session_id,
))
assert result["stopReason"] == "cancelled"
def test_prompt_nonexistent_session(self):
"""Test prompt with non-existent session."""
server = ACPServer()
result = asyncio.run(server.prompt(
prompt=[{"type": "text", "text": "Hello"}],
session_id="nonexistent",
))
assert result["stopReason"] == "refusal"
@@ -0,0 +1,203 @@
"""Unit tests for ACP session management."""
import tempfile
import time
from pathlib import Path
from praisonai.acp.session import ACPSession, SessionStore
class TestACPSession:
"""Tests for ACPSession class."""
def test_create_session(self):
"""Test session creation."""
session = ACPSession.create(
workspace=Path("/tmp/test"),
agent_id="test_agent",
)
assert session.session_id.startswith("sess_")
assert session.workspace == Path("/tmp/test")
assert session.agent_id == "test_agent"
assert session.run_id is not None
assert session.trace_id is not None
assert session.mode == "manual"
def test_update_activity(self):
"""Test activity timestamp update."""
session = ACPSession.create(workspace=Path("/tmp"))
original_time = session.last_activity
time.sleep(0.01)
session.update_activity()
assert session.last_activity > original_time
def test_add_message(self):
"""Test adding messages to session."""
session = ACPSession.create(workspace=Path("/tmp"))
session.add_message("user", "Hello")
session.add_message("assistant", "Hi there!")
assert len(session.messages) == 2
assert session.messages[0]["role"] == "user"
assert session.messages[0]["content"] == "Hello"
assert session.messages[1]["role"] == "assistant"
assert session.messages[1]["content"] == "Hi there!"
def test_add_tool_call(self):
"""Test adding tool calls to session."""
session = ACPSession.create(workspace=Path("/tmp"))
session.add_tool_call(
tool_call_id="call_001",
title="Read file",
status="completed",
path="/tmp/test.txt",
)
assert len(session.tool_calls) == 1
assert session.tool_calls[0]["tool_call_id"] == "call_001"
assert session.tool_calls[0]["title"] == "Read file"
assert session.tool_calls[0]["status"] == "completed"
assert session.tool_calls[0]["path"] == "/tmp/test.txt"
def test_to_dict(self):
"""Test session serialization."""
session = ACPSession.create(
workspace=Path("/tmp/test"),
agent_id="test_agent",
)
session.add_message("user", "Test message")
data = session.to_dict()
assert data["session_id"] == session.session_id
assert data["workspace"] == "/tmp/test"
assert data["agent_id"] == "test_agent"
assert len(data["messages"]) == 1
def test_from_dict(self):
"""Test session deserialization."""
data = {
"session_id": "sess_test123",
"workspace": "/tmp/test",
"created_at": 1234567890.0,
"last_activity": 1234567891.0,
"agent_id": "test_agent",
"run_id": "run_abc",
"trace_id": "trace_xyz",
"mode": "auto",
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"tool_calls": [],
"mcp_servers": [],
}
session = ACPSession.from_dict(data)
assert session.session_id == "sess_test123"
assert session.workspace == Path("/tmp/test")
assert session.agent_id == "test_agent"
assert session.mode == "auto"
assert session.model == "gpt-4"
assert len(session.messages) == 1
class TestSessionStore:
"""Tests for SessionStore class."""
def test_save_and_load(self):
"""Test saving and loading sessions."""
with tempfile.TemporaryDirectory() as tmpdir:
store = SessionStore(storage_dir=Path(tmpdir))
session = ACPSession.create(workspace=Path("/tmp/test"))
session.add_message("user", "Test")
store.save(session)
loaded = store.load(session.session_id)
assert loaded is not None
assert loaded.session_id == session.session_id
assert len(loaded.messages) == 1
def test_load_nonexistent(self):
"""Test loading non-existent session."""
with tempfile.TemporaryDirectory() as tmpdir:
store = SessionStore(storage_dir=Path(tmpdir))
loaded = store.load("nonexistent_session")
assert loaded is None
def test_load_last(self):
"""Test loading the last session."""
with tempfile.TemporaryDirectory() as tmpdir:
store = SessionStore(storage_dir=Path(tmpdir))
# Create and save multiple sessions
session1 = ACPSession.create(workspace=Path("/tmp/test1"))
store.save(session1)
session2 = ACPSession.create(workspace=Path("/tmp/test2"))
store.save(session2)
# Last saved should be session2
last = store.load_last()
assert last is not None
assert last.session_id == session2.session_id
def test_delete(self):
"""Test deleting a session."""
with tempfile.TemporaryDirectory() as tmpdir:
store = SessionStore(storage_dir=Path(tmpdir))
session = ACPSession.create(workspace=Path("/tmp/test"))
store.save(session)
# Verify it exists
assert store.load(session.session_id) is not None
# Delete it
result = store.delete(session.session_id)
assert result is True
# Verify it's gone
assert store.load(session.session_id) is None
def test_list_sessions(self):
"""Test listing sessions."""
with tempfile.TemporaryDirectory() as tmpdir:
store = SessionStore(storage_dir=Path(tmpdir))
# Create multiple sessions
for i in range(3):
session = ACPSession.create(workspace=Path(f"/tmp/test{i}"))
store.save(session)
time.sleep(0.01) # Ensure different timestamps
sessions = store.list_sessions()
assert len(sessions) == 3
# Should be sorted by last_activity, most recent first
assert sessions[0].last_activity >= sessions[1].last_activity
assert sessions[1].last_activity >= sessions[2].last_activity
def test_list_sessions_limit(self):
"""Test listing sessions with limit."""
with tempfile.TemporaryDirectory() as tmpdir:
store = SessionStore(storage_dir=Path(tmpdir))
# Create multiple sessions
for i in range(5):
session = ACPSession.create(workspace=Path(f"/tmp/test{i}"))
store.save(session)
sessions = store.list_sessions(limit=2)
assert len(sessions) == 2
+1
View File
@@ -0,0 +1 @@
"""Unit tests for PraisonAI CLI."""
@@ -0,0 +1,414 @@
"""
Tests for PraisonAI Typer CLI.
Tests the new modular Typer-based CLI structure.
"""
import json
import os
import pytest
from pathlib import Path
from typer.testing import CliRunner
from unittest.mock import patch, MagicMock
# Create CLI runner
runner = CliRunner()
class TestTyperApp:
"""Tests for the main Typer app."""
def test_app_import(self):
"""Test that the Typer app can be imported."""
from praisonai.cli.app import app
assert app is not None
def test_help_output(self):
"""Test --help shows expected content."""
from praisonai.cli.app import app
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
assert "PraisonAI" in result.output
assert "config" in result.output
assert "traces" in result.output
assert "session" in result.output
def test_version_flag(self):
"""Test --version flag."""
from praisonai.cli.app import app
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
assert "PraisonAI version" in result.output
class TestConfigCommand:
"""Tests for config command group."""
def test_config_list(self):
"""Test config list command."""
from praisonai.cli.commands.config import app
result = runner.invoke(app, ["list"])
assert result.exit_code == 0
def test_config_get_missing_key(self):
"""Test config get with missing key."""
from praisonai.cli.commands.config import app
result = runner.invoke(app, ["get", "nonexistent.key"])
assert result.exit_code == 1
assert "not found" in result.output.lower()
def test_config_path(self):
"""Test config path command."""
from praisonai.cli.commands.config import app
result = runner.invoke(app, ["path"])
assert result.exit_code == 0
assert "config.toml" in result.output
class TestTracesCommand:
"""Tests for traces command group."""
def test_traces_status(self):
"""Test traces status command."""
from praisonai.cli.commands.traces import app
result = runner.invoke(app, ["status"])
assert result.exit_code == 0
assert "Trace" in result.output or "trace" in result.output.lower()
def test_traces_enable(self):
"""Test traces enable command."""
from praisonai.cli.commands.traces import app
result = runner.invoke(app, ["enable"])
assert result.exit_code == 0
class TestEnvCommand:
"""Tests for env command group."""
def test_env_view(self):
"""Test env view command."""
from praisonai.cli.commands.environment import app
result = runner.invoke(app, ["view"])
# Should succeed even if no env vars found
assert result.exit_code == 0
def test_env_check(self):
"""Test env check command."""
from praisonai.cli.commands.environment import app
result = runner.invoke(app, ["check"])
# May exit with 3 if keys are missing, but should not crash
assert result.exit_code in [0, 3]
class TestSessionCommand:
"""Tests for session command group."""
def test_session_list(self):
"""Test session list command."""
from praisonai.cli.commands.session import app
result = runner.invoke(app, ["list"])
assert result.exit_code == 0
class TestCompletionCommand:
"""Tests for completion command group."""
def test_completion_bash(self):
"""Test bash completion generation."""
from praisonai.cli.commands.completion import app
result = runner.invoke(app, ["bash"])
assert result.exit_code == 0
assert "praisonai" in result.output
assert "complete" in result.output or "COMPREPLY" in result.output
def test_completion_zsh(self):
"""Test zsh completion generation."""
from praisonai.cli.commands.completion import app
result = runner.invoke(app, ["zsh"])
assert result.exit_code == 0
assert "praisonai" in result.output
assert "compdef" in result.output or "_praisonai" in result.output
def test_completion_fish(self):
"""Test fish completion generation."""
from praisonai.cli.commands.completion import app
result = runner.invoke(app, ["fish"])
assert result.exit_code == 0
assert "praisonai" in result.output
assert "complete" in result.output
class TestVersionCommand:
"""Tests for version command group."""
def test_version_show(self):
"""Test version show command."""
from praisonai.cli.commands.version import app
result = runner.invoke(app, ["show"])
assert result.exit_code == 0
assert "PraisonAI" in result.output or "praisonai" in result.output.lower()
class TestMCPCommand:
"""Tests for MCP command group."""
def test_mcp_list(self):
"""Test mcp list command."""
from praisonai.cli.commands.mcp import app
result = runner.invoke(app, ["list"])
assert result.exit_code == 0
class TestOutputController:
"""Tests for output controller."""
def test_output_modes(self):
"""Test output mode enumeration."""
from praisonai.cli.output.console import OutputMode
assert OutputMode.TEXT == "text"
assert OutputMode.JSON == "json"
assert OutputMode.STREAM_JSON == "stream-json"
assert OutputMode.SCREEN_READER == "screen-reader"
assert OutputMode.QUIET == "quiet"
assert OutputMode.VERBOSE == "verbose"
def test_output_controller_creation(self):
"""Test output controller creation."""
from praisonai.cli.output.console import OutputController, OutputMode
controller = OutputController(mode=OutputMode.TEXT)
assert controller.mode == OutputMode.TEXT
assert not controller.is_json_mode
controller = OutputController(mode=OutputMode.JSON)
assert controller.is_json_mode
def test_stream_event(self):
"""Test stream event creation."""
from praisonai.cli.output.console import StreamEvent
event = StreamEvent(
event_type="start",
run_id="run_123",
trace_id="trace_456",
message="Test message"
)
data = event.to_dict()
assert data["event"] == "start"
assert data["run_id"] == "run_123"
assert data["trace_id"] == "trace_456"
assert data["message"] == "Test message"
assert "timestamp" in data
class TestConfigurationSystem:
"""Tests for configuration system."""
def test_config_schema(self):
"""Test config schema creation."""
from praisonai.cli.configuration.schema import ConfigSchema, DEFAULT_CONFIG
assert DEFAULT_CONFIG is not None
assert DEFAULT_CONFIG.output.format == "text"
assert DEFAULT_CONFIG.traces.enabled == False
def test_config_to_dict(self):
"""Test config to dict conversion."""
from praisonai.cli.configuration.schema import ConfigSchema
config = ConfigSchema()
data = config.to_dict()
assert "output" in data
assert "traces" in data
assert "mcp" in data
assert "model" in data
assert "session" in data
def test_config_from_dict(self):
"""Test config from dict creation."""
from praisonai.cli.configuration.schema import ConfigSchema
data = {
"output": {"format": "json", "verbose": True},
"traces": {"enabled": True},
}
config = ConfigSchema.from_dict(data)
assert config.output.format == "json"
assert config.output.verbose == True
assert config.traces.enabled == True
def test_config_paths(self):
"""Test config paths."""
from praisonai.cli.configuration.paths import (
get_user_config_path,
get_project_config_path,
get_sessions_dir,
)
user_path = get_user_config_path()
assert user_path.name == "config.toml"
assert ".praison" in str(user_path)
project_path = get_project_config_path()
assert project_path.name == "config.toml"
sessions_dir = get_sessions_dir()
assert "sessions" in str(sessions_dir)
class TestStateManagement:
"""Tests for state management."""
def test_run_id_generation(self):
"""Test run ID generation."""
from praisonai.cli.state.identifiers import generate_run_id
run_id = generate_run_id()
assert run_id.startswith("run_")
assert len(run_id) > 10
def test_trace_id_generation(self):
"""Test trace ID generation."""
from praisonai.cli.state.identifiers import generate_trace_id
trace_id = generate_trace_id()
assert trace_id.startswith("trace_")
def test_agent_id_generation(self):
"""Test agent ID generation is deterministic."""
from praisonai.cli.state.identifiers import generate_agent_id
agent_id1 = generate_agent_id("test_agent", "run_123", 0)
agent_id2 = generate_agent_id("test_agent", "run_123", 0)
assert agent_id1 == agent_id2
assert agent_id1.startswith("agent_")
def test_run_context(self):
"""Test run context creation."""
from praisonai.cli.state.identifiers import RunContext
ctx = RunContext()
assert ctx.run_id.startswith("run_")
assert ctx.trace_id.startswith("trace_")
# Test agent ID tracking
agent_id = ctx.get_agent_id("test_agent")
assert agent_id.startswith("agent_")
# Same agent should get same ID
agent_id2 = ctx.get_agent_id("test_agent")
assert agent_id == agent_id2
def test_context_to_dict(self):
"""Test context serialization."""
from praisonai.cli.state.identifiers import RunContext
ctx = RunContext(workspace="/test")
ctx.get_agent_id("agent1")
data = ctx.to_dict()
assert "run_id" in data
assert "trace_id" in data
assert "agents" in data
assert data["workspace"] == "/test"
class TestSessionManager:
"""Tests for session manager."""
def test_session_metadata(self):
"""Test session metadata creation."""
from praisonai.cli.state.sessions import SessionMetadata
from datetime import datetime
now = datetime.utcnow()
metadata = SessionMetadata(
session_id="test_session",
run_id="run_123",
trace_id="trace_456",
created_at=now,
updated_at=now,
name="Test Session",
)
assert metadata.session_id == "test_session"
assert metadata.name == "Test Session"
data = metadata.to_dict()
assert data["session_id"] == "test_session"
assert data["name"] == "Test Session"
class TestLegacyCompatibility:
"""Tests for legacy compatibility."""
def test_is_legacy_invocation(self):
"""Test legacy invocation detection."""
from praisonai.cli.legacy import is_legacy_invocation
# Legacy patterns
assert is_legacy_invocation(["--framework", "crewai"])
assert is_legacy_invocation(["--auto", "test"])
assert is_legacy_invocation(["chat"]) # Legacy command
# Typer patterns
assert not is_legacy_invocation(["config", "list"])
assert not is_legacy_invocation(["version", "show"])
def test_typer_commands_set(self):
"""Test TYPER_COMMANDS contains expected commands."""
from praisonai.cli.legacy import TYPER_COMMANDS
expected = {'config', 'traces', 'env', 'session', 'completion', 'version',
'debug', 'lsp', 'diag', 'doctor', 'acp', 'mcp', 'serve', 'schedule', 'run'}
for cmd in expected:
assert cmd in TYPER_COMMANDS
class TestGlobalOptions:
"""Tests for global CLI options."""
def test_json_output_option(self):
"""Test --json option."""
from praisonai.cli.app import app
result = runner.invoke(app, ["--json", "config", "list"])
# Should produce JSON output
assert result.exit_code == 0
def test_quiet_option(self):
"""Test --quiet option."""
from praisonai.cli.app import app
result = runner.invoke(app, ["--quiet", "version", "show"])
assert result.exit_code == 0
def test_no_color_option(self):
"""Test --no-color option."""
from praisonai.cli.app import app
result = runner.invoke(app, ["--no-color", "version", "show"])
assert result.exit_code == 0
class TestExitCodes:
"""Tests for deterministic exit codes."""
def test_success_exit_code(self):
"""Test success returns 0."""
from praisonai.cli.app import app
result = runner.invoke(app, ["version", "show"])
assert result.exit_code == 0
def test_usage_error_exit_code(self):
"""Test usage error returns 2."""
from praisonai.cli.app import app
result = runner.invoke(app, ["config", "get"]) # Missing required arg
assert result.exit_code == 2
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -196,5 +196,90 @@ class TestChatPyLogLevel:
assert log_level == "DEBUG"
class TestLocalFileStorageClient:
"""Tests for LocalFileStorageClient."""
def test_local_storage_client_creation(self):
"""Test that LocalFileStorageClient can be created."""
from praisonai.ui.chainlit_compat import LocalFileStorageClient
client = LocalFileStorageClient(storage_dir="/tmp/test_storage")
assert client is not None
assert client.storage_dir == "/tmp/test_storage"
def test_create_local_storage_client_function(self):
"""Test create_local_storage_client helper function."""
from praisonai.ui.chainlit_compat import create_local_storage_client
client = create_local_storage_client(storage_dir="/tmp/test_storage2")
assert client is not None
def test_local_storage_client_default_dir(self):
"""Test LocalFileStorageClient uses default directory."""
from praisonai.ui.chainlit_compat import LocalFileStorageClient
with patch.dict(os.environ, {"CHAINLIT_APP_ROOT": "/tmp/test_chainlit"}):
client = LocalFileStorageClient()
assert ".files" in client.storage_dir
class TestToolsLoading:
"""Tests for custom tools loading."""
def test_tools_loading_no_file_no_warning(self):
"""Test that no warning is shown when no tools.py exists."""
# The fix ensures no warning when tools.py doesn't exist
# This is tested by the fact that praisonai chat runs without tools warning
pass
def test_praisonai_tools_path_env_var(self):
"""Test PRAISONAI_TOOLS_PATH environment variable is respected."""
# This tests the resolution order in load_custom_tools
assert os.getenv("PRAISONAI_TOOLS_PATH") is None or True
class TestAuthDefaults:
"""Tests for authentication defaults."""
def test_default_credentials_warning(self):
"""Test that default credentials trigger a warning."""
# The warning is: "Using default admin credentials..."
# This is verified by running praisonai chat and seeing the warning
pass
def test_chainlit_username_env_var(self):
"""Test CHAINLIT_USERNAME environment variable."""
with patch.dict(os.environ, {"CHAINLIT_USERNAME": "testuser"}):
assert os.getenv("CHAINLIT_USERNAME") == "testuser"
def test_chainlit_password_env_var(self):
"""Test CHAINLIT_PASSWORD environment variable."""
with patch.dict(os.environ, {"CHAINLIT_PASSWORD": "testpass"}):
assert os.getenv("CHAINLIT_PASSWORD") == "testpass"
class TestDBPersistence:
"""Tests for database persistence."""
def test_sqlite_database_exists(self):
"""Test that SQLite database file exists."""
db_path = os.path.expanduser("~/.praison/database.sqlite")
# Database should exist after running praisonai chat
if os.path.exists(db_path):
assert os.path.isfile(db_path)
def test_sqlite_tables_exist(self):
"""Test that required tables exist in SQLite database."""
import sqlite3
db_path = os.path.expanduser("~/.praison/database.sqlite")
if os.path.exists(db_path):
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = [t[0] for t in cursor.fetchall()]
conn.close()
expected_tables = ['users', 'threads', 'steps', 'elements', 'feedbacks', 'settings']
for table in expected_tables:
assert table in tables, f"Table {table} not found"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+95 -73
View File
@@ -9,6 +9,18 @@ resolution-markers = [
"python_full_version < '3.11'",
]
[[package]]
name = "agent-client-protocol"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/7c/12da39be4f73026fd9b02144df5f64d803488cf1439aa221b0edb7c305e3/agent_client_protocol-0.7.1.tar.gz", hash = "sha256:8d7031209e14c3f2f987e3b95e7d9c3286158e7b2af1bf43d6aae5b8a429249f", size = 66226, upload-time = "2025-12-28T13:58:57.012Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/48/48d2fb454f911147432cd779f548e188274e1700f1cbe0a258e78158331a/agent_client_protocol-0.7.1-py3-none-any.whl", hash = "sha256:4ffe999488f2b23db26f09becdfaa2aaae6529f0847a52bca61bc2c628001c0f", size = 53771, upload-time = "2025-12-28T13:58:55.967Z" },
]
[[package]]
name = "agentops"
version = "0.3.16"
@@ -177,6 +189,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/ad/77fad9d6f974ec58d837cb49fb9b483d6227a420c4f908c3578633de1d47/alphashape-1.3.1-py2.py3-none-any.whl", hash = "sha256:96a5ddd5f09534a35f03a8916aeeaac00fe4d6bec2f9ad78f87f57be3007f795", size = 13122, upload-time = "2021-04-16T17:47:17.773Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -260,14 +281,15 @@ wheels = [
[[package]]
name = "asyncer"
version = "0.0.7"
version = "0.0.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/39/29/245ba9fa5769a1e3226c1157aedb372fe9dab28c4e1dcf6911d84d3a5e04/asyncer-0.0.7.tar.gz", hash = "sha256:d5e563fb0f56eb87b97257984703658a4f5bbdb52ff851b3e8ed864cc200b1d2", size = 14437, upload-time = "2024-04-30T06:26:00.79Z" }
sdist = { url = "https://files.pythonhosted.org/packages/33/3a/e3d6919a41328f837a1c73ac057a8be35276c350046a79af588c536bd0fc/asyncer-0.0.12.tar.gz", hash = "sha256:4ceaa472bf40f3778caa3bd7f7dfcbafec0a948a9688e3c90a1796469ebe5e19", size = 17909, upload-time = "2025-12-26T12:05:10.248Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/4b/40a1dc52fc26695b1e80a9e67dfb0fe7e6ddc57bbc5b61348e40c0045abb/asyncer-0.0.7-py3-none-any.whl", hash = "sha256:f0d579d4f67c4ead52ede3a45c854f462cae569058a8a6a68a4ebccac1c335d8", size = 8476, upload-time = "2024-04-30T06:25:58.991Z" },
{ url = "https://files.pythonhosted.org/packages/54/f6/ebdca24b43151ed2a7dcf98bab4b61fdfc50dbd7224b9b4de195099fa65e/asyncer-0.0.12-py3-none-any.whl", hash = "sha256:2eb8bb255bdecf04a81df2d47cf168401df1f65a5f62e1bccc1f86ba375c9913", size = 9320, upload-time = "2025-12-26T12:05:08.848Z" },
]
[[package]]
@@ -602,11 +624,12 @@ wheels = [
[[package]]
name = "chainlit"
version = "2.5.5"
version = "2.8.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
{ name = "asyncer" },
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
{ name = "click" },
{ name = "dataclasses-json" },
{ name = "fastapi" },
@@ -618,6 +641,7 @@ dependencies = [
{ name = "nest-asyncio" },
{ name = "packaging" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "python-dotenv" },
{ name = "python-multipart" },
@@ -625,13 +649,12 @@ dependencies = [
{ name = "starlette" },
{ name = "syncer" },
{ name = "tomli" },
{ name = "uptrace" },
{ name = "uvicorn" },
{ name = "watchfiles" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/32/e4a6ac0a8e7430cd7b76b712b8df2f83abf129e7b843180e319f647fd3be/chainlit-2.5.5.tar.gz", hash = "sha256:d6388f93b9089e3a80bc2bd286a157a4a9d8016dc2c806372fd77119bcc0d36c", size = 9520733, upload-time = "2025-04-14T13:27:15.432Z" }
sdist = { url = "https://files.pythonhosted.org/packages/46/71/d0465682910792ff382d604b9973dcd0115c682dde6c22753eaa4304afe3/chainlit-2.8.5.tar.gz", hash = "sha256:ebe73b3ad9d7e545454432eb280618ef92d69d6470eb66488149c9bb7f6c526d", size = 9884512, upload-time = "2025-11-07T22:58:11.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/b2/9bf8c07b18ef34dc70771a0e21f6f0a5392dd3a753135743178cd59f1b7f/chainlit-2.5.5-py3-none-any.whl", hash = "sha256:1783c7b868fe41158283b498020fa18a23c39d07319ad9927e429f1e888f6eb5", size = 9670768, upload-time = "2025-04-14T13:27:13.415Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e2/c9a1ff354acc05fe910a19a8053647674315c091f33fe3b1eef3c69fcbe7/chainlit-2.8.5-py3-none-any.whl", hash = "sha256:1d6ef81a14f4b9a526229b0e6839f364c8ef1a3335e648889d43c0c8f8444a8e", size = 9725985, upload-time = "2025-11-07T22:58:08.631Z" },
]
[[package]]
@@ -1130,16 +1153,17 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.115.6"
version = "0.128.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/93/72/d83b98cd106541e8f5e5bfab8ef2974ab45a62e8a6c5b5e6940f26d2ed4b/fastapi-0.115.6.tar.gz", hash = "sha256:9ec46f7addc14ea472958a96aae5b5de65f39721a46aaf5705c480d9a8b76654", size = 301336, upload-time = "2024-12-03T22:46:01.629Z" }
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/b3/7e4df40e585df024fac2f80d1a2d579c854ac37109675db2b0cc22c0bb9e/fastapi-0.115.6-py3-none-any.whl", hash = "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305", size = 94843, upload-time = "2024-12-03T22:45:59.368Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
]
[[package]]
@@ -2470,21 +2494,24 @@ wheels = [
[[package]]
name = "mcp"
version = "1.6.0"
version = "1.12.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "uvicorn" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/d2/f587cb965a56e992634bebc8611c5b579af912b74e04eb9164bd49527d21/mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723", size = 200031, upload-time = "2025-03-27T16:46:32.336Z" }
sdist = { url = "https://files.pythonhosted.org/packages/31/88/f6cb7e7c260cd4b4ce375f2b1614b33ce401f63af0f49f7141a2e9bf0a45/mcp-1.12.4.tar.gz", hash = "sha256:0765585e9a3a5916a3c3ab8659330e493adc7bd8b2ca6120c2d7a0c43e034ca5", size = 431148, upload-time = "2025-08-07T20:31:18.082Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/30/20a7f33b0b884a9d14dd3aa94ff1ac9da1479fe2ad66dd9e2736075d2506/mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0", size = 76077, upload-time = "2025-03-27T16:46:29.919Z" },
{ url = "https://files.pythonhosted.org/packages/ad/68/316cbc54b7163fa22571dcf42c9cc46562aae0a021b974e0a8141e897200/mcp-1.12.4-py3-none-any.whl", hash = "sha256:7aa884648969fab8e78b89399d59a683202972e12e6bc9a1c88ce7eda7743789", size = 160145, upload-time = "2025-08-07T20:31:15.69Z" },
]
[[package]]
@@ -2958,19 +2985,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/5a/3f8d078dbf55d18442f6a2ecedf6786d81d7245844b2b20ce2b8ad6f0307/opentelemetry_api-1.35.0-py3-none-any.whl", hash = "sha256:c4ea7e258a244858daf18474625e9cc0149b8ee354f37843415771a40c25ee06", size = 65566, upload-time = "2025-07-11T12:23:07.944Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp"
version = "1.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-exporter-otlp-proto-grpc" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/2e/63718faa67b17f449a7fb7efdc7125a408cbe5d8c0bb35f423f2776d60b5/opentelemetry_exporter_otlp-1.35.0.tar.gz", hash = "sha256:f94feff09b3524df867c7876b79c96cef20068106cb5efe55340e8d08192c8a4", size = 6142, upload-time = "2025-07-11T12:23:30.128Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/db/2da28358d3101ca936c1643becbb4ebd69e9e48acf27f153d735a4813c6b/opentelemetry_exporter_otlp-1.35.0-py3-none-any.whl", hash = "sha256:8e6bb9025f6238db7d69bba7ee37c77e4858d0a1ff22a9e126f7c9e017e83afe", size = 7016, upload-time = "2025-07-11T12:23:10.679Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.35.0"
@@ -3974,9 +3988,13 @@ dependencies = [
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "rich" },
{ name = "typer" },
]
[package.optional-dependencies]
acp = [
{ name = "agent-client-protocol" },
]
agentops = [
{ name = "agentops" },
]
@@ -4073,6 +4091,7 @@ ui = [
[package.metadata]
requires-dist = [
{ name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.7.0" },
{ name = "agentops", marker = "extra == 'agentops'", specifier = ">=0.3.12" },
{ name = "aiosqlite", marker = "extra == 'chat'", specifier = ">=0.20.0" },
{ name = "aiosqlite", marker = "extra == 'code'", specifier = ">=0.20.0" },
@@ -4081,10 +4100,10 @@ requires-dist = [
{ name = "autogen-agentchat", marker = "extra == 'autogen-v4'", specifier = ">=0.4.0" },
{ name = "autogen-core", marker = "extra == 'autogen-v4'", specifier = ">=0.4.0" },
{ name = "autogen-ext", extras = ["openai"], marker = "extra == 'autogen-v4'", specifier = ">=0.4.0" },
{ name = "chainlit", marker = "extra == 'chat'", specifier = "==2.5.5" },
{ name = "chainlit", marker = "extra == 'code'", specifier = "==2.5.5" },
{ name = "chainlit", marker = "extra == 'realtime'", specifier = "==2.5.5" },
{ name = "chainlit", marker = "extra == 'ui'", specifier = "==2.5.5" },
{ name = "chainlit", marker = "extra == 'chat'", specifier = ">=2.8.5,<2.9.0" },
{ name = "chainlit", marker = "extra == 'code'", specifier = ">=2.8.5,<2.9.0" },
{ name = "chainlit", marker = "extra == 'realtime'", specifier = ">=2.8.5,<2.9.0" },
{ name = "chainlit", marker = "extra == 'ui'", specifier = ">=2.8.5,<2.9.0" },
{ name = "crawl4ai", marker = "extra == 'chat'", specifier = ">=0.7.0" },
{ name = "crawl4ai", marker = "extra == 'code'", specifier = ">=0.7.0" },
{ name = "crawl4ai", marker = "extra == 'realtime'", specifier = ">=0.7.0" },
@@ -4118,7 +4137,7 @@ requires-dist = [
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
{ name = "praisonaiagents", specifier = ">=0.5.2" },
{ name = "praisonaiagents", specifier = ">=0.5.3" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
{ name = "pydantic", marker = "extra == 'chat'", specifier = "<=2.10.1" },
{ name = "pydantic", marker = "extra == 'code'", specifier = "<=2.10.1" },
@@ -4139,13 +4158,14 @@ requires-dist = [
{ name = "tavily-python", marker = "extra == 'code'", specifier = "==0.5.0" },
{ name = "tavily-python", marker = "extra == 'realtime'", specifier = "==0.5.0" },
{ name = "twilio", marker = "extra == 'call'", specifier = ">=7.0.0" },
{ name = "typer", specifier = ">=0.9.0" },
{ name = "uvicorn", marker = "extra == 'api'", specifier = ">=0.34.0" },
{ name = "uvicorn", marker = "extra == 'call'", specifier = ">=0.20.0" },
{ name = "websockets", marker = "extra == 'call'", specifier = ">=12.0" },
{ name = "websockets", marker = "extra == 'realtime'", specifier = ">=12.0" },
{ name = "yfinance", marker = "extra == 'realtime'", specifier = ">=0.2.44" },
]
provides-extras = ["ui", "gradio", "api", "agentops", "google", "openai", "anthropic", "cohere", "chat", "code", "realtime", "call", "train", "crewai", "autogen", "autogen-v4"]
provides-extras = ["ui", "gradio", "api", "agentops", "google", "openai", "anthropic", "cohere", "chat", "code", "realtime", "call", "train", "crewai", "autogen", "autogen-v4", "acp"]
[[package]]
name = "praisonai-tools"
@@ -4161,7 +4181,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.5.2"
version = "0.5.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -4170,9 +4190,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/e5/f0569d57595331894418c250cc40edb158a1476a4c7271448095ef3aa744/praisonaiagents-0.5.2.tar.gz", hash = "sha256:1d4f0616ca6a683b54edc84757ae9decaf14742acfa2398debdde65aef02ae32", size = 780477, upload-time = "2025-12-30T17:47:27.852Z" }
sdist = { url = "https://files.pythonhosted.org/packages/75/33/6091503673fb3c143293e9639bafe3acb6e7406e0ace1f893c34cbe22912/praisonaiagents-0.5.3.tar.gz", hash = "sha256:4163b00c28ef5e5d1a75411eb4b72fc7749790e52cdaa4bfc98c9a747fe42140", size = 780945, upload-time = "2025-12-31T16:36:45.335Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/7f/9b71b7c67a07e8a3085ab380a3fc2a8d69521543cae285ffb0c7462cebeb/praisonaiagents-0.5.2-py3-none-any.whl", hash = "sha256:a9fc263fe5664e3901ac2cee05e44a3cf0ea9e6fe732da786d89f2e862c8def1", size = 724488, upload-time = "2025-12-30T17:47:25.834Z" },
{ url = "https://files.pythonhosted.org/packages/32/a2/459f111873f1751a34bc780e1b3a2cd264cf580f35acff868fa35aa9d917/praisonaiagents-0.5.3-py3-none-any.whl", hash = "sha256:35bd6c26dfff6c9e77575f72a2f077cdbcc0544d0d45b0ed29fb00ca96c3719c", size = 724879, upload-time = "2025-12-31T16:36:43.383Z" },
]
[[package]]
@@ -4477,15 +4497,16 @@ wheels = [
[[package]]
name = "pydantic-settings"
version = "2.8.1"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550, upload-time = "2025-02-27T10:10:32.338Z" }
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839, upload-time = "2025-02-27T10:10:30.711Z" },
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
]
[[package]]
@@ -4691,21 +4712,24 @@ wheels = [
[[package]]
name = "pywin32"
version = "308"
version = "311"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/a6/3e9f2c474895c1bb61b11fa9640be00067b5c5b363c501ee9c3fa53aec01/pywin32-308-cp310-cp310-win32.whl", hash = "sha256:796ff4426437896550d2981b9c2ac0ffd75238ad9ea2d3bfa67a1abd546d262e", size = 5927028, upload-time = "2024-10-12T20:41:58.898Z" },
{ url = "https://files.pythonhosted.org/packages/d9/b4/84e2463422f869b4b718f79eb7530a4c1693e96b8a4e5e968de38be4d2ba/pywin32-308-cp310-cp310-win_amd64.whl", hash = "sha256:4fc888c59b3c0bef905ce7eb7e2106a07712015ea1c8234b703a088d46110e8e", size = 6558484, upload-time = "2024-10-12T20:42:01.271Z" },
{ url = "https://files.pythonhosted.org/packages/9f/8f/fb84ab789713f7c6feacaa08dad3ec8105b88ade8d1c4f0f0dfcaaa017d6/pywin32-308-cp310-cp310-win_arm64.whl", hash = "sha256:a5ab5381813b40f264fa3495b98af850098f814a25a63589a8e9eb12560f450c", size = 7971454, upload-time = "2024-10-12T20:42:03.544Z" },
{ url = "https://files.pythonhosted.org/packages/eb/e2/02652007469263fe1466e98439831d65d4ca80ea1a2df29abecedf7e47b7/pywin32-308-cp311-cp311-win32.whl", hash = "sha256:5d8c8015b24a7d6855b1550d8e660d8daa09983c80e5daf89a273e5c6fb5095a", size = 5928156, upload-time = "2024-10-12T20:42:05.78Z" },
{ url = "https://files.pythonhosted.org/packages/48/ef/f4fb45e2196bc7ffe09cad0542d9aff66b0e33f6c0954b43e49c33cad7bd/pywin32-308-cp311-cp311-win_amd64.whl", hash = "sha256:575621b90f0dc2695fec346b2d6302faebd4f0f45c05ea29404cefe35d89442b", size = 6559559, upload-time = "2024-10-12T20:42:07.644Z" },
{ url = "https://files.pythonhosted.org/packages/79/ef/68bb6aa865c5c9b11a35771329e95917b5559845bd75b65549407f9fc6b4/pywin32-308-cp311-cp311-win_arm64.whl", hash = "sha256:100a5442b7332070983c4cd03f2e906a5648a5104b8a7f50175f7906efd16bb6", size = 7972495, upload-time = "2024-10-12T20:42:09.803Z" },
{ url = "https://files.pythonhosted.org/packages/00/7c/d00d6bdd96de4344e06c4afbf218bc86b54436a94c01c71a8701f613aa56/pywin32-308-cp312-cp312-win32.whl", hash = "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897", size = 5939729, upload-time = "2024-10-12T20:42:12.001Z" },
{ url = "https://files.pythonhosted.org/packages/21/27/0c8811fbc3ca188f93b5354e7c286eb91f80a53afa4e11007ef661afa746/pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47", size = 6543015, upload-time = "2024-10-12T20:42:14.044Z" },
{ url = "https://files.pythonhosted.org/packages/9d/0f/d40f8373608caed2255781a3ad9a51d03a594a1248cd632d6a298daca693/pywin32-308-cp312-cp312-win_arm64.whl", hash = "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091", size = 7976033, upload-time = "2024-10-12T20:42:16.215Z" },
{ url = "https://files.pythonhosted.org/packages/a9/a4/aa562d8935e3df5e49c161b427a3a2efad2ed4e9cf81c3de636f1fdddfd0/pywin32-308-cp313-cp313-win32.whl", hash = "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed", size = 5938579, upload-time = "2024-10-12T20:42:18.623Z" },
{ url = "https://files.pythonhosted.org/packages/c7/50/b0efb8bb66210da67a53ab95fd7a98826a97ee21f1d22949863e6d588b22/pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4", size = 6542056, upload-time = "2024-10-12T20:42:20.864Z" },
{ url = "https://files.pythonhosted.org/packages/26/df/2b63e3e4f2df0224f8aaf6d131f54fe4e8c96400eb9df563e2aae2e1a1f9/pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd", size = 7974986, upload-time = "2024-10-12T20:42:22.799Z" },
{ url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" },
{ url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" },
{ url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" },
{ url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" },
{ url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" },
{ url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" },
{ url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
{ url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
]
[[package]]
@@ -5431,14 +5455,15 @@ wheels = [
[[package]]
name = "starlette"
version = "0.41.3"
version = "0.50.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/4c/9b5764bd22eec91c4039ef4c55334e9187085da2d8a2df7bd570869aae18/starlette-0.41.3.tar.gz", hash = "sha256:0e4ab3d16522a255be6b28260b938eae2482f98ce5cc934cb08dce8dc3ba5835", size = 2574159, upload-time = "2024-11-18T19:45:04.283Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/00/2b325970b3060c7cecebab6d295afe763365822b1306a12eeab198f74323/starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7", size = 73225, upload-time = "2024-11-18T19:45:02.027Z" },
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
]
[[package]]
@@ -5905,6 +5930,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "tzdata"
version = "2024.2"
@@ -5914,21 +5951,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586, upload-time = "2024-09-23T18:56:45.478Z" },
]
[[package]]
name = "uptrace"
version = "1.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-sdk" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1b/e7/090a8b7404b1fcf74c22aed25bb04601740bd1707e31579095b166fa2b19/uptrace-1.35.0.tar.gz", hash = "sha256:0897ea40fcc22122a11d05a0bbcacffc55d2a9a18dd03cffd6c5625e5afa55f0", size = 7660, upload-time = "2025-07-14T11:37:52.142Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/db/73bf119afdadbbc8578a61ce41117cda05f8703eaa24163e9236b96ccc40/uptrace-1.35.0-py3-none-any.whl", hash = "sha256:c98ce900d3c660c99763a684f5a51f876d93660114d9ed51de676ed886e5106a", size = 8590, upload-time = "2025-07-14T11:37:51.252Z" },
]
[[package]]
name = "urllib3"
version = "2.3.0"
@@ -5965,16 +5987,16 @@ wheels = [
[[package]]
name = "uvicorn"
version = "0.34.2"
version = "0.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815, upload-time = "2025-04-19T06:02:50.101Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483, upload-time = "2025-04-19T06:02:48.42Z" },
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
]
[package.optional-dependencies]