Compare commits

...
32 Commits
Author SHA1 Message Date
Mervin Praison f5d4db4262 Release v3.1.2 2026-01-02 08:17:39 +00:00
Mervin Praison 87d691c616 Release v3.1.1 2026-01-02 07:54:02 +00:00
Mervin Praison 8ea15dee0f Release v3.1.0 2026-01-02 07:35:57 +00:00
Mervin Praison 4f56842e08 Release v3.0.9 2026-01-02 07:22:27 +00:00
Mervin Praison a24836aaee Release v3.0.8 2026-01-02 06:59:48 +00:00
Mervin Praison be9ce27615 Release v3.0.7 2026-01-02 06:43:56 +00:00
Mervin Praison 0159a1e082 Release v3.0.6 2026-01-02 06:18:14 +00:00
Mervin Praison 71a5076c07 Release v3.0.5 2026-01-02 06:06:01 +00:00
Mervin Praison 9815a9f8a8 Release v3.0.4 2026-01-02 05:45:18 +00:00
Mervin Praison 2ae44a05b0 Release v3.0.3 2026-01-02 05:17:33 +00:00
Mervin Praison 3386d25022 Release v3.0.2 2026-01-02 04:33:19 +00:00
Mervin Praison 6dca2ace8a feat(tui): queue editing + update_input method
## Queue Editing
- Added update_input method to QueueScheduler for editing queued runs
- Added update_input method to QueueManager
- Added handler for QueuePanelWidget.EditRequested in TUIApp
- Only queued (not running) items can be edited

## Queue-First Architecture Support
- Queue items can be edited before processing
- EditRequested message already existed in QueuePanelWidget

31 tests passing
2026-01-02 04:24:10 +00:00
Mervin Praison 63548739e8 feat(tui): session history persistence with SessionStore
## Session History Implementation
- Created SessionStore class for in-memory chat history persistence
- Added chat_history field to QueuedRun model
- Modified queue worker to inject chat_history into Agent
- Updated TUIApp to store user/assistant messages in SessionStore
- Pass chat_history to queue when submitting messages

## How It Works
1. User sends message -> stored in SessionStore
2. Queue submission includes chat_history from SessionStore
3. Worker injects chat_history into Agent.chat_history
4. Agent has full conversation context
5. Response stored back in SessionStore

## Verified
- Agent remembers previous messages in session
- History format correct for LLM (role + content)
- 31 tests passing
2026-01-02 04:22:26 +00:00
Mervin Praison 3e046537f4 fix(tui): sessions screen loading + update_sessions_screen method
- Call _update_sessions_screen() after pushing sessions screen
- Add _update_sessions_screen() method to load sessions from persistence
- Fallback to showing current session if persistence fails

31 tests passing
2026-01-02 04:02:55 +00:00
Mervin Praison 4a34677567 fix(tui): compose() NoneType error + add missing slash commands
## Fixes
1. ChatWidget.compose() now yields empty generator instead of None
2. Added 7 missing slash commands: /tokens, /plan, /map, /undo, /diff, /commit, /tools

## Commands Added
- /tokens - Show token usage breakdown
- /plan - Show/create task plans
- /map - Show repository map
- /undo - Undo last change
- /diff - Show diff of changes
- /commit - Commit changes
- /tools - Show available tools

31 tests passing
2026-01-02 04:01:25 +00:00
Mervin Praison 19e91cb669 fix(tui): proper scrollbar + non-blocking input
## Scrollbar Fix (VerticalScroll)
- ChatWidget now inherits from VerticalScroll (not ScrollableContainer)
- Messages mount directly to container (removed inner Vertical)
- scroll_end() called after adding messages and during streaming
- CSS has overflow-y: auto for proper scrollbar display

## Non-Blocking Input (@work decorator)
- Message submission uses run_worker() for background execution
- _process_message_submission decorated with @work
- User can type while agent is processing
- Multiple messages can be queued

## Root Causes Fixed
1. Inner Vertical container had overflow:hidden - removed it
2. Message handler was async blocking - now uses worker pattern

Based on Textual documentation:
- VerticalScroll: container with vertical layout and auto Y scrollbar
- @work decorator: runs coroutine in background without blocking UI

31 tests passing
2026-01-02 03:56:56 +00:00
Mervin Praison 5a6125021a fix(tui): add scrollbar to chat + fix session history
## Scrollbar Fix
- ChatWidget now inherits from ScrollableContainer
- Scrollbar visible in chat area (like queue panel)

## Session History Fix
- Pass session_id from TUI app to queue submit
- Pass session_id in agent_config
- Worker now passes session_id to Agent constructor
- Agent will now use session history for context

Flow: TUI.session_id → queue.submit() → worker → Agent(session_id=...)

31 tests passing
2026-01-02 03:44:55 +00:00
Mervin Praison da26887f24 feat(tui): NEWEST-AT-TOP rendering + fix @ typing blockage
## UX STRATEGY CHANGE (Stabilization)

### Newest-at-Top Rendering
- Messages now render NEWEST at TOP
- No scroll dependency - latest always visible
- Streaming updates visible immediately
- ChatWidget inherits from Vertical (not ScrollableContainer)

### @ Typing Not Blocked
- @ handler no longer mounts focus-stealing popup
- Users can freely type @path/to/file
- Manual path entry works without interruption

### Verified Working
- ✅ New messages appear at TOP
- ✅ Latest response always visible
- ✅ Streaming updates visible
- ✅ Can type while agent runs
- ✅ Queue increments immediately
- ✅ Backslash popup works
- ✅ @ does NOT block typing
- ✅ 31 tests passing
2026-01-02 03:38:13 +00:00
Mervin Praison 1b2f7139f9 docs(tui): update help screen with auto-trigger features
- Document @ auto-show file browser
- Document / auto-show command discovery
- Update backslash description to 'auto-show'
2026-01-02 03:25:17 +00:00
Mervin Praison aafbc86180 feat(tui): auto-trigger popups for backslash, @, and / commands
## Auto-Trigger Features (Claude Code-style UX)

### Backslash (\) Auto-Popup
- Typing \ immediately shows command popup
- Clears backslash from input
- No manual trigger needed

### @ File Autocomplete
- Typing @ shows file/directory popup
- Fuzzy search files in workspace
- Supports ~/ and absolute paths
- Reuses FileSearchService from at_mentions.py

### / Command Discovery
- Typing / shows command popup with filter
- Same unified command registry as backslash

## Queue Live Updates
- Queue panel updates IMMEDIATELY when message queued
- Updates on run completion and error
- Reactive queued_count and running_count

## Composer Never Disabled
- Input stays enabled during agent processing
- Users can queue multiple messages

## New Files
- widgets/file_popup.py: FilePopupWidget for @ autocomplete

## Tests
- 31 tests passing (10 new tests)
- TestAutoTriggerEvents: 4 tests
- TestFilePopup: 4 tests
- TestQueueLiveUpdates: 2 tests
2026-01-02 03:24:39 +00:00
Mervin Praison 3d4ca1b7c0 docs(tui): update help screen with safe keyboard defaults and backslash commands
## Help Screen Updates
- Document backslash (\) command popup
- Show safe keyboard defaults (no Ctrl/Fn by default)
- Add command mode (: prefix) section
- Add slash commands (/ prefix) section
- Document optional Ctrl/Fn key env vars

## Keyboard Shortcuts Documented
- \ - Show command popup
- Enter - Send message
- Shift+Enter - New line
- q - Quit (when not typing)
- ? - Help
- : - Command mode
- / - Search mode
- Escape - Cancel/Close
2026-01-02 03:07:50 +00:00
Mervin Praison 1180aa38bd feat(tui): add backslash command popup for Claude Code-style UX
## New Features

### Command Popup Widget (command_popup.py)
- CommandPopupWidget: Overlay showing all available commands
- Triggered by backslash (\) key - matches Claude Code UX
- Searchable/filterable command list
- Keyboard navigation (arrows, Enter, Escape)
- 16 default commands with aliases

### MainScreen Integration
- Backslash binding shows command popup
- Escape dismisses popup or cancels input
- Command selection executes command
- Popup layer CSS for proper overlay positioning

## Commands Available
- help, clear, model, cost, tokens, queue
- cancel, settings, sessions, tools, exit
- plan, undo, diff, commit, map

## Tests
- 21 tests passing
- TestCommandPopup: 4 new tests
- TestMainScreenBackslash: 3 new tests

## Files Changed
- NEW: widgets/command_popup.py
- screens/main.py: Add popup integration
- tests/unit/tui/test_tui_widgets.py: Add tests
2026-01-02 03:06:44 +00:00
Mervin Praison 65f65e5c7b feat(tui): command mode routing and safe keyboard defaults
## Changes

### Command System (: and / prefix)
- Both : and / now work as command prefixes
- Local command handling in MainScreen._handle_local_command()
- Commands: :quit, :clear, :help, :tools, :queue, :settings, :cancel
- Aliases work: :q, :cl, :h, :t, :qu

### Keyboard Defaults
- TUIApp bindings now hidden in footer (show=False)
- Ctrl+Q/C kept for emergency exit but not displayed
- MainScreen shows safe defaults: q, :, ?, Esc

### Help Text Updated
- Shows safe keyboard shortcuts
- Documents command mode usage
- Removed references to Ctrl/Fn keys

## Files Changed
- app.py: Hide Ctrl bindings in footer, update help text
- screens/main.py: Add _handle_local_command() for : commands
- widgets/composer.py: Support both / and : as command prefixes

## Tests
- 14 tests passing
- Command parsing verified
- TUI app startup verified
2026-01-02 02:48:50 +00:00
Mervin Praison f81a12d5e8 feat(tui): comprehensive UX fixes - safe keyboard defaults, auto-scroll, tools panel
BREAKING CHANGE: Keyboard shortcuts changed to safe defaults

## Changes

### 1. Auto-scroll Fix
- Use call_after_refresh() to ensure scroll happens after DOM updates
- Fixed in ChatWidget.add_message() and update_streaming()

### 2. Tools Panel Fix
- Added ScrollableContainer for tools list (no more truncation)
- Shows count of available tools
- All 13 tools now visible with scrolling

### 3. Safe Keyboard Defaults (NO Ctrl/Fn keys by default)
- Single-char shortcuts: q (quit), : (command mode), ? (help)
- Command mode: :quit, :clear, :help, :tools, :queue, :settings
- Ctrl keys (^Q, ^C, ^L) - DISABLED by default (opt-in)
- Function keys (F1-F5) - DISABLED by default (opt-in)

### 4. Configuration System
- New TUIConfig class in config.py
- Environment variables:
  - PRAISONAI_TUI_CTRL_KEYS=1 (enable Ctrl keys)
  - PRAISONAI_TUI_FN_KEYS=1 (enable Fn keys)
  - PRAISONAI_TUI_LEADER=; (custom leader key)

### 5. Non-blocking Input
- Queue accepts messages while agents are running
- Already implemented in previous commit

## Files Changed
- widgets/chat.py - Auto-scroll fix
- widgets/tool_panel.py - ScrollableContainer, show all tools
- widgets/composer.py - set_text() method for command mode
- screens/main.py - Safe default bindings, command mode actions
- config.py - NEW: TUIConfig, command parsing, help text

## Tests
- 14 tests passing
- New tests for TUIConfig and safe bindings
2026-01-02 02:39:16 +00:00
Mervin Praison cf6f5fbefe feat(tui): fix help screen, Enter key, and add TDD tests
Fixes:
- Add HelpScreen for F1 key (was missing, caused KeyError)
- Fix Enter key to send message using custom SubmitTextArea class
- Fix Shift+Enter to insert newline properly
- Register HelpScreen in TUIApp.SCREENS

Tests:
- Add TDD test suite for TUI widgets (12 tests)
- Test Enter key submission
- Test Shift+Enter newline
- Test F1 help screen
- Test Ctrl key bindings

All 13 default tools (ACP, LSP, Basic) load correctly.
Queue system tested and working with real API keys.
2026-01-02 02:23:02 +00:00
Mervin Praison 908d7710db feat(tui): improve TUI usability and add tools panel display
- Enter key now sends message (was Ctrl+Enter)
- Shift+Enter inserts newline without backslash
- Allow typing while response is generating (queue multiple messages)
- Display available tools in Tools panel
- Add edit/delete functionality to queue panel
- Auto-scroll already working in chat widget

TUI Architecture:
- TUIApp -> QueueManager -> WorkerPool -> Agent (from praisonaiagents)
- Tools stored in runtime registry to avoid JSON serialization issues
2026-01-02 02:08:22 +00:00
Mervin Praison 7d5b41e31f fix: use 'llm' parameter instead of 'model' for Agent initialization in queue worker
- Agent class uses 'llm' parameter, not 'model'
- This fixes 'unexpected keyword argument model' error in TUI mode
2026-01-01 19:57:31 +00:00
Mervin Praison e46a0bc9f3 Release v3.0.1 2025-12-31 19:29:02 +00:00
Mervin Praison 911483c92c Release v3.0.0 2025-12-31 18:55:59 +00:00
Mervin Praison 4ffb9d6e3f Release v2.9.2 2025-12-31 17:17:17 +00:00
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
170 changed files with 36141 additions and 990 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.9" \
"praisonai>=3.1.2" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.8.9" \
"praisonai>=3.1.2" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.8.9" \
"praisonai>=3.1.2" \
"praisonai[ui]" \
"praisonai[crewai]"
+203
View File
@@ -0,0 +1,203 @@
# Profiling CLI
PraisonAI provides unified profiling capabilities for performance analysis and debugging, with **full visibility from ENTER to first response**.
## Quick Start
```bash
# Basic profiling
praisonai "What is 2+2?" --profile
# Deep profiling with call graph, decision trace, and module breakdown
praisonai "What is 2+2?" --profile --profile-deep
# JSON output format
praisonai "What is 2+2?" --profile --profile-format json
```
## Time to First Response (TTFR)
**TTFR** is the key user-perceived latency metric. It measures the time from when you press ENTER until the first visible output appears.
### What contributes to TTFR?
1. **CLI Entry** - Time to reach the CLI entry point
2. **CLI Parse** - Argparse/Typer routing time
3. **Routing** - Command routing to handler
4. **Imports** - Loading required modules
5. **Agent Init** - Initializing the agent
6. **Network Request** - Sending request to LLM API
7. **First Token** - Receiving first token (streaming)
8. **First Output** - Rendering first visible output
### How to reduce TTFR
1. **Warm runs** - Imports are cached after first run
2. **Streaming mode** - See output as tokens arrive
3. **Lighter models** - Faster response times
4. **Local models** - Eliminate network latency
## CLI Flags
| Flag | Description | Default |
|------|-------------|---------|
| `--profile` | Enable profiling with timing breakdown | Disabled |
| `--profile-deep` | Enable deep profiling with call graph (higher overhead) | Disabled |
| `--profile-format` | Output format: `text` or `json` | `text` |
## Profile Command
For more detailed profiling options, use the `profile` subcommand:
```bash
# Profile a query
praisonai profile query "What is 2+2?"
# With deep call tracing
praisonai profile query "Hello" --deep
# Limit functions shown
praisonai profile query "Test" --limit 20
# Save artifacts to directory
praisonai profile query "Test" --save ./profile_results
# JSON output
praisonai profile query "Test" --format json
```
### Profile Query Options
| Option | Description | Default |
|--------|-------------|---------|
| `--model, -m` | Model to use | Default model |
| `--stream/--no-stream` | Use streaming mode | No stream |
| `--deep` | Enable deep call tracing | Disabled |
| `--limit, -n` | Top N functions to show | 30 |
| `--sort, -s` | Sort by: `cumulative` or `tottime` | `cumulative` |
| `--show-callers` | Show caller functions | Disabled |
| `--show-callees` | Show callee functions | Disabled |
| `--save` | Save artifacts to path | None |
| `--format, -f` | Output format: `text` or `json` | `text` |
## Other Profile Commands
```bash
# Profile module import times
praisonai profile imports
# Profile CLI startup time
praisonai profile startup
# Run comprehensive profiling suite
praisonai profile suite
# Create performance snapshots for comparison
praisonai profile snapshot --baseline
praisonai profile snapshot current --compare
# Configure performance optimizations
praisonai profile optimize --show
```
## Output Format
### Text Report
```
======================================================================
PraisonAI Profile Report
======================================================================
Run ID: abc12345
Timestamp: 2024-01-02T12:00:00.000000Z
Method: cli_direct
Version: 3.0.1
## Execution Timeline
---------------------------------------------
Imports : 500.00 ms
Agent Init : 0.50 ms
Execution : 2000.00 ms
───────────────────────────────────────────
⏱ Time to First Response : 2500.50 ms
TOTAL : 2500.50 ms
## Timing Breakdown
----------------------------------------
Imports: 500.00 ms
Agent Init: 0.50 ms
Execution: 2000.00 ms
─────────────────────────────────────
TOTAL: 2500.50 ms
## Decision Trace (deep profile only)
----------------------------------------
Agent Config: default
Model: gpt-4
Streaming: disabled
Profile Layer: 2
## Top Functions by Cumulative Time
----------------------------------------------------------------------
Function Calls Cumul (ms)
----------------------------------------------------------------------
request 1 1800.00
send 1 1750.00
...
```
### JSON Report
```json
{
"schema_version": "1.0",
"run_id": "abc12345",
"timestamp": "2024-01-02T12:00:00.000000Z",
"invocation": {
"method": "cli_direct",
"flags": {"layer": 1, "stream": false, "model": null},
"praisonai_version": "3.0.1",
"python_version": "3.12.0"
},
"timing": {
"total_ms": 2500.5,
"imports_ms": 500.0,
"agent_init_ms": 0.5,
"execution_ms": 2000.0
},
"functions": [...],
"response_preview": "four"
}
```
## Profiling Layers
| Layer | Overhead | Features |
|-------|----------|----------|
| 0 | < 1ms | Wall-clock phases only |
| 1 | ~5% | + cProfile function stats |
| 2 | ~15% | + Call graph (callers/callees) |
- `--profile` uses Layer 1
- `--profile --profile-deep` uses Layer 2
## Performance Tips
1. **Use Layer 0 for production monitoring** - minimal overhead
2. **Use Layer 1 for development profiling** - good balance
3. **Use Layer 2 only for deep debugging** - higher overhead
## Observational Equivalence
The following commands produce equivalent profile reports:
```bash
# CLI direct
praisonai "prompt" --profile
# Profile command
praisonai profile query "prompt"
```
Both use the same unified profiling architecture and produce identical schema output.
+257
View File
@@ -0,0 +1,257 @@
# Profiling Module
PraisonAI provides a unified profiling module for programmatic performance analysis.
## Quick Start
```python
from praisonai.cli.execution import (
ExecutionRequest,
Profiler,
ProfilerConfig,
)
# Create execution request
request = ExecutionRequest(prompt="What is 2+2?")
# Configure profiler
config = ProfilerConfig(layer=1)
# Run with profiling
profiler = Profiler(config)
result, report = profiler.profile_sync(request)
# Access results
print(f"Output: {result.output}")
print(f"Total time: {report.timing.total_ms}ms")
```
## ExecutionRequest
Immutable request descriptor for execution.
```python
from praisonai.cli.execution import ExecutionRequest
# Basic request
request = ExecutionRequest(prompt="Hello")
# With model
request = ExecutionRequest(
prompt="Hello",
model="gpt-4",
agent_name="MyAgent",
)
# With streaming
request = ExecutionRequest(
prompt="Hello",
stream=True,
)
```
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `prompt` | str | Required | The prompt to execute |
| `agent_name` | str | "Assistant" | Name of the agent |
| `agent_instructions` | str | None | Custom instructions |
| `model` | str | None | Model to use |
| `stream` | bool | False | Enable streaming |
| `tools` | tuple | () | Tools to provide |
## ProfilerConfig
Configuration for profiling behavior.
```python
from praisonai.cli.execution import ProfilerConfig
# Layer 0: Wall-clock only (< 1ms overhead)
config = ProfilerConfig(layer=0)
# Layer 1: + cProfile stats (~5% overhead)
config = ProfilerConfig(layer=1)
# Layer 2: + Call graph (~15% overhead)
config = ProfilerConfig(
layer=2,
show_callers=True,
show_callees=True,
)
# From CLI flags
config = ProfilerConfig.from_flags(
profile=True,
deep=True,
network=False,
)
```
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `layer` | int | 1 | Profiling layer (0, 1, or 2) |
| `limit` | int | 30 | Max functions to show |
| `sort_by` | str | "cumulative" | Sort by: cumulative, time, calls |
| `show_callers` | bool | False | Show caller functions |
| `show_callees` | bool | False | Show callee functions |
| `track_network` | bool | False | Track network requests |
| `output_format` | str | "text" | Output format: text, json |
| `save_path` | str | None | Path to save artifacts |
## Profiler
The main profiler class that wraps execution.
```python
from praisonai.cli.execution import Profiler, ProfilerConfig, ExecutionRequest
config = ProfilerConfig(layer=1)
profiler = Profiler(config)
request = ExecutionRequest(prompt="Hello")
result, report = profiler.profile_sync(request, invocation_method="api")
```
### Methods
#### `profile_sync(request, invocation_method="profile_command")`
Profile synchronous execution.
**Parameters:**
- `request`: ExecutionRequest - The execution request
- `invocation_method`: str - How this was invoked (for report)
**Returns:** Tuple of (ExecutionResult, ProfileReport)
## ProfileReport
The canonical profile report with timing and statistics.
```python
# Access timing breakdown
print(f"Total: {report.timing.total_ms}ms")
print(f"Imports: {report.timing.imports_ms}ms")
print(f"Agent Init: {report.timing.agent_init_ms}ms")
print(f"Execution: {report.timing.execution_ms}ms")
# Access function stats (layer 1+)
if report.functions:
for func in report.functions[:5]:
print(f"{func.name}: {func.cumulative_time_ms}ms")
# Access call graph (layer 2)
if report.call_graph:
print(f"Edges: {len(report.call_graph.edges)}")
# Export formats
json_str = report.to_json()
text_str = report.to_text()
dict_data = report.to_dict()
```
### Schema Version
The report uses schema version `1.0`. Schema is frozen after release - new fields are always optional with defaults.
### Mandatory Fields
- `schema_version`: str
- `run_id`: str
- `timestamp`: str (ISO 8601)
- `invocation`: InvocationInfo
- `timing`: TimingBreakdown
- `response_preview`: str
### Optional Fields
- `functions`: List[FunctionStat] (layer 1+)
- `call_graph`: CallGraph (layer 2)
- `network`: List[RequestTiming] (if track_network)
## ExecutionResult
Result of execution with output and metadata.
```python
# Access output
print(result.output)
print(result.success)
print(result.run_id)
# Check for errors
if result.error:
print(f"Error: {result.error}")
# Duration
print(f"Duration: {result.duration_ms}ms")
```
## Data Bounds
Profile data is strictly bounded to prevent memory issues:
| Data Type | Maximum |
|-----------|---------|
| Function stats | 1000 |
| Call graph edges | 5000 |
| Network requests | 100 |
## Example: Full Profiling Workflow
```python
from praisonai.cli.execution import (
ExecutionRequest,
Profiler,
ProfilerConfig,
)
# Configure deep profiling
config = ProfilerConfig(
layer=2,
limit=50,
show_callers=True,
show_callees=True,
)
# Create request
request = ExecutionRequest(
prompt="Explain quantum computing in simple terms",
model="gpt-4",
)
# Profile execution
profiler = Profiler(config)
result, report = profiler.profile_sync(request)
# Print text report
print(report.to_text())
# Save JSON report
with open("profile.json", "w") as f:
f.write(report.to_json())
# Analyze timing
print(f"\nTiming Analysis:")
print(f" Import overhead: {report.timing.imports_ms / report.timing.total_ms * 100:.1f}%")
print(f" Execution time: {report.timing.execution_ms / report.timing.total_ms * 100:.1f}%")
# Top 5 hotspots
if report.functions:
print(f"\nTop 5 Hotspots:")
for func in report.functions[:5]:
print(f" {func.name}: {func.cumulative_time_ms:.2f}ms ({func.calls} calls)")
```
## Invariants
The profiling module guarantees:
1. **Output Equivalence**: Profiled execution produces identical output to non-profiled execution
2. **Schema Stability**: Schema version 1.0 is frozen; new fields are always optional
3. **Bounded Data**: All profile data respects maximum bounds
4. **Zero Overhead When Disabled**: No profiling code runs unless explicitly enabled
@@ -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())
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""
PraisonAI Benchmark Example
This example demonstrates how to use the benchmark system programmatically
to compare performance across different execution paths.
Usage:
python benchmark_example.py
Requirements:
- OPENAI_API_KEY environment variable set
- praisonai package installed
"""
import os
import sys
# Ensure the package is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/praisonai'))
from praisonai.cli.features.benchmark import BenchmarkHandler, BenchmarkReport
def run_quick_comparison():
"""Run a quick comparison of key execution paths."""
print("=" * 60)
print("Quick Benchmark Comparison")
print("=" * 60)
handler = BenchmarkHandler()
# Run benchmark with 2 iterations on key paths
report = handler.run_full_benchmark(
prompt="What is 2+2?",
iterations=2,
paths=["openai_sdk", "praisonai_agent"],
verbose=True
)
# Print comparison table
print("\n" + handler.create_comparison_table(report))
return report
def run_full_benchmark():
"""Run full benchmark suite across all paths."""
print("=" * 60)
print("Full Benchmark Suite")
print("=" * 60)
handler = BenchmarkHandler()
# Run full benchmark
report = handler.run_full_benchmark(
prompt="Hi",
iterations=3,
verbose=True
)
# Print full report
handler.print_report(report)
return report
def run_agent_benchmark():
"""Benchmark PraisonAI Agent specifically."""
print("=" * 60)
print("Agent Benchmark")
print("=" * 60)
handler = BenchmarkHandler()
# Benchmark agent vs SDK
report = handler.run_full_benchmark(
prompt="Explain Python in one sentence",
iterations=3,
paths=["openai_sdk", "praisonai_agent"],
verbose=True
)
# Show timeline diagrams
for name, result in report.results.items():
print(f"\n### {name}")
print(handler.create_timeline_diagram(result))
# Show variance analysis
print(handler.create_variance_table(report))
return report
def save_benchmark_results(report: BenchmarkReport, output_path: str):
"""Save benchmark results to JSON file."""
import json
with open(output_path, 'w') as f:
json.dump(report.to_dict(), f, indent=2)
print(f"\nResults saved to: {output_path}")
def main():
"""Main entry point."""
import argparse
parser = argparse.ArgumentParser(description="PraisonAI Benchmark Example")
parser.add_argument(
"--mode",
choices=["quick", "full", "agent"],
default="quick",
help="Benchmark mode: quick (2 paths), full (all paths), agent (agent focus)"
)
parser.add_argument(
"--output",
type=str,
default=None,
help="Output JSON file path"
)
args = parser.parse_args()
# Check for API key
if not os.environ.get("OPENAI_API_KEY"):
print("Error: OPENAI_API_KEY environment variable not set")
sys.exit(1)
# Run selected benchmark
if args.mode == "quick":
report = run_quick_comparison()
elif args.mode == "full":
report = run_full_benchmark()
elif args.mode == "agent":
report = run_agent_benchmark()
# Save results if requested
if args.output:
save_benchmark_results(report, args.output)
print("\n✅ Benchmark complete!")
if __name__ == "__main__":
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()
@@ -0,0 +1,147 @@
"""
Interactive Default Tools Example
This example demonstrates how PraisonAI's interactive modes (TUI and prompt)
include ACP and LSP tools by default.
Tool Groups:
- ACP: acp_create_file, acp_edit_file, acp_delete_file, acp_execute_command
- LSP: lsp_list_symbols, lsp_find_definition, lsp_find_references, lsp_get_diagnostics
- Basic: read_file, write_file, list_files, execute_command, internet_search
Usage:
python interactive_tools_example.py
"""
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'))
def example_get_default_tools():
"""Example: Get all default interactive tools."""
from praisonai.cli.features.interactive_tools import (
get_interactive_tools,
ToolConfig,
TOOL_GROUPS,
)
print("=== Default Interactive Tools ===\n")
# Show tool groups
print("Tool Groups:")
for group, tools in TOOL_GROUPS.items():
if group != "interactive":
print(f" {group}: {tools}")
print()
# Get all tools
config = ToolConfig(workspace=os.getcwd())
tools = get_interactive_tools(config=config)
print(f"Total tools loaded: {len(tools)}")
print(f"Tool names: {[t.__name__ for t in tools]}")
print()
return tools
def example_disable_acp():
"""Example: Disable ACP tools."""
from praisonai.cli.features.interactive_tools import (
get_interactive_tools,
ToolConfig,
)
print("=== Disable ACP Tools (--no-acp) ===\n")
config = ToolConfig(workspace=os.getcwd(), enable_acp=False)
tools = get_interactive_tools(config=config, disable=["acp"])
acp_tools = [t for t in tools if t.__name__.startswith("acp_")]
print(f"ACP tools after --no-acp: {len(acp_tools)}")
print(f"Remaining tools: {[t.__name__ for t in tools]}")
print()
return tools
def example_disable_lsp():
"""Example: Disable LSP tools."""
from praisonai.cli.features.interactive_tools import (
get_interactive_tools,
ToolConfig,
)
print("=== Disable LSP Tools (--no-lsp) ===\n")
config = ToolConfig(workspace=os.getcwd(), enable_lsp=False)
tools = get_interactive_tools(config=config, disable=["lsp"])
lsp_tools = [t for t in tools if t.__name__.startswith("lsp_")]
print(f"LSP tools after --no-lsp: {len(lsp_tools)}")
print(f"Remaining tools: {[t.__name__ for t in tools]}")
print()
return tools
def example_agent_with_default_tools():
"""Example: Create an Agent with default interactive tools."""
from praisonai.cli.features.interactive_tools import get_interactive_tools, ToolConfig
from praisonaiagents import Agent
print("=== Agent with Default Interactive Tools ===\n")
# Get default tools
config = ToolConfig(workspace=os.getcwd())
tools = get_interactive_tools(config=config)
# Create agent with tools
agent = Agent(
name="InteractiveAgent",
instructions="You are a helpful coding assistant with file and code analysis capabilities.",
tools=tools,
verbose=False
)
print(f"Agent created with {len(tools)} tools")
print(f"Tools: {[t.__name__ for t in tools]}")
print()
# Simple test - list files
print("Testing agent with list_files tool...")
result = agent.start("List the Python files in the current directory using list_files tool.")
print(f"Result: {result[:200]}..." if len(str(result)) > 200 else f"Result: {result}")
print()
return agent
if __name__ == "__main__":
print("=" * 60)
print("PraisonAI Interactive Default Tools Example")
print("=" * 60)
print()
# Example 1: Get default tools
example_get_default_tools()
# Example 2: Disable ACP
example_disable_acp()
# Example 3: Disable LSP
example_disable_lsp()
# Example 4: Agent with default tools (requires API key)
if os.environ.get("OPENAI_API_KEY"):
example_agent_with_default_tools()
else:
print("=== Agent Example Skipped (no OPENAI_API_KEY) ===")
print("=" * 60)
print("Examples Complete")
print("=" * 60)
+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)
+83
View File
@@ -0,0 +1,83 @@
# PraisonAI Profiling Examples
This directory contains examples for profiling PraisonAI agent performance.
## Prerequisites
```bash
pip install praisonai
export OPENAI_API_KEY=your_key_here
```
## Examples
### 1. Basic Profiling (`basic_profiling.py`)
Demonstrates programmatic profiling of a single query:
```bash
python basic_profiling.py
```
### 2. Suite Profiling (`suite_profiling.py`)
Runs a comprehensive profiling suite with multiple scenarios:
```bash
python suite_profiling.py
```
### 3. Optimization Demo (`optimization_example.py`)
Demonstrates Tier 0/1/2 performance optimizations:
```bash
python optimization_example.py
```
## CLI Commands
You can also profile directly from the command line:
```bash
# Profile a query
praisonai profile query "What is 2+2?"
# Profile with file grouping
praisonai profile query "Hello" --show-files --limit 20
# Profile imports
praisonai profile imports
# Profile startup time
praisonai profile startup
# Run comprehensive suite
praisonai profile suite --quick
# Create performance baseline
praisonai profile snapshot --baseline
# Compare against baseline
praisonai profile snapshot current --compare
# Show optimization status
praisonai profile optimize --show
```
## Output
- **Text output**: Human-readable timing breakdown
- **JSON output**: Machine-readable for CI/CD (`--format json`)
- **Artifacts**: Binary cProfile data and reports (`--save ./results`)
- **Snapshots**: Performance baselines for regression detection
## Performance Optimizations
Enable opt-in optimizations via environment variables:
```bash
export PRAISONAI_LITE_MODE=1
export PRAISONAI_SKIP_TYPE_VALIDATION=1
export PRAISONAI_MINIMAL_IMPORTS=1
```
@@ -0,0 +1,71 @@
"""
Basic Profiling Example for PraisonAI
This example demonstrates how to use the unified profiler programmatically
to measure agent performance.
Run with:
python basic_profiling.py
"""
import os
# Ensure API key is set
if not os.environ.get("OPENAI_API_KEY"):
print("Please set OPENAI_API_KEY environment variable")
exit(1)
from praisonai.cli.execution import (
ExecutionRequest,
Profiler,
ProfilerConfig,
)
def main():
"""Run a profiled query and display results."""
# Configure profiler (Layer 1 = basic profiling with function stats)
config = ProfilerConfig(
layer=1, # 0=minimal, 1=basic, 2=deep
limit=15, # Show top 15 functions
show_callers=False, # Set True for caller info (layer 2)
show_callees=False, # Set True for callee info (layer 2)
)
# Create execution request
request = ExecutionRequest(
prompt="What is 2+2?",
agent_name="ProfiledAgent",
)
# Run profiled query
print("🔬 Running profiled query...")
profiler = Profiler(config)
result, report = profiler.profile_sync(request)
# Print formatted report
print("\n" + report.to_text())
# Access timing data programmatically
print("\n📊 Timing Summary:")
print(f" Imports: {report.timing.imports_ms:>10.2f} ms")
print(f" Agent Init: {report.timing.agent_init_ms:>10.2f} ms")
print(f" Execution: {report.timing.execution_ms:>10.2f} ms")
print(f" Total: {report.timing.total_ms:>10.2f} ms")
# Show top 5 functions
if report.functions:
print("\n🔥 Top 5 Functions by Cumulative Time:")
for i, func in enumerate(report.functions[:5], 1):
print(f" {i}. {func.name}: {func.cumulative_time_ms:.2f}ms")
# Show response
print(f"\n💬 Response: {result.output}")
# Export to JSON
print("\n📄 JSON Export available via: report.to_json()")
if __name__ == "__main__":
main()
@@ -0,0 +1,223 @@
"""
Performance Optimization Example for PraisonAI
This example demonstrates how to use the Tier 0/1/2 optimization features
programmatically.
Run with:
python optimization_example.py
"""
import os
# Ensure API key is set
if not os.environ.get("OPENAI_API_KEY"):
print("Please set OPENAI_API_KEY environment variable")
exit(1)
def demo_provider_cache():
"""Demonstrate provider caching (Tier 0)."""
print("\n" + "=" * 60)
print("Tier 0: Provider Cache Demo")
print("=" * 60)
from praisonai.cli.features.profiler import (
get_provider_cache,
resolve_model_provider,
)
cache = get_provider_cache()
cache.invalidate() # Start fresh
# Resolve some models
models = ['gpt-4o', 'claude-3-opus', 'gemini-pro', 'gpt-4o-mini']
print("\nResolving models:")
for model in models:
provider, name = resolve_model_provider(model)
print(f" {model} -> {provider}/{name}")
# Check cache stats
stats = cache.get_stats()
print("\nCache Stats:")
print(f" Hits: {stats['hits']}")
print(f" Misses: {stats['misses']}")
print(f" Size: {stats['size']}")
def demo_lazy_importer():
"""Demonstrate lazy imports (Tier 0)."""
print("\n" + "=" * 60)
print("Tier 0: Lazy Importer Demo")
print("=" * 60)
from praisonai.cli.features.profiler import LazyImporter
import time
LazyImporter.clear()
# Time lazy import
start = time.perf_counter()
json_module = LazyImporter.get('json')
first_import = (time.perf_counter() - start) * 1000
# Second import (cached)
start = time.perf_counter()
json_module2 = LazyImporter.get('json')
cached_import = (time.perf_counter() - start) * 1000
print(f"\nFirst import: {first_import:.3f}ms")
print(f"Cached import: {cached_import:.3f}ms")
print(f"Same object: {json_module is json_module2}")
def demo_client_pool():
"""Demonstrate client pooling (Tier 1)."""
print("\n" + "=" * 60)
print("Tier 1: Client Pool Demo")
print("=" * 60)
from praisonai.cli.features.profiler import ClientPool
pool = ClientPool(max_size=3)
# Create some mock clients
def create_client(name):
return {'name': name, 'created': True}
# Get or create clients
client1 = pool.get_or_create('openai', lambda: create_client('openai'))
_ = pool.get_or_create('anthropic', lambda: create_client('anthropic'))
# Reuse existing client
client1_again = pool.get_or_create('openai', lambda: create_client('openai_new'))
print(f"\nPool size: {pool.size()}")
print(f"Client reused: {client1 is client1_again}")
print(f"Client1 name: {client1['name']}")
def demo_prewarm():
"""Demonstrate pre-warming (Tier 1)."""
print("\n" + "=" * 60)
print("Tier 1: Pre-warming Demo")
print("=" * 60)
from praisonai.cli.features.profiler import PrewarmManager
import time
PrewarmManager.reset()
print(f"\nPre-warming enabled: {PrewarmManager.is_enabled()}")
# Enable and prewarm
PrewarmManager.enable()
print(f"After enable: {PrewarmManager.is_enabled()}")
# Prewarm OpenAI (runs in background)
PrewarmManager.prewarm_provider('openai')
print("Pre-warming OpenAI in background...")
# Wait a bit for background thread
time.sleep(0.5)
print(f"OpenAI prewarmed: {PrewarmManager.is_prewarmed('openai')}")
PrewarmManager.reset()
def demo_lite_mode():
"""Demonstrate lite mode (Tier 2)."""
print("\n" + "=" * 60)
print("Tier 2: Lite Mode Demo")
print("=" * 60)
from praisonai.cli.features.profiler import (
LiteModeConfig,
is_lite_mode,
)
# Default config
config = LiteModeConfig()
print(f"\nDefault lite mode: {config.enabled}")
# Config from env
print("\nTo enable lite mode, set environment variables:")
print(" export PRAISONAI_LITE_MODE=1")
print(" export PRAISONAI_SKIP_TYPE_VALIDATION=1")
print(f"\nCurrent is_lite_mode(): {is_lite_mode()}")
def demo_perf_snapshot():
"""Demonstrate performance snapshots (Tier 2)."""
print("\n" + "=" * 60)
print("Tier 2: Performance Snapshot Demo")
print("=" * 60)
from praisonai.cli.features.profiler import (
PerfSnapshot,
PerfComparison,
PerfSnapshotManager,
)
import tempfile
from pathlib import Path
# Create manager with temp directory
with tempfile.TemporaryDirectory() as tmpdir:
manager = PerfSnapshotManager(snapshot_dir=Path(tmpdir))
# Create baseline
baseline = PerfSnapshot(
timestamp='2025-01-01T00:00:00Z',
name='baseline',
startup_cold_ms=100.0,
startup_warm_ms=80.0,
import_time_ms=500.0,
query_time_ms=2000.0,
first_token_ms=300.0,
)
manager.save_baseline(baseline)
print("\nBaseline saved")
# Create current snapshot (simulated improvement)
current = PerfSnapshot(
timestamp='2025-01-02T00:00:00Z',
name='current',
startup_cold_ms=95.0, # 5% faster
startup_warm_ms=78.0,
import_time_ms=480.0, # 4% faster
query_time_ms=1900.0, # 5% faster
first_token_ms=280.0,
)
# Compare
comparison = PerfComparison(baseline=baseline, current=current)
print(f"\nStartup improvement: {-comparison.startup_cold_diff_pct:.1f}%")
print(f"Import improvement: {-comparison.import_time_diff_pct:.1f}%")
print(f"Query improvement: {-comparison.query_time_diff_pct:.1f}%")
print(f"Is regression: {comparison.is_regression()}")
def main():
"""Run all demos."""
print("=" * 60)
print("PraisonAI Performance Optimization Demo")
print("=" * 60)
demo_provider_cache()
demo_lazy_importer()
demo_client_pool()
demo_prewarm()
demo_lite_mode()
demo_perf_snapshot()
print("\n" + "=" * 60)
print("All demos complete!")
print("=" * 60)
if __name__ == "__main__":
main()
@@ -0,0 +1,227 @@
{
"timestamp": "2025-12-31T18:45:38.187539Z",
"metadata": {
"python_version": "3.12.11",
"platform": "macOS-15.7.4-arm64-arm-64bit",
"praisonai_version": "2.9.2",
"timestamp": "2025-12-31T18:45:38.226558Z"
},
"startup": {
"cold_ms": 80.9305000002496,
"warm_ms": 55.266541996388696
},
"import_analysis": [
{
"module": "praisonaiagents",
"self_us": 2419,
"cumulative_us": 1645390,
"self_ms": 2.419,
"cumulative_ms": 1645.39
},
{
"module": "praisonaiagents.workflows",
"self_us": 670,
"cumulative_us": 1375919,
"self_ms": 0.67,
"cumulative_ms": 1375.919
},
{
"module": "praisonaiagents.workflows.yaml_parser",
"self_us": 775,
"cumulative_us": 1369435,
"self_ms": 0.775,
"cumulative_ms": 1369.435
},
{
"module": "praisonaiagents.agent.agent",
"self_us": 19,
"cumulative_us": 1349174,
"self_ms": 0.019,
"cumulative_ms": 1349.174
},
{
"module": "praisonaiagents.agent",
"self_us": 537,
"cumulative_us": 1349155,
"self_ms": 0.537,
"cumulative_ms": 1349.155
},
{
"module": "praisonaiagents.agent.agent",
"self_us": 1453,
"cumulative_us": 1285299,
"self_ms": 1.453,
"cumulative_ms": 1285.299
},
{
"module": "praisonaiagents.llm.openai_client",
"self_us": 7887,
"cumulative_us": 1123578,
"self_ms": 7.887,
"cumulative_ms": 1123.578
},
{
"module": "openai",
"self_us": 1083,
"cumulative_us": 1115692,
"self_ms": 1.083,
"cumulative_ms": 1115.692
},
{
"module": "openai.types",
"self_us": 2606,
"cumulative_us": 957990,
"self_ms": 2.606,
"cumulative_ms": 957.99
},
{
"module": "openai.types.batch",
"self_us": 1787,
"cumulative_us": 485667,
"self_ms": 1.787,
"cumulative_ms": 485.667
},
{
"module": "openai._models",
"self_us": 13423,
"cumulative_us": 450798,
"self_ms": 13.423,
"cumulative_ms": 450.798
},
{
"module": "openai.types.eval_create_params",
"self_us": 644,
"cumulative_us": 393275,
"self_ms": 0.644,
"cumulative_ms": 393.275
},
{
"module": "openai.types.graders.grader_inputs_param",
"self_us": 17,
"cumulative_us": 359515,
"self_ms": 0.017,
"cumulative_ms": 359.515
},
{
"module": "openai.types.graders",
"self_us": 392,
"cumulative_us": 359498,
"self_ms": 0.392,
"cumulative_ms": 359.498
},
{
"module": "openai.types.graders.multi_grader",
"self_us": 3847,
"cumulative_us": 351251,
"self_ms": 3.847,
"cumulative_ms": 351.251
},
{
"module": "openai.types.graders.label_model_grader",
"self_us": 5450,
"cumulative_us": 337291,
"self_ms": 5.45,
"cumulative_ms": 337.291
},
{
"module": "openai.types.graders.grader_inputs",
"self_us": 545,
"cumulative_us": 331841,
"self_ms": 0.545,
"cumulative_ms": 331.841
},
{
"module": "openai.types.responses.response_input_text",
"self_us": 21,
"cumulative_us": 331297,
"self_ms": 0.021,
"cumulative_ms": 331.297
},
{
"module": "openai.types.responses",
"self_us": 1301,
"cumulative_us": 331277,
"self_ms": 1.301,
"cumulative_ms": 331.277
},
{
"module": "rich.logging",
"self_us": 1066,
"cumulative_us": 238256,
"self_ms": 1.066,
"cumulative_ms": 238.256
}
],
"scenarios": [
{
"name": "simple_query",
"config": {
"prompt": "What is 2+2?",
"model": "default",
"stream": false,
"iterations": 2
},
"import_time_stats": {
"mean": 0.008833496394800022,
"min": 0.005916997906751931,
"max": 0.011749994882848114,
"stdev": 0.0041245517164382375,
"median": 0.008833496394800022
},
"total_time_stats": {
"mean": 1727.4090415012324,
"min": 1367.2264170018025,
"max": 2087.5916660006624,
"stdev": 509.3751524982296,
"median": 1727.4090415012324
},
"first_token_stats": null,
"raw_import_times_ms": [
0.011749994882848114,
0.005916997906751931
],
"raw_total_times_ms": [
1367.2264170018025,
2087.5916660006624
]
},
{
"name": "simple_stream",
"config": {
"prompt": "What is 2+2?",
"model": "default",
"stream": true,
"iterations": 2
},
"import_time_stats": {
"mean": 0.008792001608526334,
"min": 0.006375004886649549,
"max": 0.01120899833040312,
"stdev": 0.0034181495442894613,
"median": 0.008792001608526334
},
"total_time_stats": {
"mean": 2321.0982290001994,
"min": 1697.1089580038097,
"max": 2945.087499996589,
"stdev": 882.4540898183949,
"median": 2321.0982290001994
},
"first_token_stats": {
"mean": 2312.8511880022415,
"min": 1681.2440420035273,
"max": 2944.458334000956,
"stdev": 893.2273919631452,
"median": 2312.8511880022415
},
"raw_import_times_ms": [
0.01120899833040312,
0.006375004886649549
],
"raw_total_times_ms": [
2945.087499996589,
1697.1089580038097
]
}
]
}
@@ -0,0 +1,80 @@
"""
Profile Suite Example for PraisonAI
This example demonstrates how to run a comprehensive profiling suite
programmatically with multiple scenarios.
Run with:
python suite_profiling.py
"""
import os
# Ensure API key is set
if not os.environ.get("OPENAI_API_KEY"):
print("Please set OPENAI_API_KEY environment variable")
exit(1)
from praisonai.cli.features.profiler import (
ScenarioConfig,
run_profile_suite,
)
def main():
"""Run a profiling suite with custom scenarios."""
# Define custom scenarios
scenarios = [
ScenarioConfig(
name="simple_query",
prompt="What is 2+2?",
stream=False,
iterations=2,
warmup=1,
),
ScenarioConfig(
name="simple_stream",
prompt="What is 2+2?",
stream=True,
iterations=2,
warmup=1,
),
]
# Run suite with custom output directory
print("🔬 Running Profile Suite...")
result = run_profile_suite(
output_dir="./profile_results",
scenarios=scenarios,
verbose=True,
)
# Print summary
print("\n" + "=" * 60)
print("Profile Suite Results")
print("=" * 60)
print("\n📊 Startup Times:")
print(f" Cold Start: {result.startup_cold_ms:.2f}ms")
print(f" Warm Start: {result.startup_warm_ms:.2f}ms")
if result.import_analysis:
print("\n📦 Top Import:")
print(f" {result.import_analysis[0]['module']}: {result.import_analysis[0]['cumulative_ms']:.2f}ms")
print("\n🎯 Scenario Results:")
for scenario in result.scenarios:
stats = scenario.get_stats(scenario.total_times)
print(f" {scenario.name}:")
print(f" Mean: {stats['mean']:.2f}ms")
print(f" Min: {stats['min']:.2f}ms")
print(f" Max: {stats['max']:.2f}ms")
print("\n✅ Results saved to: ./profile_results/")
print(" - suite_results.json (machine-readable)")
print(" - suite_report.txt (human-readable)")
if __name__ == "__main__":
main()
@@ -0,0 +1,165 @@
"""
Session Persistence Example
Demonstrates automatic session persistence with zero configuration.
When you provide a session_id to an Agent, conversation history is
automatically saved to disk and restored on subsequent runs.
Usage:
python session_persistence_example.py
Requirements:
- praisonaiagents package installed
- OPENAI_API_KEY environment variable set
"""
from praisonaiagents import Agent
def example_basic_persistence():
"""Basic session persistence example."""
print("=" * 60)
print("EXAMPLE 1: Basic Session Persistence")
print("=" * 60)
session_id = "demo-session-basic"
# Create agent with session_id
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant. Be very brief.",
session_id=session_id,
verbose=False,
)
# First message
print("\n[User]: My favorite color is blue and my lucky number is 7.")
response1 = agent.chat("My favorite color is blue and my lucky number is 7.")
print(f"[Assistant]: {response1}")
# Second message - agent remembers
print("\n[User]: What is my favorite color?")
response2 = agent.chat("What is my favorite color?")
print(f"[Assistant]: {response2}")
print(f"\n✅ Session saved to: ~/.praison/sessions/{session_id}.json")
print(f" Chat history length: {len(agent.chat_history)} messages")
def example_session_restoration():
"""Demonstrates session restoration across Agent instances."""
print("\n" + "=" * 60)
print("EXAMPLE 2: Session Restoration")
print("=" * 60)
session_id = "demo-session-restore"
# First agent instance
print("\n--- First Agent Instance ---")
agent1 = Agent(
name="MemoryBot",
instructions="You are a helpful assistant. Be very brief.",
session_id=session_id,
verbose=False,
)
print("[User]: Remember that my pet's name is Max.")
response1 = agent1.chat("Remember that my pet's name is Max.")
print(f"[Assistant]: {response1}")
# Simulate new process by creating new agent instance
print("\n--- New Agent Instance (simulating restart) ---")
agent2 = Agent(
name="MemoryBot",
instructions="You are a helpful assistant. Be very brief.",
session_id=session_id,
verbose=False,
)
print("[User]: What is my pet's name?")
response2 = agent2.chat("What is my pet's name?")
print(f"[Assistant]: {response2}")
if "max" in response2.lower():
print("\n✅ Session restored successfully! Agent remembered 'Max'.")
else:
print("\n⚠️ Agent may not have remembered correctly.")
def example_in_memory_only():
"""Demonstrates in-memory memory without persistence."""
print("\n" + "=" * 60)
print("EXAMPLE 3: In-Memory Only (No session_id)")
print("=" * 60)
# Agent without session_id - in-memory only
agent = Agent(
name="TempBot",
instructions="You are a helpful assistant. Be very brief.",
verbose=False,
)
print("\n[User]: My name is Alice.")
response1 = agent.chat("My name is Alice.")
print(f"[Assistant]: {response1}")
print("\n[User]: What is my name?")
response2 = agent.chat("What is my name?")
print(f"[Assistant]: {response2}")
print(f"\n✅ In-memory chat history: {len(agent.chat_history)} messages")
print(" (This will be lost when the Agent instance is garbage collected)")
def example_direct_store_access():
"""Demonstrates direct access to the session store."""
print("\n" + "=" * 60)
print("EXAMPLE 4: Direct Session Store Access")
print("=" * 60)
from praisonaiagents.session import get_default_session_store
store = get_default_session_store()
session_id = "demo-direct-access"
# Add messages directly
store.add_user_message(session_id, "Hello from direct access!")
store.add_assistant_message(session_id, "Hi! I received your message.")
# Get history
history = store.get_chat_history(session_id)
print(f"\n✅ Added {len(history)} messages directly to session store")
for msg in history:
print(f" [{msg['role']}]: {msg['content']}")
# List all sessions
sessions = store.list_sessions()
print(f"\n📋 Total sessions in store: {len(sessions)}")
# Clean up demo session
store.delete_session(session_id)
print(f" Deleted demo session: {session_id}")
def main():
"""Run all examples."""
print("\n" + "=" * 60)
print("SESSION PERSISTENCE EXAMPLES")
print("=" * 60)
# Run examples
example_basic_persistence()
example_session_restoration()
example_in_memory_only()
example_direct_store_access()
print("\n" + "=" * 60)
print("ALL EXAMPLES COMPLETE")
print("=" * 60)
print("\nSession files are stored in: ~/.praison/sessions/")
print("You can inspect them with: cat ~/.praison/sessions/<session_id>.json")
if __name__ == "__main__":
main()
+30
View File
@@ -0,0 +1,30 @@
"""
Basic TUI Usage Example for PraisonAI.
This example demonstrates how to launch the interactive TUI.
"""
import os
# Ensure API key is set
if not os.environ.get("OPENAI_API_KEY"):
print("Please set OPENAI_API_KEY environment variable")
exit(1)
def main():
"""Launch the TUI."""
try:
from praisonai.cli.features.tui import run_tui
# Launch with default settings
run_tui()
except ImportError:
print("Textual is required for TUI. Install with:")
print(" pip install praisonai[tui]")
exit(1)
if __name__ == "__main__":
main()
+134
View File
@@ -0,0 +1,134 @@
"""
Headless TUI Simulation Example for PraisonAI.
This example demonstrates how to run TUI simulations without
launching the interactive UI - useful for testing and CI.
"""
import asyncio
from praisonai.cli.features.tui import (
TuiOrchestrator,
SimulationRunner,
MockProvider,
)
from praisonai.cli.features.tui.orchestrator import OutputMode
from praisonai.cli.features.queue import QueueConfig
async def run_simulation():
"""Run a headless simulation."""
# Create orchestrator with mock provider (no real API calls)
config = QueueConfig(enable_persistence=False)
orchestrator = TuiOrchestrator(
queue_config=config,
output_mode=OutputMode.PRETTY,
debug=True,
)
# Create simulation runner
runner = SimulationRunner(orchestrator, assert_mode=True)
# Define simulation script
script = {
"session_id": "sim-example",
"model": "gpt-4o-mini",
"steps": [
# Navigate to different screens
{"action": "navigate", "args": {"screen": "main"}},
{"action": "focus", "args": {"widget": "composer"}},
# Change model
{
"action": "model",
"args": {"model": "gpt-4"},
"expected": {"model": "gpt-4"}
},
# Navigate to queue screen
{"action": "navigate", "args": {"screen": "queue"}},
# Take a snapshot
{"action": "snapshot"},
# Navigate back
{"action": "navigate", "args": {"screen": "main"}},
# Small delay
{"action": "sleep", "args": {"seconds": 0.5}},
]
}
# Run the simulation
print("Running headless TUI simulation...")
print("=" * 50)
success = await runner.run_script(script)
# Print results
print("=" * 50)
summary = runner.get_summary()
print(f"\nSimulation {'PASSED' if success else 'FAILED'}")
print(f" Assertions passed: {summary['assertions_passed']}")
print(f" Assertions failed: {summary['assertions_failed']}")
if summary['errors']:
print("\nErrors:")
for error in summary['errors']:
print(f" - {error}")
return success
async def run_with_events():
"""Run simulation and capture all events."""
config = QueueConfig(enable_persistence=False)
orchestrator = TuiOrchestrator(
queue_config=config,
output_mode=OutputMode.SILENT,
)
# Capture events
events = []
orchestrator.add_event_callback(lambda e: events.append(e))
await orchestrator.start(session_id="event-capture")
# Perform some actions
orchestrator.set_model("gpt-4")
orchestrator.navigate_screen("queue")
orchestrator.set_focus("queue-panel")
orchestrator.navigate_screen("main")
await orchestrator.stop()
# Print captured events
print("\nCaptured Events:")
print("-" * 40)
for event in events:
print(f" {event.event_type.value}")
return events
def main():
"""Main entry point."""
print("PraisonAI TUI Headless Simulation Example")
print("=" * 50)
# Run simulation
success = asyncio.run(run_simulation())
# Run event capture
print("\n" + "=" * 50)
events = asyncio.run(run_with_events())
print(f"\nTotal events captured: {len(events)}")
return 0 if success else 1
if __name__ == "__main__":
exit(main())
+122
View File
@@ -0,0 +1,122 @@
"""
Queue Management Example for PraisonAI.
This example demonstrates how to use the queue system programmatically.
"""
import asyncio
from praisonai.cli.features.queue import (
QueueManager,
QueueConfig,
QueuedRun,
RunState,
RunPriority,
)
async def main():
"""Demonstrate queue management."""
print("PraisonAI Queue Management Example")
print("=" * 50)
# Create queue configuration
config = QueueConfig(
max_concurrent_global=2,
max_concurrent_per_agent=1,
max_queue_size=10,
enable_persistence=False, # Use in-memory for demo
)
# Track outputs
outputs = {}
async def on_output(run_id: str, chunk: str):
if run_id not in outputs:
outputs[run_id] = ""
outputs[run_id] += chunk
print(f" [{run_id[:8]}] {chunk}", end="", flush=True)
async def on_complete(run_id: str, run: QueuedRun):
print(f"\n [{run_id[:8]}] Completed: {run.state.value}")
async def on_error(run_id: str, error: Exception):
print(f"\n [{run_id[:8]}] Error: {error}")
# Create queue manager
manager = QueueManager(
config=config,
on_output=on_output,
on_complete=on_complete,
on_error=on_error,
)
# Start the manager
await manager.start(recover=False)
print("Queue manager started")
# Submit some runs with different priorities
print("\nSubmitting runs...")
run1 = await manager.submit(
input_content="Low priority task",
agent_name="Agent1",
priority=RunPriority.LOW,
)
print(f" Submitted run1: {run1} (LOW priority)")
run2 = await manager.submit(
input_content="High priority task",
agent_name="Agent2",
priority=RunPriority.HIGH,
)
print(f" Submitted run2: {run2} (HIGH priority)")
run3 = await manager.submit(
input_content="Normal priority task",
agent_name="Agent1",
priority=RunPriority.NORMAL,
)
print(f" Submitted run3: {run3} (NORMAL priority)")
# Check queue status
print(f"\nQueue status:")
print(f" Queued: {manager.queued_count}")
print(f" Running: {manager.running_count}")
# List runs
runs = manager.list_runs()
print(f"\nAll runs ({len(runs)}):")
for run in runs:
print(f" {run.run_id[:8]}: {run.agent_name} - {run.state.value} ({run.priority.name})")
# Wait a bit for processing
print("\nWaiting for processing...")
await asyncio.sleep(2)
# Cancel a run
if runs:
cancel_id = runs[-1].run_id
print(f"\nCancelling run: {cancel_id[:8]}")
await manager.cancel(cancel_id)
# Get stats
stats = manager.get_stats()
print(f"\nQueue statistics:")
print(f" Total runs: {stats.total_runs}")
print(f" Queued: {stats.queued_count}")
print(f" Running: {stats.running_count}")
print(f" Succeeded: {stats.succeeded_count}")
print(f" Failed: {stats.failed_count}")
print(f" Cancelled: {stats.cancelled_count}")
# Stop the manager
await manager.stop()
print("\nQueue manager stopped")
print("\n" + "=" * 50)
print("Example complete!")
if __name__ == "__main__":
asyncio.run(main())
@@ -687,6 +687,11 @@ Your Goal: {self.goal}
self._db = db
self._session_id = session_id
self._db_initialized = False
# Session store for JSON-based persistence (lazy - no imports until used)
# Used when session_id is provided but no DB adapter
self._session_store = None
self._session_store_initialized = False
@property
def console(self):
@@ -2094,6 +2099,49 @@ Your Goal: {self.goal}"""
self._db_initialized = True
self._current_run_id = None # Track current run
def _init_session_store(self):
"""
Initialize session store for JSON-based persistence (lazy, first chat only).
This is used when session_id is provided but no DB adapter.
Enables automatic session persistence with zero configuration.
"""
if self._session_store_initialized:
return
# Only initialize if session_id is provided and no DB adapter
if self._session_id is None or self._db is not None:
self._session_store_initialized = True
return
try:
from ..session import get_default_session_store
self._session_store = get_default_session_store()
# Restore chat history from previous session
history = self._session_store.get_chat_history(self._session_id)
if history:
# Only restore if chat_history is empty (avoid duplicates)
if not self.chat_history:
for msg in history:
self.chat_history.append({
"role": msg["role"],
"content": msg["content"]
})
logging.info(f"Restored session {self._session_id} with {len(history)} messages from JSON store")
# Set agent info
self._session_store.set_agent_info(
self._session_id,
agent_name=self.name,
user_id=self.user_id,
)
except Exception as e:
logging.warning(f"Failed to initialize session store: {e}")
self._session_store = None
self._session_store_initialized = True
def _start_run(self, input_content: str):
"""Start a new run (turn) for persistence tracking."""
@@ -2135,17 +2183,27 @@ Your Goal: {self.goal}"""
self._current_run_id = None
def _persist_message(self, role: str, content: str):
"""Persist a message to the DB if adapter is provided."""
if self._db is None:
"""Persist a message to the DB or session store."""
# Try DB adapter first
if self._db is not None:
try:
if role == "user":
self._db.on_user_message(self._session_id, content)
elif role == "assistant":
self._db.on_agent_message(self._session_id, content)
except Exception as e:
logging.warning(f"Failed to persist message to DB: {e}")
return
try:
if role == "user":
self._db.on_user_message(self._session_id, content)
elif role == "assistant":
self._db.on_agent_message(self._session_id, content)
except Exception as e:
logging.warning(f"Failed to persist message: {e}")
# Fall back to session store (JSON-based)
if self._session_store is not None and self._session_id is not None:
try:
if role == "user":
self._session_store.add_user_message(self._session_id, content)
elif role == "assistant":
self._session_store.add_assistant_message(self._session_id, content)
except Exception as e:
logging.warning(f"Failed to persist message to session store: {e}")
@property
def session_id(self) -> Optional[str]:
@@ -2160,6 +2218,10 @@ Your Goal: {self.goal}"""
# Initialize DB session on first chat (lazy)
self._init_db_session()
# Initialize session store for JSON-based persistence (lazy)
# This enables automatic session persistence when session_id is provided
self._init_session_store()
# Start a new run for this chat turn
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
self._start_run(prompt_str)
+22 -13
View File
@@ -58,6 +58,12 @@ class LLM:
# Class-level flag for one-time logging configuration
_logging_configured = False
# Class-level cache for LiteLLM module (avoids repeated import overhead)
_litellm_module = None
# Class-level flag for LiteLLM global settings (set once)
_litellm_configured = False
# Default window sizes for different models (75% of actual to be safe)
MODEL_WINDOWS = {
# OpenAI
@@ -272,14 +278,16 @@ Respond with ONLY a valid JSON tool call in this format:
if not LLM._logging_configured:
LLM._configure_logging()
# Import litellm after logging is configured
try:
import litellm
except ImportError:
raise ImportError(
"LiteLLM is required but not installed. "
"Please install with: pip install 'praisonaiagents[llm]'"
)
# Use cached LiteLLM module or import once (avoids repeated import overhead)
if LLM._litellm_module is None:
try:
import litellm
LLM._litellm_module = litellm
except ImportError:
raise ImportError(
"LiteLLM is required but not installed. "
"Please install with: pip install 'praisonaiagents[llm]'"
)
self.model = model
self.timeout = timeout
@@ -341,11 +349,12 @@ Respond with ONLY a valid JSON tool call in this format:
self._formatted_tools_cache = {}
self._max_cache_size = 100
# Enable error dropping for cleaner output
import litellm
litellm.drop_params = True
# Enable parameter modification for providers like Anthropic
litellm.modify_params = True
# Configure LiteLLM global settings only once (avoids repeated setup cost)
if not LLM._litellm_configured:
litellm = LLM._litellm_module
litellm.drop_params = True
litellm.modify_params = True
LLM._litellm_configured = True
self._setup_event_tracking(events)
# Log all initialization parameters when in debug mode or verbose >= 10
+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():
@@ -0,0 +1,185 @@
# Session Persistence for PraisonAI Agents
This module provides automatic session persistence with zero configuration.
## Quick Start
```python
from praisonaiagents import Agent
# With session persistence (auto-enabled)
agent = Agent(
name="Assistant",
session_id="my-session-123"
)
agent.start("Hello, my name is Alice")
# Later, in a new process - history is restored automatically
agent = Agent(
name="Assistant",
session_id="my-session-123"
)
agent.start("What is my name?") # Agent remembers: "Alice"
```
## How It Works
When you provide a `session_id` to an Agent:
1. **Automatic Persistence**: Conversation history is automatically saved to disk
2. **Automatic Restoration**: When a new Agent is created with the same `session_id`, history is restored
3. **Zero Configuration**: No database setup required - uses JSON files by default
### Default Storage Location
Sessions are stored in: `~/.praison/sessions/{session_id}.json`
### Session File Format
```json
{
"session_id": "my-session-123",
"messages": [
{"role": "user", "content": "Hello", "timestamp": 1704153600.0},
{"role": "assistant", "content": "Hi there!", "timestamp": 1704153601.5}
],
"created_at": "2026-01-02T04:00:00+00:00",
"updated_at": "2026-01-02T04:01:00+00:00",
"agent_name": "Assistant"
}
```
## Behavior Matrix
| Scenario | Behavior |
|----------|----------|
| `session_id` provided, no DB | JSON persistence (auto) |
| `session_id` provided, with DB | DB adapter used |
| No `session_id`, same Agent instance | In-memory only |
| No `session_id`, new Agent instance | No history |
## Advanced Usage
### Direct Session Store Access
```python
from praisonaiagents.session import get_default_session_store
store = get_default_session_store()
# Add messages
store.add_user_message("session-123", "Hello")
store.add_assistant_message("session-123", "Hi there!")
# Get history
history = store.get_chat_history("session-123")
# [{"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi there!"}]
# List all sessions
sessions = store.list_sessions()
# Delete a session
store.delete_session("session-123")
```
### Custom Session Directory
```python
from praisonaiagents.session import DefaultSessionStore
store = DefaultSessionStore(
session_dir="/custom/path/sessions",
max_messages=200, # Default: 100
lock_timeout=10.0, # Default: 5.0 seconds
)
```
### Using with DB Adapter
When a DB adapter is provided, it takes precedence over JSON persistence:
```python
from praisonaiagents import Agent
from praisonai.db import PostgresAdapter
agent = Agent(
name="Assistant",
session_id="my-session",
db=PostgresAdapter(connection_string="...")
)
```
## Multi-Process Safety
The session store uses file locking to ensure safe concurrent access:
- **Unix**: Uses `fcntl.flock()` for file locking
- **Windows**: Uses `msvcrt.locking()` for file locking
- **Atomic Writes**: Uses temp file + rename to prevent corruption
Multiple processes can safely read/write to the same session.
## Context Caching
For cost optimization, use `prompt_caching=True` with Anthropic models:
```python
agent = Agent(
name="Assistant",
session_id="my-session",
prompt_caching=True, # Enables Anthropic prompt caching
)
```
This caches the system prompt, reducing token costs for repeated conversations.
## API Reference
### DefaultSessionStore
```python
class DefaultSessionStore:
def __init__(
self,
session_dir: Optional[str] = None, # Default: ~/.praison/sessions/
max_messages: int = 100,
lock_timeout: float = 5.0,
): ...
def add_message(self, session_id: str, role: str, content: str) -> bool: ...
def add_user_message(self, session_id: str, content: str) -> bool: ...
def add_assistant_message(self, session_id: str, content: str) -> bool: ...
def get_chat_history(self, session_id: str, max_messages: int = None) -> List[Dict]: ...
def get_session(self, session_id: str) -> SessionData: ...
def clear_session(self, session_id: str) -> bool: ...
def delete_session(self, session_id: str) -> bool: ...
def list_sessions(self, limit: int = 50) -> List[Dict]: ...
def session_exists(self, session_id: str) -> bool: ...
```
### SessionData
```python
@dataclass
class SessionData:
session_id: str
messages: List[SessionMessage]
created_at: str
updated_at: str
agent_name: Optional[str]
user_id: Optional[str]
metadata: Dict[str, Any]
def get_chat_history(self, max_messages: int = None) -> List[Dict[str, str]]: ...
```
### SessionMessage
```python
@dataclass
class SessionMessage:
role: str # "user", "assistant", "system"
content: str
timestamp: float
metadata: Dict[str, Any]
```
@@ -0,0 +1,70 @@
"""
Session persistence module for PraisonAI Agents.
Provides automatic session persistence with zero configuration.
When a session_id is provided to an Agent, conversation history
is automatically persisted to disk and restored on subsequent runs.
Usage:
from praisonaiagents import Agent
# With session persistence (auto-enabled)
agent = Agent(
name="Assistant",
session_id="my-session-123"
)
agent.start("Hello")
# Later, new process - history is restored
agent = Agent(
name="Assistant",
session_id="my-session-123"
)
agent.start("What did I say before?") # Remembers!
Default storage: ~/.praison/sessions/{session_id}.json
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .store import DefaultSessionStore, SessionMessage, SessionData
# Lazy loading for zero import overhead
_module_cache = {}
def __getattr__(name: str):
"""Lazy load session components."""
if name in _module_cache:
return _module_cache[name]
if name == "DefaultSessionStore":
from .store import DefaultSessionStore
_module_cache[name] = DefaultSessionStore
return DefaultSessionStore
if name == "SessionMessage":
from .store import SessionMessage
_module_cache[name] = SessionMessage
return SessionMessage
if name == "SessionData":
from .store import SessionData
_module_cache[name] = SessionData
return SessionData
if name == "get_default_session_store":
from .store import get_default_session_store
_module_cache[name] = get_default_session_store
return get_default_session_store
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"DefaultSessionStore",
"SessionMessage",
"SessionData",
"get_default_session_store",
]
@@ -0,0 +1,521 @@
"""
Default Session Store for PraisonAI Agents.
JSON-based session persistence with file locking and atomic writes.
Zero dependencies beyond stdlib.
"""
import fcntl
import json
import logging
import os
import sys
import tempfile
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Default session directory
DEFAULT_SESSION_DIR = os.path.expanduser("~/.praison/sessions")
# Default limits
DEFAULT_MAX_MESSAGES = 100
DEFAULT_LOCK_TIMEOUT = 5.0 # seconds
@dataclass
class SessionMessage:
"""A single message in a session."""
role: str # "user", "assistant", "system"
content: str
timestamp: float = field(default_factory=time.time)
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"role": self.role,
"content": self.content,
"timestamp": self.timestamp,
"metadata": self.metadata,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SessionMessage":
"""Create from dictionary."""
return cls(
role=data.get("role", "user"),
content=data.get("content", ""),
timestamp=data.get("timestamp", time.time()),
metadata=data.get("metadata", {}),
)
@dataclass
class SessionData:
"""Complete session data structure."""
session_id: str
messages: List[SessionMessage] = field(default_factory=list)
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
updated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
agent_name: Optional[str] = None
user_id: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"session_id": self.session_id,
"messages": [m.to_dict() for m in self.messages],
"created_at": self.created_at,
"updated_at": self.updated_at,
"agent_name": self.agent_name,
"user_id": self.user_id,
"metadata": self.metadata,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SessionData":
"""Create from dictionary."""
messages = [
SessionMessage.from_dict(m)
for m in data.get("messages", [])
]
return cls(
session_id=data.get("session_id", ""),
messages=messages,
created_at=data.get("created_at", datetime.now(timezone.utc).isoformat()),
updated_at=data.get("updated_at", datetime.now(timezone.utc).isoformat()),
agent_name=data.get("agent_name"),
user_id=data.get("user_id"),
metadata=data.get("metadata", {}),
)
def get_chat_history(self, max_messages: Optional[int] = None) -> List[Dict[str, str]]:
"""
Get chat history in LLM-compatible format.
Returns list of {"role": "user/assistant", "content": "..."} dicts.
"""
messages = self.messages
if max_messages and len(messages) > max_messages:
messages = messages[-max_messages:]
return [{"role": m.role, "content": m.content} for m in messages]
class FileLock:
"""
Cross-platform file locking.
Uses fcntl on Unix and msvcrt on Windows.
"""
def __init__(self, filepath: str, timeout: float = DEFAULT_LOCK_TIMEOUT):
self.filepath = filepath
self.timeout = timeout
self._lock_file = None
self._lock_path = filepath + ".lock"
def __enter__(self):
self.acquire()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.release()
return False
def acquire(self) -> bool:
"""Acquire the file lock."""
# Ensure directory exists
os.makedirs(os.path.dirname(self._lock_path) or ".", exist_ok=True)
start_time = time.time()
while True:
try:
self._lock_file = open(self._lock_path, "w")
if sys.platform == "win32":
# Windows locking
import msvcrt
msvcrt.locking(self._lock_file.fileno(), msvcrt.LK_NBLCK, 1)
else:
# Unix locking
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
except (IOError, OSError, BlockingIOError):
if self._lock_file:
self._lock_file.close()
self._lock_file = None
if time.time() - start_time > self.timeout:
logger.warning(f"Failed to acquire lock for {self.filepath} after {self.timeout}s")
return False
time.sleep(0.05) # Wait 50ms before retry
def release(self) -> None:
"""Release the file lock."""
if self._lock_file:
try:
if sys.platform == "win32":
import msvcrt
msvcrt.locking(self._lock_file.fileno(), msvcrt.LK_UNLCK, 1)
else:
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_UN)
except (IOError, OSError):
pass
finally:
self._lock_file.close()
self._lock_file = None
# Clean up lock file
try:
os.remove(self._lock_path)
except (IOError, OSError):
pass
class DefaultSessionStore:
"""
JSON-based session persistence with file locking.
Features:
- Zero configuration required
- Automatic file locking for multi-process safety
- Atomic writes to prevent corruption
- Configurable message limits
- Thread-safe operations
Usage:
store = DefaultSessionStore()
# Add messages
store.add_message("session-123", "user", "Hello")
store.add_message("session-123", "assistant", "Hi there!")
# Get history
history = store.get_chat_history("session-123")
# [{"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi there!"}]
# Restore in new process
store2 = DefaultSessionStore()
history = store2.get_chat_history("session-123") # Same history!
"""
def __init__(
self,
session_dir: Optional[str] = None,
max_messages: int = DEFAULT_MAX_MESSAGES,
lock_timeout: float = DEFAULT_LOCK_TIMEOUT,
):
"""
Initialize session store.
Args:
session_dir: Directory for session files. Defaults to ~/.praison/sessions/
max_messages: Maximum messages to keep per session.
lock_timeout: Timeout for file lock acquisition.
"""
self.session_dir = session_dir or DEFAULT_SESSION_DIR
self.max_messages = max_messages
self.lock_timeout = lock_timeout
self._lock = threading.RLock()
self._cache: Dict[str, SessionData] = {}
# Ensure session directory exists
os.makedirs(self.session_dir, exist_ok=True)
def _get_session_path(self, session_id: str) -> str:
"""Get the file path for a session."""
# Sanitize session_id for filesystem
safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id)
return os.path.join(self.session_dir, f"{safe_id}.json")
def _load_session(self, session_id: str) -> SessionData:
"""Load session from disk with file locking."""
filepath = self._get_session_path(session_id)
# Check cache first
with self._lock:
if session_id in self._cache:
return self._cache[session_id]
# Load from disk
if not os.path.exists(filepath):
session = SessionData(session_id=session_id)
with self._lock:
self._cache[session_id] = session
return session
with FileLock(filepath, self.lock_timeout):
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
session = SessionData.from_dict(data)
except (json.JSONDecodeError, IOError) as e:
logger.warning(f"Failed to load session {session_id}: {e}")
session = SessionData(session_id=session_id)
with self._lock:
self._cache[session_id] = session
return session
def _save_session(self, session: SessionData) -> bool:
"""Save session to disk with atomic write."""
filepath = self._get_session_path(session.session_id)
session.updated_at = datetime.now(timezone.utc).isoformat()
# Trim messages if over limit
if len(session.messages) > self.max_messages:
session.messages = session.messages[-self.max_messages:]
with FileLock(filepath, self.lock_timeout):
try:
# Atomic write: write to temp file, then rename
dir_path = os.path.dirname(filepath)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=dir_path,
delete=False,
suffix=".tmp"
) as f:
json.dump(session.to_dict(), f, indent=2, ensure_ascii=False)
temp_path = f.name
# Atomic rename
os.replace(temp_path, filepath)
return True
except (IOError, OSError) as e:
logger.error(f"Failed to save session {session.session_id}: {e}")
# Clean up temp file if it exists
try:
if 'temp_path' in locals():
os.remove(temp_path)
except (IOError, OSError):
pass
return False
def add_message(
self,
session_id: str,
role: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
"""
Add a message to a session.
Args:
session_id: The session ID.
role: Message role ("user", "assistant", "system").
content: Message content.
metadata: Optional metadata.
Returns:
True if saved successfully.
"""
filepath = self._get_session_path(session_id)
message = SessionMessage(
role=role,
content=content,
timestamp=time.time(),
metadata=metadata or {},
)
# Use file lock for atomic read-modify-write
with FileLock(filepath, self.lock_timeout):
# Always reload from disk inside lock to avoid race conditions
if os.path.exists(filepath):
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
session = SessionData.from_dict(data)
except (json.JSONDecodeError, IOError):
session = SessionData(session_id=session_id)
else:
session = SessionData(session_id=session_id)
# Add message
session.messages.append(message)
session.updated_at = datetime.now(timezone.utc).isoformat()
# Trim messages if over limit
if len(session.messages) > self.max_messages:
session.messages = session.messages[-self.max_messages:]
# Write atomically
try:
dir_path = os.path.dirname(filepath) or "."
os.makedirs(dir_path, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=dir_path,
delete=False,
suffix=".tmp"
) as f:
json.dump(session.to_dict(), f, indent=2, ensure_ascii=False)
temp_path = f.name
os.replace(temp_path, filepath)
# Update cache
with self._lock:
self._cache[session_id] = session
return True
except (IOError, OSError) as e:
logger.error(f"Failed to save session {session_id}: {e}")
try:
if 'temp_path' in locals():
os.remove(temp_path)
except (IOError, OSError):
pass
return False
def add_user_message(
self,
session_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
"""Add a user message to a session."""
return self.add_message(session_id, "user", content, metadata)
def add_assistant_message(
self,
session_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
"""Add an assistant message to a session."""
return self.add_message(session_id, "assistant", content, metadata)
def get_chat_history(
self,
session_id: str,
max_messages: Optional[int] = None,
) -> List[Dict[str, str]]:
"""
Get chat history for a session in LLM-compatible format.
Args:
session_id: The session ID.
max_messages: Maximum messages to return (defaults to store limit).
Returns:
List of {"role": "user/assistant", "content": "..."} dicts.
"""
session = self._load_session(session_id)
limit = max_messages or self.max_messages
return session.get_chat_history(limit)
def get_session(self, session_id: str) -> SessionData:
"""Get full session data."""
return self._load_session(session_id)
def set_agent_info(
self,
session_id: str,
agent_name: Optional[str] = None,
user_id: Optional[str] = None,
) -> bool:
"""Set agent info for a session."""
session = self._load_session(session_id)
with self._lock:
if agent_name:
session.agent_name = agent_name
if user_id:
session.user_id = user_id
self._cache[session_id] = session
return self._save_session(session)
def clear_session(self, session_id: str) -> bool:
"""Clear all messages from a session."""
session = self._load_session(session_id)
with self._lock:
session.messages.clear()
self._cache[session_id] = session
return self._save_session(session)
def delete_session(self, session_id: str) -> bool:
"""Delete a session completely."""
filepath = self._get_session_path(session_id)
with self._lock:
if session_id in self._cache:
del self._cache[session_id]
try:
if os.path.exists(filepath):
os.remove(filepath)
return True
except (IOError, OSError) as e:
logger.error(f"Failed to delete session {session_id}: {e}")
return False
def list_sessions(self, limit: int = 50) -> List[Dict[str, Any]]:
"""List all sessions with metadata."""
sessions = []
try:
for filename in os.listdir(self.session_dir):
if filename.endswith(".json"):
filepath = os.path.join(self.session_dir, filename)
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
sessions.append({
"session_id": data.get("session_id", filename[:-5]),
"agent_name": data.get("agent_name"),
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"message_count": len(data.get("messages", [])),
})
except (json.JSONDecodeError, IOError):
continue
except (IOError, OSError):
pass
# Sort by updated_at descending
sessions.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
return sessions[:limit]
def session_exists(self, session_id: str) -> bool:
"""Check if a session exists."""
filepath = self._get_session_path(session_id)
return os.path.exists(filepath)
def invalidate_cache(self, session_id: Optional[str] = None) -> None:
"""Invalidate cache for a session or all sessions."""
with self._lock:
if session_id:
self._cache.pop(session_id, None)
else:
self._cache.clear()
# Global session store instance (lazy initialized)
_default_store: Optional[DefaultSessionStore] = None
_store_lock = threading.Lock()
def get_default_session_store() -> DefaultSessionStore:
"""Get the global default session store instance."""
global _default_store
if _default_store is None:
with _store_lock:
if _default_store is None:
_default_store = DefaultSessionStore()
return _default_store
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.5.2"
version = "0.6.2"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1 @@
# Session module tests
@@ -0,0 +1,404 @@
"""
TDD Tests for Session Store.
Tests for DefaultSessionStore with JSON-based persistence.
"""
import json
import os
import tempfile
import threading
import time
import pytest
from concurrent.futures import ThreadPoolExecutor
from praisonaiagents.session.store import (
DefaultSessionStore,
SessionMessage,
SessionData,
FileLock,
DEFAULT_MAX_MESSAGES,
)
class TestSessionMessage:
"""Tests for SessionMessage dataclass."""
def test_create_message(self):
"""Test creating a session message."""
msg = SessionMessage(role="user", content="Hello")
assert msg.role == "user"
assert msg.content == "Hello"
assert msg.timestamp > 0
assert msg.metadata == {}
def test_message_to_dict(self):
"""Test converting message to dict."""
msg = SessionMessage(role="assistant", content="Hi there!")
d = msg.to_dict()
assert d["role"] == "assistant"
assert d["content"] == "Hi there!"
assert "timestamp" in d
assert "metadata" in d
def test_message_from_dict(self):
"""Test creating message from dict."""
d = {"role": "user", "content": "Test", "timestamp": 123.0, "metadata": {"key": "value"}}
msg = SessionMessage.from_dict(d)
assert msg.role == "user"
assert msg.content == "Test"
assert msg.timestamp == 123.0
assert msg.metadata == {"key": "value"}
class TestSessionData:
"""Tests for SessionData dataclass."""
def test_create_session(self):
"""Test creating session data."""
session = SessionData(session_id="test-123")
assert session.session_id == "test-123"
assert session.messages == []
assert session.created_at is not None
assert session.updated_at is not None
def test_session_to_dict(self):
"""Test converting session to dict."""
session = SessionData(session_id="test-123", agent_name="Assistant")
session.messages.append(SessionMessage(role="user", content="Hello"))
d = session.to_dict()
assert d["session_id"] == "test-123"
assert d["agent_name"] == "Assistant"
assert len(d["messages"]) == 1
def test_session_from_dict(self):
"""Test creating session from dict."""
d = {
"session_id": "test-456",
"messages": [{"role": "user", "content": "Hi"}],
"agent_name": "Bot",
}
session = SessionData.from_dict(d)
assert session.session_id == "test-456"
assert session.agent_name == "Bot"
assert len(session.messages) == 1
def test_get_chat_history(self):
"""Test getting chat history in LLM format."""
session = SessionData(session_id="test")
session.messages.append(SessionMessage(role="user", content="Hello"))
session.messages.append(SessionMessage(role="assistant", content="Hi!"))
history = session.get_chat_history()
assert len(history) == 2
assert history[0] == {"role": "user", "content": "Hello"}
assert history[1] == {"role": "assistant", "content": "Hi!"}
def test_get_chat_history_with_limit(self):
"""Test getting limited chat history."""
session = SessionData(session_id="test")
for i in range(10):
session.messages.append(SessionMessage(role="user", content=f"Message {i}"))
history = session.get_chat_history(max_messages=3)
assert len(history) == 3
assert history[0]["content"] == "Message 7"
assert history[2]["content"] == "Message 9"
class TestFileLock:
"""Tests for FileLock."""
def test_acquire_release(self):
"""Test basic lock acquire and release."""
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, "test.json")
lock = FileLock(filepath, timeout=1.0)
assert lock.acquire()
lock.release()
def test_context_manager(self):
"""Test lock as context manager."""
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, "test.json")
with FileLock(filepath, timeout=1.0):
# Lock is held
pass
# Lock is released
def test_concurrent_locks(self):
"""Test that concurrent locks are serialized."""
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, "test.json")
results = []
def worker(worker_id):
with FileLock(filepath, timeout=5.0):
results.append(f"start-{worker_id}")
time.sleep(0.1)
results.append(f"end-{worker_id}")
threads = [threading.Thread(target=worker, args=(i,)) for i in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()
# Check that starts and ends are paired (no interleaving)
for i in range(3):
start_idx = results.index(f"start-{i}")
end_idx = results.index(f"end-{i}")
# No other start should be between this start and end
for j in range(3):
if i != j:
other_start = results.index(f"start-{j}")
if start_idx < other_start < end_idx:
pytest.fail(f"Lock interleaving detected: {results}")
class TestDefaultSessionStore:
"""Tests for DefaultSessionStore."""
@pytest.fixture
def temp_store(self):
"""Create a temporary session store."""
with tempfile.TemporaryDirectory() as tmpdir:
store = DefaultSessionStore(session_dir=tmpdir)
yield store
def test_add_message(self, temp_store):
"""Test adding a message."""
result = temp_store.add_message("session-1", "user", "Hello")
assert result is True
history = temp_store.get_chat_history("session-1")
assert len(history) == 1
assert history[0]["role"] == "user"
assert history[0]["content"] == "Hello"
def test_add_user_message(self, temp_store):
"""Test adding a user message."""
temp_store.add_user_message("session-1", "Hello")
history = temp_store.get_chat_history("session-1")
assert history[0]["role"] == "user"
def test_add_assistant_message(self, temp_store):
"""Test adding an assistant message."""
temp_store.add_assistant_message("session-1", "Hi there!")
history = temp_store.get_chat_history("session-1")
assert history[0]["role"] == "assistant"
def test_persistence_across_instances(self, temp_store):
"""Test that sessions persist across store instances."""
session_dir = temp_store.session_dir
# Add messages with first store
temp_store.add_user_message("session-1", "Hello")
temp_store.add_assistant_message("session-1", "Hi!")
# Create new store instance
store2 = DefaultSessionStore(session_dir=session_dir)
# Verify messages are restored
history = store2.get_chat_history("session-1")
assert len(history) == 2
assert history[0]["content"] == "Hello"
assert history[1]["content"] == "Hi!"
def test_session_file_created(self, temp_store):
"""Test that session file is created on disk."""
temp_store.add_user_message("test-session", "Hello")
filepath = os.path.join(temp_store.session_dir, "test-session.json")
assert os.path.exists(filepath)
with open(filepath, "r") as f:
data = json.load(f)
assert data["session_id"] == "test-session"
assert len(data["messages"]) == 1
def test_max_messages_limit(self):
"""Test that messages are trimmed to max limit."""
with tempfile.TemporaryDirectory() as tmpdir:
store = DefaultSessionStore(session_dir=tmpdir, max_messages=5)
for i in range(10):
store.add_user_message("session-1", f"Message {i}")
history = store.get_chat_history("session-1")
assert len(history) == 5
assert history[0]["content"] == "Message 5"
assert history[4]["content"] == "Message 9"
def test_clear_session(self, temp_store):
"""Test clearing a session."""
temp_store.add_user_message("session-1", "Hello")
temp_store.add_assistant_message("session-1", "Hi!")
temp_store.clear_session("session-1")
history = temp_store.get_chat_history("session-1")
assert len(history) == 0
def test_delete_session(self, temp_store):
"""Test deleting a session."""
temp_store.add_user_message("session-1", "Hello")
filepath = os.path.join(temp_store.session_dir, "session-1.json")
assert os.path.exists(filepath)
temp_store.delete_session("session-1")
assert not os.path.exists(filepath)
def test_list_sessions(self, temp_store):
"""Test listing sessions."""
temp_store.add_user_message("session-1", "Hello")
temp_store.add_user_message("session-2", "Hi")
temp_store.add_user_message("session-3", "Hey")
sessions = temp_store.list_sessions()
assert len(sessions) == 3
session_ids = [s["session_id"] for s in sessions]
assert "session-1" in session_ids
assert "session-2" in session_ids
assert "session-3" in session_ids
def test_session_exists(self, temp_store):
"""Test checking if session exists."""
assert not temp_store.session_exists("session-1")
temp_store.add_user_message("session-1", "Hello")
assert temp_store.session_exists("session-1")
def test_set_agent_info(self, temp_store):
"""Test setting agent info."""
temp_store.add_user_message("session-1", "Hello")
temp_store.set_agent_info("session-1", agent_name="TestBot", user_id="user-123")
session = temp_store.get_session("session-1")
assert session.agent_name == "TestBot"
assert session.user_id == "user-123"
def test_concurrent_writes(self, temp_store):
"""Test concurrent writes to same session."""
def writer(msg_id):
temp_store.add_user_message("session-1", f"Message {msg_id}")
with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(writer, i) for i in range(10)]
for f in futures:
f.result()
history = temp_store.get_chat_history("session-1")
assert len(history) == 10
def test_sanitize_session_id(self, temp_store):
"""Test that session IDs are sanitized for filesystem."""
temp_store.add_user_message("session/with/slashes", "Hello")
temp_store.add_user_message("session:with:colons", "Hi")
# Should not raise errors
history1 = temp_store.get_chat_history("session/with/slashes")
history2 = temp_store.get_chat_history("session:with:colons")
assert len(history1) == 1
assert len(history2) == 1
def test_empty_session_returns_empty_history(self, temp_store):
"""Test that non-existent session returns empty history."""
history = temp_store.get_chat_history("nonexistent")
assert history == []
def test_invalidate_cache(self, temp_store):
"""Test cache invalidation."""
temp_store.add_user_message("session-1", "Hello")
# Modify file directly
filepath = os.path.join(temp_store.session_dir, "session-1.json")
with open(filepath, "r") as f:
data = json.load(f)
data["messages"].append({"role": "user", "content": "Direct edit", "timestamp": time.time(), "metadata": {}})
with open(filepath, "w") as f:
json.dump(data, f)
# Without invalidation, cache returns old data
history = temp_store.get_chat_history("session-1")
assert len(history) == 1 # Cached
# After invalidation, new data is loaded
temp_store.invalidate_cache("session-1")
history = temp_store.get_chat_history("session-1")
assert len(history) == 2
class TestAgentSessionIntegration:
"""Tests for Agent integration with session store."""
def test_agent_session_persistence(self):
"""Test that Agent persists session with session_id."""
from praisonaiagents import Agent
with tempfile.TemporaryDirectory() as tmpdir:
# Patch the default session directory
import praisonaiagents.session.store as store_module
original_dir = store_module.DEFAULT_SESSION_DIR
store_module.DEFAULT_SESSION_DIR = tmpdir
# Reset global store
store_module._default_store = None
try:
# Create agent with session_id
agent = Agent(
name="TestAgent",
instructions="You are a test agent.",
session_id="test-session-123",
verbose=False,
)
# Verify session store is initialized after first chat
# (We can't actually call chat without API key, so we test init)
agent._init_session_store()
assert agent._session_store is not None
assert agent._session_store_initialized is True
finally:
store_module.DEFAULT_SESSION_DIR = original_dir
store_module._default_store = None
def test_agent_no_session_id_no_persistence(self):
"""Test that Agent without session_id doesn't use session store."""
from praisonaiagents import Agent
agent = Agent(
name="TestAgent",
instructions="You are a test agent.",
verbose=False,
)
agent._init_session_store()
assert agent._session_store is None
assert agent._session_store_initialized is True
def test_agent_with_db_no_session_store(self):
"""Test that Agent with DB adapter doesn't use session store."""
from praisonaiagents import Agent
# Mock DB adapter
class MockDbAdapter:
def on_agent_start(self, **kwargs):
return []
agent = Agent(
name="TestAgent",
instructions="You are a test agent.",
session_id="test-session",
db=MockDbAdapter(),
verbose=False,
)
agent._init_session_store()
# Session store should not be used when DB is provided
assert agent._session_store is None
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.5.2"
version = "0.6.2"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+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.9.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.8.9.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.1.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.1.2.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()
+442
View File
@@ -0,0 +1,442 @@
"""
Safe Edit Pipeline for ACP.
Implements a propose → diff → approve → apply workflow for file edits.
Ensures no silent file writes - all changes require explicit approval.
"""
import difflib
import logging
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
class EditStatus(str, Enum):
"""Status of an edit proposal."""
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
APPLIED = "applied"
FAILED = "failed"
@dataclass
class EditProposal:
"""
A proposed file edit with diff preview.
Represents a change that has been proposed but not yet applied.
"""
proposal_id: str
file_path: Path
original_content: str
proposed_content: str
description: str
diff_lines: List[str] = field(default_factory=list)
status: EditStatus = EditStatus.PENDING
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
approved_at: Optional[str] = None
applied_at: Optional[str] = None
error: Optional[str] = None
def __post_init__(self):
"""Generate diff on creation."""
if not self.diff_lines:
self.diff_lines = self._generate_diff()
def _generate_diff(self) -> List[str]:
"""Generate unified diff between original and proposed content."""
original_lines = self.original_content.splitlines(keepends=True)
proposed_lines = self.proposed_content.splitlines(keepends=True)
diff = difflib.unified_diff(
original_lines,
proposed_lines,
fromfile=f"a/{self.file_path.name}",
tofile=f"b/{self.file_path.name}",
lineterm=""
)
return list(diff)
@property
def diff_text(self) -> str:
"""Get diff as a single string."""
return "\n".join(self.diff_lines)
@property
def is_new_file(self) -> bool:
"""Check if this is a new file creation."""
return not self.original_content
@property
def is_deletion(self) -> bool:
"""Check if this is a file deletion."""
return not self.proposed_content and bool(self.original_content)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"proposal_id": self.proposal_id,
"file_path": str(self.file_path),
"description": self.description,
"status": self.status.value,
"is_new_file": self.is_new_file,
"is_deletion": self.is_deletion,
"diff": self.diff_text,
"created_at": self.created_at,
"approved_at": self.approved_at,
"applied_at": self.applied_at,
"error": self.error,
}
class SafeEditPipeline:
"""
Safe file editing with diff preview and approval workflow.
Workflow:
1. propose_edit() - Create an edit proposal with diff
2. show_diff() - Display the diff to user
3. approve() / reject() - User decision
4. apply_edit() - Apply approved edit
ACP remains read-only by default. Writes only happen after explicit approval.
"""
def __init__(
self,
workspace: Optional[Path] = None,
auto_approve: bool = False,
approval_callback: Optional[callable] = None,
):
"""
Initialize safe edit pipeline.
Args:
workspace: Root workspace directory for path validation
auto_approve: If True, auto-approve all edits (dangerous, for testing only)
approval_callback: Optional callback for approval (async or sync)
"""
self.workspace = Path(workspace) if workspace else Path.cwd()
self.auto_approve = auto_approve
self.approval_callback = approval_callback
self._proposals: Dict[str, EditProposal] = {}
def _validate_path(self, file_path: Path) -> Path:
"""
Validate and resolve file path.
Ensures path is within workspace and resolves to absolute path.
"""
if not file_path.is_absolute():
file_path = self.workspace / file_path
resolved = file_path.resolve()
# Security check: ensure path is within workspace
try:
resolved.relative_to(self.workspace.resolve())
except ValueError:
raise ValueError(f"Path {file_path} is outside workspace {self.workspace}")
return resolved
def propose_edit(
self,
file_path: Path,
new_content: str,
description: str,
) -> EditProposal:
"""
Create an edit proposal with diff preview.
Args:
file_path: Path to file to edit
new_content: Proposed new content
description: Human-readable description of the change
Returns:
EditProposal with diff
"""
resolved_path = self._validate_path(Path(file_path))
# Read original content if file exists
original_content = ""
if resolved_path.exists():
try:
original_content = resolved_path.read_text()
except Exception as e:
logger.warning(f"Could not read {resolved_path}: {e}")
# Create proposal
proposal = EditProposal(
proposal_id=str(uuid.uuid4())[:8],
file_path=resolved_path,
original_content=original_content,
proposed_content=new_content,
description=description,
)
self._proposals[proposal.proposal_id] = proposal
logger.info(f"Created edit proposal {proposal.proposal_id} for {file_path}")
return proposal
def propose_create(
self,
file_path: Path,
content: str,
description: str,
) -> EditProposal:
"""
Create a proposal for a new file.
Args:
file_path: Path for new file
content: File content
description: Description of the new file
Returns:
EditProposal for file creation
"""
return self.propose_edit(file_path, content, description)
def propose_delete(
self,
file_path: Path,
description: str,
) -> EditProposal:
"""
Create a proposal to delete a file.
Args:
file_path: Path to file to delete
description: Reason for deletion
Returns:
EditProposal for file deletion
"""
return self.propose_edit(file_path, "", description)
def get_proposal(self, proposal_id: str) -> Optional[EditProposal]:
"""Get a proposal by ID."""
return self._proposals.get(proposal_id)
def list_proposals(self, status: Optional[EditStatus] = None) -> List[EditProposal]:
"""
List all proposals, optionally filtered by status.
Args:
status: Optional status filter
Returns:
List of proposals
"""
proposals = list(self._proposals.values())
if status:
proposals = [p for p in proposals if p.status == status]
return proposals
def approve(self, proposal_id: str) -> bool:
"""
Approve an edit proposal.
Args:
proposal_id: ID of proposal to approve
Returns:
True if approved, False if not found or already processed
"""
proposal = self._proposals.get(proposal_id)
if not proposal:
logger.warning(f"Proposal {proposal_id} not found")
return False
if proposal.status != EditStatus.PENDING:
logger.warning(f"Proposal {proposal_id} is not pending (status: {proposal.status})")
return False
proposal.status = EditStatus.APPROVED
proposal.approved_at = datetime.now().isoformat()
logger.info(f"Approved proposal {proposal_id}")
return True
def reject(self, proposal_id: str, reason: Optional[str] = None) -> bool:
"""
Reject an edit proposal.
Args:
proposal_id: ID of proposal to reject
reason: Optional rejection reason
Returns:
True if rejected, False if not found or already processed
"""
proposal = self._proposals.get(proposal_id)
if not proposal:
logger.warning(f"Proposal {proposal_id} not found")
return False
if proposal.status != EditStatus.PENDING:
logger.warning(f"Proposal {proposal_id} is not pending (status: {proposal.status})")
return False
proposal.status = EditStatus.REJECTED
proposal.error = reason
logger.info(f"Rejected proposal {proposal_id}: {reason}")
return True
def apply_edit(self, proposal_id: str) -> bool:
"""
Apply an approved edit.
Args:
proposal_id: ID of approved proposal to apply
Returns:
True if applied successfully, False otherwise
"""
proposal = self._proposals.get(proposal_id)
if not proposal:
logger.error(f"Proposal {proposal_id} not found")
return False
if proposal.status != EditStatus.APPROVED:
logger.error(f"Proposal {proposal_id} is not approved (status: {proposal.status})")
return False
try:
if proposal.is_deletion:
# Delete file
if proposal.file_path.exists():
proposal.file_path.unlink()
logger.info(f"Deleted {proposal.file_path}")
else:
# Create parent directories if needed
proposal.file_path.parent.mkdir(parents=True, exist_ok=True)
# Write new content
proposal.file_path.write_text(proposal.proposed_content)
logger.info(f"Applied edit to {proposal.file_path}")
proposal.status = EditStatus.APPLIED
proposal.applied_at = datetime.now().isoformat()
return True
except Exception as e:
logger.error(f"Failed to apply proposal {proposal_id}: {e}")
proposal.status = EditStatus.FAILED
proposal.error = str(e)
return False
def propose_and_apply(
self,
file_path: Path,
new_content: str,
description: str,
) -> EditProposal:
"""
Convenience method: propose, auto-approve if enabled, and apply.
Only applies if auto_approve is True or approval_callback returns True.
Args:
file_path: Path to file
new_content: New content
description: Description
Returns:
EditProposal (may be pending, approved, or applied)
"""
proposal = self.propose_edit(file_path, new_content, description)
if self.auto_approve:
self.approve(proposal.proposal_id)
self.apply_edit(proposal.proposal_id)
elif self.approval_callback:
try:
if self.approval_callback(proposal):
self.approve(proposal.proposal_id)
self.apply_edit(proposal.proposal_id)
else:
self.reject(proposal.proposal_id, "Rejected by callback")
except Exception as e:
logger.error(f"Approval callback failed: {e}")
proposal.error = str(e)
return proposal
def format_diff_for_display(self, proposal: EditProposal) -> str:
"""
Format diff for terminal display with colors.
Args:
proposal: Edit proposal
Returns:
Formatted diff string with ANSI colors
"""
lines = []
lines.append(f"📝 Edit Proposal: {proposal.proposal_id}")
lines.append(f" File: {proposal.file_path}")
lines.append(f" Description: {proposal.description}")
lines.append(f" Status: {proposal.status.value}")
lines.append("")
if proposal.is_new_file:
lines.append(" [NEW FILE]")
elif proposal.is_deletion:
lines.append(" [DELETE FILE]")
lines.append(" --- Diff ---")
for line in proposal.diff_lines:
if line.startswith("+") and not line.startswith("+++"):
lines.append(f" \033[32m{line}\033[0m") # Green for additions
elif line.startswith("-") and not line.startswith("---"):
lines.append(f" \033[31m{line}\033[0m") # Red for deletions
elif line.startswith("@@"):
lines.append(f" \033[36m{line}\033[0m") # Cyan for line numbers
else:
lines.append(f" {line}")
return "\n".join(lines)
def clear_proposals(self, status: Optional[EditStatus] = None) -> int:
"""
Clear proposals, optionally filtered by status.
Args:
status: Optional status filter (None = clear all)
Returns:
Number of proposals cleared
"""
if status is None:
count = len(self._proposals)
self._proposals.clear()
else:
to_remove = [pid for pid, p in self._proposals.items() if p.status == status]
for pid in to_remove:
del self._proposals[pid]
count = len(to_remove)
logger.info(f"Cleared {count} proposals")
return count
# Global pipeline instance for ACP
_safe_edit_pipeline: Optional[SafeEditPipeline] = None
def get_safe_edit_pipeline(workspace: Optional[Path] = None) -> SafeEditPipeline:
"""Get or create the global safe edit pipeline."""
global _safe_edit_pipeline
if _safe_edit_pipeline is None:
_safe_edit_pipeline = SafeEditPipeline(workspace=workspace)
return _safe_edit_pipeline
+271
View File
@@ -0,0 +1,271 @@
"""
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 - Core commands
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
from .commands.profile import app as profile_app
from .commands.benchmark import app as benchmark_app
# Import new command modules - Previously legacy-only commands
from .commands.chat import app as chat_app
from .commands.code import app as code_app
from .commands.call import app as call_app
from .commands.realtime import app as realtime_app
from .commands.train import app as train_app
from .commands.ui import app as ui_app
from .commands.context import app as context_app
from .commands.research import app as research_app
from .commands.memory import app as memory_app
from .commands.workflow import app as workflow_app
from .commands.tools import app as tools_app
from .commands.knowledge import app as knowledge_app
from .commands.deploy import app as deploy_app
from .commands.agents import app as agents_app
from .commands.skills import app as skills_app
from .commands.eval import app as eval_app
from .commands.templates import app as templates_app
from .commands.recipe import app as recipe_app
from .commands.todo import app as todo_app
from .commands.docs import app as docs_app
from .commands.commit import app as commit_app
from .commands.hooks import app as hooks_app
from .commands.rules import app as rules_app
from .commands.registry import app as registry_app
from .commands.package import app as package_app
from .commands.endpoints import app as endpoints_app
# Import TUI and queue commands
from .features.tui.debug import create_debug_app as create_tui_debug_app
from .features.tui.cli import create_queue_app
# Register sub-apps - Core commands
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")
app.add_typer(profile_app, name="profile", help="Performance profiling and diagnostics")
app.add_typer(benchmark_app, name="benchmark", help="Comprehensive performance benchmarking")
# Register sub-apps - Previously legacy-only commands (now visible in --help)
app.add_typer(chat_app, name="chat", help="Interactive chat mode")
app.add_typer(code_app, name="code", help="Code assistant mode")
app.add_typer(call_app, name="call", help="Voice/call interaction mode")
app.add_typer(realtime_app, name="realtime", help="Realtime interaction mode")
app.add_typer(train_app, name="train", help="Model training and fine-tuning")
app.add_typer(ui_app, name="ui", help="Web UI management")
app.add_typer(context_app, name="context", help="Context management")
app.add_typer(research_app, name="research", help="Research and analysis")
app.add_typer(memory_app, name="memory", help="Memory management")
app.add_typer(workflow_app, name="workflow", help="Workflow management")
app.add_typer(tools_app, name="tools", help="Tool management")
app.add_typer(knowledge_app, name="knowledge", help="Knowledge base management")
app.add_typer(deploy_app, name="deploy", help="Deployment management")
app.add_typer(agents_app, name="agents", help="Agent management")
app.add_typer(skills_app, name="skills", help="Skill management")
app.add_typer(eval_app, name="eval", help="Evaluation and testing")
app.add_typer(templates_app, name="templates", help="Template management")
app.add_typer(recipe_app, name="recipe", help="Recipe management")
app.add_typer(todo_app, name="todo", help="Todo/task management")
app.add_typer(docs_app, name="docs", help="Documentation management")
app.add_typer(commit_app, name="commit", help="AI-assisted git commits")
app.add_typer(hooks_app, name="hooks", help="Hook management")
app.add_typer(rules_app, name="rules", help="Rules management")
app.add_typer(registry_app, name="registry", help="Registry management")
app.add_typer(package_app, name="package", help="Package management")
app.add_typer(endpoints_app, name="endpoints", help="API endpoint management")
# Register TUI and queue commands
tui_app = create_tui_debug_app()
queue_app = create_queue_app()
if tui_app:
app.add_typer(tui_app, name="tui", help="Interactive TUI and simulation")
if queue_app:
app.add_typer(queue_app, name="queue", help="Queue management")
# Register commands on import
register_commands()
@@ -0,0 +1,75 @@
"""
PraisonAI CLI Commands Module.
This module contains all Typer command groups and subcommands.
Each file represents a command group (e.g., config, traces, session).
"""
# Lazy imports to avoid loading all commands at startup
__all__ = [
'run_app',
'config_app',
'traces_app',
'env_app',
'session_app',
'schedule_app',
'serve_app',
'completion_app',
'version_app',
'debug_app',
'lsp_app',
'diag_app',
'doctor_app',
'acp_app',
'mcp_app',
]
def __getattr__(name: str):
"""Lazy load command apps."""
if name == 'run_app':
from .run import app as run_app
return run_app
elif name == 'config_app':
from .config import app as config_app
return config_app
elif name == 'traces_app':
from .traces import app as traces_app
return traces_app
elif name == 'env_app':
from .environment import app as env_app
return env_app
elif name == 'session_app':
from .session import app as session_app
return session_app
elif name == 'schedule_app':
from .schedule import app as schedule_app
return schedule_app
elif name == 'serve_app':
from .serve import app as serve_app
return serve_app
elif name == 'completion_app':
from .completion import app as completion_app
return completion_app
elif name == 'version_app':
from .version import app as version_app
return version_app
elif name == 'debug_app':
from .debug import app as debug_app
return debug_app
elif name == 'lsp_app':
from .lsp import app as lsp_app
return lsp_app
elif name == 'diag_app':
from .diag import app as diag_app
return diag_app
elif name == 'doctor_app':
from .doctor import app as doctor_app
return doctor_app
elif name == 'acp_app':
from .acp import app as acp_app
return acp_app
elif name == 'mcp_app':
from .mcp import app as mcp_app
return mcp_app
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,70 @@
"""
ACP command group for PraisonAI CLI.
Wraps existing ACP functionality from features/acp.py.
Provides Agent Client Protocol server for IDE integration.
"""
from typing import Optional
import typer
from ..output.console import get_output_controller
app = typer.Typer(help="Agent Client Protocol server")
@app.callback(invoke_without_command=True)
def acp_main(
ctx: typer.Context,
workspace: str = typer.Option(".", "--workspace", "-w", help="Workspace root directory"),
agent: str = typer.Option("default", "--agent", "-a", help="Agent name or configuration file"),
agents_file: Optional[str] = typer.Option(None, "--agents", help="Multi-agent configuration YAML file"),
router: bool = typer.Option(False, "--router", help="Enable router agent for task delegation"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
resume: Optional[str] = typer.Option(None, "--resume", "-r", help="Resume session by ID"),
last: bool = typer.Option(False, "--last", help="Resume the last session"),
approve: str = typer.Option("manual", "--approve", help="Approval mode: manual, auto, scoped"),
read_only: bool = typer.Option(True, "--read-only/--allow-write", help="Read-only mode"),
allow_shell: bool = typer.Option(False, "--allow-shell", help="Allow shell command execution"),
allow_network: bool = typer.Option(False, "--allow-network", help="Allow network requests"),
debug: bool = typer.Option(False, "--debug", help="Enable debug logging"),
profile: Optional[str] = typer.Option(None, "--profile", help="Use named profile from config"),
):
"""Start ACP server for IDE integration."""
# Build args for existing handler
args = [
"--workspace", workspace,
"--agent", agent,
"--approve", approve,
]
if agents_file:
args.extend(["--agents", agents_file])
if router:
args.append("--router")
if model:
args.extend(["--model", model])
if resume:
args.extend(["--resume", resume])
if last:
args.append("--last")
if not read_only:
args.append("--allow-write")
if allow_shell:
args.append("--allow-shell")
if allow_network:
args.append("--allow-network")
if debug:
args.append("--debug")
if profile:
args.extend(["--profile", profile])
try:
from ..features.acp import run_acp_command
exit_code = run_acp_command(args)
raise typer.Exit(exit_code)
except ImportError as e:
output = get_output_controller()
output.print_error(f"ACP module not available: {e}")
raise typer.Exit(4)
@@ -0,0 +1,80 @@
"""
Agents command group for PraisonAI CLI.
Provides agent management commands.
"""
import typer
app = typer.Typer(help="Agent management")
@app.command("list")
def agents_list(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed info"),
):
"""List available agents."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['agents', 'list']
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("create")
def agents_create(
name: str = typer.Argument(..., help="Agent name"),
template: str = typer.Option(None, "--template", "-t", help="Template to use"),
):
"""Create a new agent."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['agents', 'create', name]
if template:
argv.extend(['--template', template])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("info")
def agents_info(
name: str = typer.Argument(..., help="Agent name"),
):
"""Show agent information."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['agents', 'info', name]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,286 @@
"""
Benchmark command group for PraisonAI CLI.
Provides comprehensive performance benchmarking across all execution paths.
"""
from typing import Optional
import typer
app = typer.Typer(help="Comprehensive performance benchmarking")
@app.command("profile")
def benchmark_profile(
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations per path"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
output_file: Optional[str] = typer.Option(None, "--output", "-o", help="Save results to file"),
deep: bool = typer.Option(False, "--deep", help="Enable deep cProfile profiling (per-function timing, call graphs)"),
limit: int = typer.Option(30, "--limit", "-l", help="Top N functions to show in deep profile"),
):
"""
Run full benchmark suite across all execution paths.
Benchmarks:
- OpenAI SDK (baseline)
- PraisonAI Agent
- PraisonAI CLI
- PraisonAI CLI with profiling
- PraisonAI Workflow (single agent)
- PraisonAI Workflow (multi-agent)
- PraisonAI via LiteLLM
- LiteLLM standalone
With --deep flag, includes:
- Per-function timing (cProfile stats)
- Self time vs cumulative time
- Call counts per function
- Caller/callee relationships
- Module breakdown by category
- Call graph data
Examples:
praisonai benchmark profile "What is 2+2?"
praisonai benchmark profile "Hi" --iterations 5
praisonai benchmark profile "Hi" --deep --limit 50
praisonai benchmark profile "Hi" --deep --format json --output results.json
"""
try:
from ..features.benchmark import BenchmarkHandler
except ImportError as e:
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
raise typer.Exit(1)
if deep:
typer.echo("⚠️ Deep profiling enabled - this adds overhead to measurements", err=True)
handler = BenchmarkHandler()
report = handler.run_full_benchmark(
prompt=prompt,
iterations=iterations,
verbose=True,
deep=deep,
limit=limit
)
if output_format == "json":
import json
output = json.dumps(report.to_dict(), indent=2)
if output_file:
with open(output_file, "w") as f:
f.write(output)
typer.echo(f"Results saved to {output_file}")
else:
typer.echo(output)
else:
handler.print_report(report, deep=deep, limit=limit)
if output_file:
import json
with open(output_file, "w") as f:
f.write(json.dumps(report.to_dict(), indent=2))
typer.echo(f"\nJSON results saved to {output_file}")
@app.command("compare")
def benchmark_compare(
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
iterations: int = typer.Option(2, "--iterations", "-n", help="Number of iterations"),
):
"""
Quick comparison of key execution paths.
Compares OpenAI SDK, PraisonAI Agent, PraisonAI CLI, and LiteLLM.
Examples:
praisonai benchmark compare "Hi"
praisonai benchmark compare "What is 2+2?" --iterations 3
"""
try:
from ..features.benchmark import BenchmarkHandler
except ImportError as e:
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
raise typer.Exit(1)
handler = BenchmarkHandler()
report = handler.run_full_benchmark(
prompt=prompt,
iterations=iterations,
paths=["openai_sdk", "praisonai_agent", "praisonai_cli", "litellm_standalone"],
verbose=True
)
typer.echo("\n" + handler.create_comparison_table(report))
@app.command("sdk")
def benchmark_sdk(
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Benchmark OpenAI SDK only (baseline).
Examples:
praisonai benchmark sdk "Hi"
praisonai benchmark sdk "What is 2+2?" --iterations 5
"""
try:
from ..features.benchmark import BenchmarkHandler
except ImportError as e:
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
raise typer.Exit(1)
handler = BenchmarkHandler()
report = handler.run_full_benchmark(prompt=prompt, iterations=iterations, paths=["openai_sdk"], verbose=True)
if output_format == "json":
import json
typer.echo(json.dumps(report.to_dict(), indent=2))
else:
handler.print_report(report)
@app.command("agent")
def benchmark_agent(
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Benchmark PraisonAI Agent vs SDK baseline.
Examples:
praisonai benchmark agent "Hi"
praisonai benchmark agent "What is 2+2?" --iterations 5
"""
try:
from ..features.benchmark import BenchmarkHandler
except ImportError as e:
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
raise typer.Exit(1)
handler = BenchmarkHandler()
report = handler.run_full_benchmark(
prompt=prompt,
iterations=iterations,
paths=["openai_sdk", "praisonai_agent"],
verbose=True
)
if output_format == "json":
import json
typer.echo(json.dumps(report.to_dict(), indent=2))
else:
handler.print_report(report)
@app.command("cli")
def benchmark_cli(
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Benchmark PraisonAI CLI vs SDK baseline.
Examples:
praisonai benchmark cli "Hi"
praisonai benchmark cli "What is 2+2?" --iterations 5
"""
try:
from ..features.benchmark import BenchmarkHandler
except ImportError as e:
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
raise typer.Exit(1)
handler = BenchmarkHandler()
report = handler.run_full_benchmark(
prompt=prompt,
iterations=iterations,
paths=["openai_sdk", "praisonai_cli", "praisonai_cli_profile"],
verbose=True
)
if output_format == "json":
import json
typer.echo(json.dumps(report.to_dict(), indent=2))
else:
handler.print_report(report)
@app.command("workflow")
def benchmark_workflow(
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Benchmark PraisonAI Workflow (single and multi-agent) vs SDK baseline.
Examples:
praisonai benchmark workflow "Hi"
praisonai benchmark workflow "What is 2+2?" --iterations 5
"""
try:
from ..features.benchmark import BenchmarkHandler
except ImportError as e:
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
raise typer.Exit(1)
handler = BenchmarkHandler()
report = handler.run_full_benchmark(
prompt=prompt,
iterations=iterations,
paths=["openai_sdk", "praisonai_workflow_single", "praisonai_workflow_multi"],
verbose=True
)
if output_format == "json":
import json
typer.echo(json.dumps(report.to_dict(), indent=2))
else:
handler.print_report(report)
@app.command("litellm")
def benchmark_litellm(
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Benchmark LiteLLM paths vs SDK baseline.
Examples:
praisonai benchmark litellm "Hi"
praisonai benchmark litellm "What is 2+2?" --iterations 5
"""
try:
from ..features.benchmark import BenchmarkHandler
except ImportError as e:
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
raise typer.Exit(1)
handler = BenchmarkHandler()
report = handler.run_full_benchmark(
prompt=prompt,
iterations=iterations,
paths=["openai_sdk", "praisonai_litellm", "litellm_standalone"],
verbose=True
)
if output_format == "json":
import json
typer.echo(json.dumps(report.to_dict(), indent=2))
else:
handler.print_report(report)
@app.callback(invoke_without_command=True)
def benchmark_callback(ctx: typer.Context):
"""Show benchmark help if no subcommand."""
if ctx.invoked_subcommand is None:
typer.echo(ctx.get_help())
@@ -0,0 +1,45 @@
"""
Call command group for PraisonAI CLI.
Provides voice/call interaction commands.
"""
from typing import Optional
import typer
app = typer.Typer(help="Voice/call interaction mode")
@app.callback(invoke_without_command=True)
def call_main(
ctx: typer.Context,
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""
Start voice/call interaction mode.
Examples:
praisonai call
praisonai call --model gpt-4o
"""
from praisonai.cli.main import PraisonAI
import sys
argv = ['call']
if model:
argv.extend(['--model', model])
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,60 @@
"""
Chat command group for PraisonAI CLI.
Provides interactive chat commands.
"""
from typing import Optional
import typer
app = typer.Typer(help="Interactive chat mode")
@app.callback(invoke_without_command=True)
def chat_main(
ctx: typer.Context,
prompt: Optional[str] = typer.Argument(None, help="Initial prompt for chat"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Tools file path"),
user_id: Optional[str] = typer.Option(None, "--user-id", help="User ID for memory isolation"),
session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID to resume"),
):
"""
Start interactive chat mode.
Examples:
praisonai chat
praisonai chat "Hello, how are you?"
praisonai chat --model gpt-4o --memory
"""
from praisonai.cli.main import PraisonAI
import sys
# Build args for legacy handler
argv = ['chat']
if prompt:
argv.append(prompt)
if model:
argv.extend(['--model', model])
if verbose:
argv.append('--verbose')
if memory:
argv.append('--memory')
if tools:
argv.extend(['--tools', tools])
if user_id:
argv.extend(['--user-id', user_id])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,53 @@
"""
Code command group for PraisonAI CLI.
Provides code assistant commands.
"""
from typing import Optional
import typer
app = typer.Typer(help="Code assistant mode")
@app.callback(invoke_without_command=True)
def code_main(
ctx: typer.Context,
prompt: Optional[str] = typer.Argument(None, help="Code task or question"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Tools file path"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
):
"""
Start code assistant mode.
Examples:
praisonai code
praisonai code "Refactor this function"
praisonai code --model gpt-4o --workspace ./src
"""
from praisonai.cli.main import PraisonAI
import sys
argv = ['code']
if prompt:
argv.append(prompt)
if model:
argv.extend(['--model', model])
if verbose:
argv.append('--verbose')
if tools:
argv.extend(['--tools', tools])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,47 @@
"""
Commit command group for PraisonAI CLI.
Provides git commit commands with AI assistance.
"""
import typer
app = typer.Typer(help="AI-assisted git commits")
@app.callback(invoke_without_command=True)
def commit_main(
ctx: typer.Context,
message: str = typer.Option(None, "--message", "-m", help="Commit message (auto-generated if not provided)"),
all_files: bool = typer.Option(False, "--all", "-a", help="Stage all changes"),
push: bool = typer.Option(False, "--push", "-p", help="Push after commit"),
):
"""
Create AI-assisted git commits.
Examples:
praisonai commit
praisonai commit -m "Fix bug"
praisonai commit --all --push
"""
from praisonai.cli.main import PraisonAI
import sys
argv = ['commit']
if message:
argv.extend(['--message', message])
if all_files:
argv.append('--all')
if push:
argv.append('--push')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -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,77 @@
"""
Context command group for PraisonAI CLI.
Provides context management commands.
"""
from typing import Optional
import typer
app = typer.Typer(help="Context management")
@app.command("show")
def context_show(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Show current context."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['context', 'show']
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("clear")
def context_clear():
"""Clear current context."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['context', 'clear']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("add")
def context_add(
content: str = typer.Argument(..., help="Content to add to context"),
):
"""Add content to context."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['context', 'add', content]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -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,109 @@
"""
Deploy command group for PraisonAI CLI.
Provides deployment commands.
"""
import typer
app = typer.Typer(help="Deployment management")
@app.command("docker")
def deploy_docker(
file: str = typer.Argument("agents.yaml", help="Agent file to deploy"),
tag: str = typer.Option(None, "--tag", "-t", help="Docker image tag"),
):
"""Deploy as Docker container."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['deploy', 'docker', file]
if tag:
argv.extend(['--tag', tag])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("aws")
def deploy_aws(
file: str = typer.Argument("agents.yaml", help="Agent file to deploy"),
region: str = typer.Option(None, "--region", "-r", help="AWS region"),
):
"""Deploy to AWS."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['deploy', 'aws', file]
if region:
argv.extend(['--region', region])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("gcp")
def deploy_gcp(
file: str = typer.Argument("agents.yaml", help="Agent file to deploy"),
project: str = typer.Option(None, "--project", "-p", help="GCP project"),
):
"""Deploy to Google Cloud."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['deploy', 'gcp', file]
if project:
argv.extend(['--project', project])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("azure")
def deploy_azure(
file: str = typer.Argument("agents.yaml", help="Agent file to deploy"),
resource_group: str = typer.Option(None, "--resource-group", "-g", help="Azure resource group"),
):
"""Deploy to Azure."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['deploy', 'azure', file]
if resource_group:
argv.extend(['--resource-group', resource_group])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -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,54 @@
"""
Docs command group for PraisonAI CLI.
Provides documentation commands.
"""
import typer
app = typer.Typer(help="Documentation management")
@app.command("generate")
def docs_generate(
source: str = typer.Argument(".", help="Source directory"),
output: str = typer.Option("docs", "--output", "-o", help="Output directory"),
):
"""Generate documentation."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['docs', 'generate', source, '--output', output]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("serve")
def docs_serve(
port: int = typer.Option(8000, "--port", "-p", help="Port to serve on"),
):
"""Serve documentation locally."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['docs', 'serve', '--port', str(port)]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -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,51 @@
"""
Endpoints command group for PraisonAI CLI.
Provides API endpoint management commands.
"""
import typer
app = typer.Typer(help="API endpoint management")
@app.command("list")
def endpoints_list():
"""List available endpoints."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['endpoints', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("test")
def endpoints_test(
endpoint: str = typer.Argument(..., help="Endpoint to test"),
):
"""Test an endpoint."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['endpoints', 'test', endpoint]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,179 @@
"""
Environment command group for PraisonAI CLI.
Provides environment management and diagnostics:
- env view: Show environment variables
- env check: Validate API keys
- env doctor: Run diagnostics (alias for doctor command)
"""
import os
from typing import Optional
import typer
from ..output.console import get_output_controller
app = typer.Typer(help="Environment and diagnostics")
# Known API key patterns
API_KEY_PATTERNS = {
"OPENAI_API_KEY": {"prefix": "sk-", "min_length": 20},
"ANTHROPIC_API_KEY": {"prefix": "sk-ant-", "min_length": 20},
"GOOGLE_API_KEY": {"prefix": "AI", "min_length": 20},
"GEMINI_API_KEY": {"prefix": "AI", "min_length": 20},
"TAVILY_API_KEY": {"prefix": "tvly-", "min_length": 20},
"GROQ_API_KEY": {"prefix": "gsk_", "min_length": 20},
}
def _redact_key(value: str, show_chars: int = 4) -> str:
"""Redact an API key for display."""
if len(value) <= show_chars * 2:
return "*" * len(value)
return value[:show_chars] + "*" * (len(value) - show_chars * 2) + value[-show_chars:]
def _validate_key(name: str, value: str) -> tuple[bool, str]:
"""Validate an API key format."""
if name not in API_KEY_PATTERNS:
return True, "Unknown key type"
pattern = API_KEY_PATTERNS[name]
if len(value) < pattern["min_length"]:
return False, f"Too short (expected >= {pattern['min_length']} chars)"
if pattern.get("prefix") and not value.startswith(pattern["prefix"]):
return False, f"Invalid prefix (expected {pattern['prefix']}...)"
return True, "Valid format"
@app.command("view")
def env_view(
show_values: bool = typer.Option(
False,
"--show-values",
help="Show actual values (redacted by default)",
),
filter_prefix: Optional[str] = typer.Option(
None,
"--filter",
"-f",
help="Filter by prefix (e.g., OPENAI, PRAISONAI)",
),
):
"""Show relevant environment variables."""
output = get_output_controller()
# Relevant prefixes
prefixes = ["OPENAI", "ANTHROPIC", "GOOGLE", "GEMINI", "TAVILY", "GROQ",
"PRAISONAI", "MODEL", "LLM", "OLLAMA", "AZURE"]
if filter_prefix:
prefixes = [filter_prefix.upper()]
env_vars = {}
for key, value in os.environ.items():
for prefix in prefixes:
if key.upper().startswith(prefix):
if show_values:
if "KEY" in key.upper() or "SECRET" in key.upper() or "TOKEN" in key.upper():
env_vars[key] = _redact_key(value)
else:
env_vars[key] = value
else:
env_vars[key] = "***" if value else "(empty)"
break
if output.is_json_mode:
output.print_json({"environment": env_vars})
return
if not env_vars:
output.print_info("No relevant environment variables found")
return
headers = ["Variable", "Value"]
rows = [[k, v] for k, v in sorted(env_vars.items())]
output.print_table(headers, rows, title="Environment Variables")
@app.command("check")
def env_check():
"""Validate API keys and configuration."""
output = get_output_controller()
results = []
all_valid = True
for key_name, pattern in API_KEY_PATTERNS.items():
value = os.environ.get(key_name)
if value:
valid, message = _validate_key(key_name, value)
results.append({
"key": key_name,
"present": True,
"valid": valid,
"message": message,
})
if not valid:
all_valid = False
else:
results.append({
"key": key_name,
"present": False,
"valid": False,
"message": "Not set",
})
if output.is_json_mode:
output.print_json({"checks": results, "all_valid": all_valid})
return
headers = ["Key", "Status", "Message"]
rows = []
for r in results:
if r["present"]:
status = "✅" if r["valid"] else "⚠️"
else:
status = "❌"
rows.append([r["key"], status, r["message"]])
output.print_table(headers, rows, title="API Key Validation")
if not all_valid:
output.print_warning("Some API keys are missing or invalid")
raise typer.Exit(3) # Exit code 3 = missing config/env
@app.command("doctor")
def env_doctor(
deep: bool = typer.Option(
False,
"--deep",
help="Run deep checks (network, DB connections)",
),
):
"""Run environment diagnostics (alias for 'praisonai doctor env')."""
output = get_output_controller()
# Delegate to doctor command
try:
from ..features.doctor.handler import DoctorHandler
handler = DoctorHandler()
# Run env checks
if output.is_json_mode:
# Run and get JSON output
result = handler.handle(["env", "--json"] if not deep else ["env", "--json", "--deep"])
else:
result = handler.handle(["env"] if not deep else ["env", "--deep"])
raise typer.Exit(result)
except ImportError:
# Fallback to basic env check
env_check()
@@ -0,0 +1,57 @@
"""
Eval command group for PraisonAI CLI.
Provides evaluation commands.
"""
import typer
app = typer.Typer(help="Evaluation and testing")
@app.command("accuracy")
def eval_accuracy(
agent: str = typer.Argument(..., help="Agent file or name"),
input_text: str = typer.Option(..., "--input", "-i", help="Input text"),
expected: str = typer.Option(..., "--expected", "-e", help="Expected output"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
):
"""Run accuracy evaluation."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['eval', 'accuracy', agent, '--input', input_text, '--expected', expected]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("performance")
def eval_performance(
agent: str = typer.Argument(..., help="Agent file or name"),
iterations: int = typer.Option(50, "--iterations", "-n", help="Number of iterations"),
):
"""Run performance evaluation."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['eval', 'performance', agent, '--iterations', str(iterations)]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,74 @@
"""
Hooks command group for PraisonAI CLI.
Provides hook management commands.
"""
import typer
app = typer.Typer(help="Hook management")
@app.command("list")
def hooks_list():
"""List available hooks."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['hooks', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("add")
def hooks_add(
name: str = typer.Argument(..., help="Hook name"),
event: str = typer.Option(..., "--event", "-e", help="Event to hook into"),
):
"""Add a hook."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['hooks', 'add', name, '--event', event]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("remove")
def hooks_remove(
name: str = typer.Argument(..., help="Hook name to remove"),
):
"""Remove a hook."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['hooks', 'remove', name]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,76 @@
"""
Knowledge command group for PraisonAI CLI.
Provides knowledge base management commands.
"""
import typer
app = typer.Typer(help="Knowledge base management")
@app.command("add")
def knowledge_add(
source: str = typer.Argument(..., help="Source file or URL"),
name: str = typer.Option(None, "--name", "-n", help="Knowledge base name"),
):
"""Add knowledge from a source."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['knowledge', 'add', source]
if name:
argv.extend(['--name', name])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("list")
def knowledge_list():
"""List knowledge bases."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['knowledge', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("search")
def knowledge_search(
query: str = typer.Argument(..., help="Search query"),
):
"""Search knowledge base."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['knowledge', 'search', query]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
+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)
@@ -0,0 +1,109 @@
"""
Memory command group for PraisonAI CLI.
Provides memory management commands.
"""
import typer
app = typer.Typer(help="Memory management")
@app.command("show")
def memory_show(
user_id: str = typer.Option(None, "--user-id", help="User ID for memory isolation"),
limit: int = typer.Option(10, "--limit", "-l", help="Number of memories to show"),
):
"""Show stored memories."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['memory', 'show']
if user_id:
argv.extend(['--user-id', user_id])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("add")
def memory_add(
content: str = typer.Argument(..., help="Memory content to add"),
user_id: str = typer.Option(None, "--user-id", help="User ID for memory isolation"),
):
"""Add a memory."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['memory', 'add', content]
if user_id:
argv.extend(['--user-id', user_id])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("search")
def memory_search(
query: str = typer.Argument(..., help="Search query"),
user_id: str = typer.Option(None, "--user-id", help="User ID for memory isolation"),
):
"""Search memories."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['memory', 'search', query]
if user_id:
argv.extend(['--user-id', user_id])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("clear")
def memory_clear(
user_id: str = typer.Option(None, "--user-id", help="User ID for memory isolation"),
force: bool = typer.Option(False, "--force", "-f", help="Force clear without confirmation"),
):
"""Clear all memories."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['memory', 'clear']
if user_id:
argv.extend(['--user-id', user_id])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,76 @@
"""
Package command group for PraisonAI CLI.
Provides package management commands (pip-like).
"""
import typer
app = typer.Typer(help="Package management")
@app.command("install")
def package_install(
package: str = typer.Argument(..., help="Package to install"),
upgrade: bool = typer.Option(False, "--upgrade", "-U", help="Upgrade if already installed"),
):
"""Install a package."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['package', 'install', package]
if upgrade:
argv.append('--upgrade')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("uninstall")
def package_uninstall(
package: str = typer.Argument(..., help="Package to uninstall"),
):
"""Uninstall a package."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['package', 'uninstall', package]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("list")
def package_list():
"""List installed packages."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['package', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,466 @@
"""
Profile command group for PraisonAI CLI.
Provides detailed cProfile-based profiling for query execution.
"""
import sys
from typing import Optional
import typer
app = typer.Typer(help="Performance profiling and diagnostics")
@app.command("query")
def profile_query(
prompt: str = typer.Argument(..., help="Prompt to profile"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="Model to use"),
stream: bool = typer.Option(False, "--stream/--no-stream", help="Use streaming mode"),
deep: bool = typer.Option(False, "--deep", help="Enable deep call tracing (higher overhead)"),
limit: int = typer.Option(30, "--limit", "-n", help="Top N functions to show"),
sort: str = typer.Option("cumulative", "--sort", "-s", help="Sort by: cumulative or tottime"),
show_callers: bool = typer.Option(False, "--show-callers", help="Show caller functions"),
show_callees: bool = typer.Option(False, "--show-callees", help="Show callee functions"),
save: Optional[str] = typer.Option(None, "--save", help="Save artifacts to path (creates .prof, .txt, .json)"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Profile a query execution with detailed timing breakdown.
Uses the unified profiling architecture for consistent results across
CLI direct invocation (--profile) and this command.
Shows per-function timing, call graphs, and latency metrics.
Examples:
praisonai profile query "What is 2+2?"
praisonai profile query "Hello" --limit 20
praisonai profile query "Test" --deep --show-callers --show-callees
praisonai profile query "Test" --save ./profile_results
"""
# Use unified profiler for consistent results
try:
from ..execution import ExecutionRequest, Profiler, ProfilerConfig as UnifiedProfilerConfig
except ImportError as e:
typer.echo(f"Error: Unified profiler not available: {e}", err=True)
raise typer.Exit(1)
# Warn about deep tracing overhead
if deep:
typer.echo("⚠️ Deep call tracing enabled - this adds significant overhead", err=True)
# Create unified execution request
request = ExecutionRequest(
prompt=prompt,
agent_name="ProfiledAgent",
model=model,
stream=stream,
)
# Create unified profiler config
config = UnifiedProfilerConfig(
layer=2 if deep else 1,
limit=limit,
sort_by=sort,
show_callers=show_callers,
show_callees=show_callees,
output_format=output_format,
save_path=save,
)
# Run profiler
typer.echo("🔬 Starting profiled execution...", err=True)
try:
profiler = Profiler(config)
result, report = profiler.profile_sync(request, invocation_method="profile_command")
except ImportError as e:
typer.echo(f"Error: {e}", err=True)
typer.echo("Install praisonaiagents: pip install praisonaiagents", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error during profiling: {e}", err=True)
raise typer.Exit(1)
# Output results
if output_format == "json":
typer.echo(report.to_json())
else:
typer.echo(report.to_text())
# Save artifacts if requested
if save:
try:
import os
os.makedirs(save, exist_ok=True)
# Save JSON report
json_path = os.path.join(save, "profile_report.json")
with open(json_path, 'w') as f:
f.write(report.to_json())
# Save text report
txt_path = os.path.join(save, "profile_report.txt")
with open(txt_path, 'w') as f:
f.write(report.to_text())
typer.echo("\n✅ Artifacts saved:", err=True)
typer.echo(f" {json_path}", err=True)
typer.echo(f" {txt_path}", err=True)
except Exception as e:
typer.echo(f"Warning: Failed to save artifacts: {e}", err=True)
@app.command("imports")
def profile_imports():
"""
Profile module import times.
Shows which modules take longest to import, useful for optimizing startup time.
"""
import subprocess
import re
typer.echo("🔬 Profiling import times...", err=True)
try:
result = subprocess.run(
[sys.executable, "-X", "importtime", "-c", "import praisonaiagents"],
capture_output=True,
text=True,
timeout=60,
)
# Parse import times
import_times = []
for line in result.stderr.split('\n'):
if 'import time:' in line:
match = re.search(r'import time:\s+(\d+)\s+\|\s+(\d+)\s+\|\s+(.+)', line)
if match:
self_time = int(match.group(1))
cumulative = int(match.group(2))
module = match.group(3).strip()
import_times.append((module, self_time, cumulative))
# Sort by cumulative time
import_times.sort(key=lambda x: x[2], reverse=True)
# Display
typer.echo("\n" + "=" * 70)
typer.echo("Import Time Analysis")
typer.echo("=" * 70)
typer.echo(f"{'Module':<45} {'Self (μs)':>10} {'Cumul (μs)':>12}")
typer.echo("-" * 70)
for module, self_time, cumulative in import_times[:30]:
module_name = module
if len(module_name) > 43:
module_name = "..." + module_name[-40:]
typer.echo(f"{module_name:<45} {self_time:>10} {cumulative:>12}")
typer.echo("-" * 70)
if import_times:
total_time = max(t[2] for t in import_times)
typer.echo(f"Total import time: {total_time / 1000:.2f} ms")
except subprocess.TimeoutExpired:
typer.echo("Error: Import profiling timed out", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command("startup")
def profile_startup():
"""
Profile CLI startup time.
Measures time from CLI invocation to ready state.
"""
import time
import subprocess
typer.echo("🔬 Profiling startup time...", err=True)
# Measure cold start (new process)
start = time.perf_counter()
subprocess.run(
[sys.executable, "-c", "import praisonai; import praisonai.cli"],
capture_output=True,
text=True,
)
cold_start = (time.perf_counter() - start) * 1000
# Measure warm start (imports cached)
start = time.perf_counter()
subprocess.run(
[sys.executable, "-c", "import praisonai; import praisonai.cli"],
capture_output=True,
text=True,
)
warm_start = (time.perf_counter() - start) * 1000
typer.echo("\n" + "=" * 50)
typer.echo("Startup Time Analysis")
typer.echo("=" * 50)
typer.echo(f"Cold Start: {cold_start:>10.2f} ms")
typer.echo(f"Warm Start: {warm_start:>10.2f} ms")
typer.echo("=" * 50)
if cold_start > 1000:
typer.echo("\n⚠️ Cold start > 1s - consider lazy imports", err=True)
@app.command("suite")
def profile_suite(
output_dir: Optional[str] = typer.Option("/tmp/praisonai_profile_suite", "--output", "-o", help="Output directory for results"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Iterations per scenario"),
quick: bool = typer.Option(False, "--quick", help="Quick mode (fewer iterations)"),
):
"""
Run a comprehensive profiling suite.
Runs multiple scenarios (streaming/non-streaming, simple/complex prompts)
and produces aggregated statistics and reports.
Examples:
praisonai profile suite
praisonai profile suite --output ./my_profile_results
praisonai profile suite --quick
"""
try:
from ..features.profiler import run_profile_suite, ScenarioConfig
except ImportError as e:
typer.echo(f"Error: Profiler module not available: {e}", err=True)
raise typer.Exit(1)
# Define scenarios
iters = 1 if quick else iterations
scenarios = [
ScenarioConfig(name="simple_non_stream", prompt="hi", stream=False, iterations=iters, warmup=0 if quick else 1),
ScenarioConfig(name="simple_stream", prompt="hi", stream=True, iterations=iters, warmup=0 if quick else 1),
]
if not quick:
scenarios.extend([
ScenarioConfig(name="medium_non_stream", prompt="Explain Python in 2 sentences", stream=False, iterations=max(1, iters-1)),
ScenarioConfig(name="medium_stream", prompt="Explain Python in 2 sentences", stream=True, iterations=max(1, iters-1)),
])
typer.echo("🔬 Running Profile Suite...", err=True)
typer.echo(f" Output: {output_dir}", err=True)
typer.echo(f" Scenarios: {len(scenarios)}", err=True)
typer.echo(f" Iterations: {iterations}", err=True)
try:
result = run_profile_suite(
output_dir=output_dir,
scenarios=scenarios,
verbose=True,
)
# Print summary
typer.echo("\n" + "=" * 60)
typer.echo("Profile Suite Summary")
typer.echo("=" * 60)
typer.echo(f"Startup Cold: {result.startup_cold_ms:.2f}ms")
typer.echo(f"Startup Warm: {result.startup_warm_ms:.2f}ms")
if result.import_analysis:
typer.echo(f"\nTop Import: {result.import_analysis[0]['module']}")
typer.echo(f" Time: {result.import_analysis[0]['cumulative_ms']:.2f}ms")
typer.echo("\nScenario Results:")
for scenario in result.scenarios:
stats = scenario.get_stats(scenario.total_times)
typer.echo(f" {scenario.name}: {stats['mean']:.2f}ms (±{stats['stdev']:.2f}ms)")
typer.echo(f"\n✅ Full results saved to: {output_dir}")
except Exception as e:
typer.echo(f"Error running suite: {e}", err=True)
raise typer.Exit(1)
@app.command("snapshot")
def profile_snapshot(
name: str = typer.Argument("current", help="Name for the snapshot"),
baseline: bool = typer.Option(False, "--baseline", "-b", help="Save as baseline for comparison"),
compare: bool = typer.Option(False, "--compare", "-c", help="Compare against baseline"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Create or compare performance snapshots.
Snapshots record timing data for baseline comparison.
Use --baseline to save a reference point, then --compare to check for regressions.
Examples:
praisonai profile snapshot --baseline
praisonai profile snapshot current --compare
praisonai profile snapshot v2.0 --format json
"""
try:
from ..features.profiler import (
run_profile_suite,
ScenarioConfig,
PerfSnapshotManager,
create_snapshot_from_suite,
format_comparison_report,
)
except ImportError as e:
typer.echo(f"Error: Profiler module not available: {e}", err=True)
raise typer.Exit(1)
manager = PerfSnapshotManager()
if compare:
# Load baseline and compare
baseline_snap = manager.load_baseline()
if not baseline_snap:
typer.echo("❌ No baseline found. Create one with: praisonai profile snapshot --baseline", err=True)
raise typer.Exit(1)
typer.echo("🔬 Running current profile for comparison...", err=True)
# Run quick suite
scenarios = [
ScenarioConfig(name="simple", prompt="hi", stream=False, iterations=2, warmup=1),
]
result = run_profile_suite(
output_dir="/tmp/praisonai_snapshot_compare",
scenarios=scenarios,
verbose=False,
)
current_snap = create_snapshot_from_suite(result, name)
comparison = manager.compare(current_snap)
if output_format == "json":
import json
typer.echo(json.dumps(comparison.to_dict(), indent=2))
else:
typer.echo(format_comparison_report(comparison))
if comparison.is_regression():
raise typer.Exit(1) # Exit with error on regression
else:
# Create new snapshot
typer.echo(f"🔬 Creating performance snapshot '{name}'...", err=True)
scenarios = [
ScenarioConfig(name="simple", prompt="hi", stream=False, iterations=3, warmup=1),
ScenarioConfig(name="stream", prompt="hi", stream=True, iterations=2, warmup=1),
]
result = run_profile_suite(
output_dir="/tmp/praisonai_snapshot",
scenarios=scenarios,
verbose=True,
)
snapshot = create_snapshot_from_suite(result, name)
if baseline:
path = manager.save_baseline(snapshot)
typer.echo(f"\n✅ Baseline saved to: {path}")
else:
path = manager.save(snapshot)
typer.echo(f"\n✅ Snapshot saved to: {path}")
if output_format == "json":
import json
typer.echo(json.dumps(snapshot.to_dict(), indent=2))
else:
typer.echo("\nSnapshot Summary:")
typer.echo(f" Startup Cold: {snapshot.startup_cold_ms:.2f}ms")
typer.echo(f" Import Time: {snapshot.import_time_ms:.2f}ms")
typer.echo(f" Query Time: {snapshot.query_time_ms:.2f}ms")
@app.command("optimize")
def profile_optimize(
prewarm: bool = typer.Option(False, "--prewarm", help="Enable provider pre-warming"),
lite: bool = typer.Option(False, "--lite", help="Enable lite mode (skip type validation)"),
show_config: bool = typer.Option(False, "--show", help="Show current optimization config"),
):
"""
Configure performance optimizations.
All optimizations are opt-in and safe by default.
Examples:
praisonai profile optimize --show
praisonai profile optimize --prewarm
praisonai profile optimize --lite
"""
try:
from ..features.profiler import (
PrewarmManager,
get_lite_mode_config,
get_provider_cache,
)
except ImportError as e:
typer.echo(f"Error: Profiler module not available: {e}", err=True)
raise typer.Exit(1)
if show_config:
typer.echo("=" * 50)
typer.echo("Performance Optimization Status")
typer.echo("=" * 50)
lite_config = get_lite_mode_config()
typer.echo(f"\nLite Mode: {'Enabled' if lite_config.enabled else 'Disabled'}")
if lite_config.enabled:
typer.echo(f" Skip Type Validation: {lite_config.skip_type_validation}")
typer.echo(f" Skip Model Validation: {lite_config.skip_model_validation}")
typer.echo(f" Minimal Imports: {lite_config.minimal_imports}")
typer.echo(f"\nPre-warming: {'Enabled' if PrewarmManager.is_enabled() else 'Disabled'}")
cache = get_provider_cache()
stats = cache.get_stats()
typer.echo("\nProvider Cache:")
typer.echo(f" Hits: {stats['hits']}")
typer.echo(f" Misses: {stats['misses']}")
typer.echo(f" Size: {stats['size']}")
typer.echo("\n" + "=" * 50)
typer.echo("\nTo enable optimizations, set environment variables:")
typer.echo(" PRAISONAI_LITE_MODE=1")
typer.echo(" PRAISONAI_SKIP_TYPE_VALIDATION=1")
typer.echo(" PRAISONAI_MINIMAL_IMPORTS=1")
return
if prewarm:
typer.echo("🔥 Enabling provider pre-warming...")
PrewarmManager.enable()
import os
if os.environ.get('OPENAI_API_KEY'):
typer.echo(" Pre-warming OpenAI...")
PrewarmManager.prewarm_provider('openai')
if os.environ.get('ANTHROPIC_API_KEY'):
typer.echo(" Pre-warming Anthropic...")
PrewarmManager.prewarm_provider('anthropic')
typer.echo("✅ Pre-warming initiated (runs in background)")
if lite:
typer.echo("⚡ Lite mode configuration:")
typer.echo(" Set these environment variables to enable:")
typer.echo(" export PRAISONAI_LITE_MODE=1")
typer.echo(" export PRAISONAI_SKIP_TYPE_VALIDATION=1")
typer.echo(" export PRAISONAI_MINIMAL_IMPORTS=1")
if not prewarm and not lite and not show_config:
typer.echo("Use --show to see current config, or --prewarm/--lite to enable optimizations")
@app.callback(invoke_without_command=True)
def profile_callback(ctx: typer.Context):
"""Show profile help if no subcommand."""
if ctx.invoked_subcommand is None:
typer.echo(ctx.get_help())
@@ -0,0 +1,45 @@
"""
Realtime command group for PraisonAI CLI.
Provides realtime interaction commands.
"""
from typing import Optional
import typer
app = typer.Typer(help="Realtime interaction mode")
@app.callback(invoke_without_command=True)
def realtime_main(
ctx: typer.Context,
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""
Start realtime interaction mode.
Examples:
praisonai realtime
praisonai realtime --model gpt-4o
"""
from praisonai.cli.main import PraisonAI
import sys
argv = ['realtime']
if model:
argv.extend(['--model', model])
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,76 @@
"""
Recipe command group for PraisonAI CLI.
Provides recipe management commands.
"""
import typer
app = typer.Typer(help="Recipe management")
@app.command("list")
def recipe_list():
"""List available recipes."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['recipe', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("run")
def recipe_run(
name: str = typer.Argument(..., help="Recipe name"),
model: str = typer.Option(None, "--model", "-m", help="LLM model to use"),
):
"""Run a recipe."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['recipe', 'run', name]
if model:
argv.extend(['--model', model])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("install")
def recipe_install(
source: str = typer.Argument(..., help="Recipe source (path or URL)"),
):
"""Install a recipe."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['recipe', 'install', source]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,51 @@
"""
Registry command group for PraisonAI CLI.
Provides registry management commands.
"""
import typer
app = typer.Typer(help="Registry management")
@app.command("list")
def registry_list():
"""List registry entries."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['registry', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("serve")
def registry_serve(
port: int = typer.Option(8080, "--port", "-p", help="Port to serve on"),
):
"""Start registry server."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['registry', 'serve', '--port', str(port)]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,49 @@
"""
Research command group for PraisonAI CLI.
Provides research and analysis commands.
"""
import typer
app = typer.Typer(help="Research and analysis")
@app.callback(invoke_without_command=True)
def research_main(
ctx: typer.Context,
query: str = typer.Argument(None, help="Research query"),
model: str = typer.Option(None, "--model", "-m", help="LLM model to use"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
output: str = typer.Option(None, "--output", "-o", help="Output file path"),
):
"""
Run research and analysis tasks.
Examples:
praisonai research "What are the latest AI trends?"
praisonai research --model gpt-4o "Analyze market data"
"""
from praisonai.cli.main import PraisonAI
import sys
argv = ['research']
if query:
argv.append(query)
if model:
argv.extend(['--model', model])
if verbose:
argv.append('--verbose')
if output:
argv.extend(['--output', output])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,71 @@
"""
Rules command group for PraisonAI CLI.
Provides rules management commands.
"""
import typer
app = typer.Typer(help="Rules management")
@app.command("list")
def rules_list():
"""List active rules."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['rules', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("add")
def rules_add(
rule: str = typer.Argument(..., help="Rule to add"),
):
"""Add a rule."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['rules', 'add', rule]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("clear")
def rules_clear():
"""Clear all rules."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['rules', 'clear']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
+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,99 @@
"""
Skills command group for PraisonAI CLI.
Provides skill management commands.
"""
import typer
app = typer.Typer(help="Skill management")
@app.command("list")
def skills_list(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed info"),
):
"""List available skills."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['skills', 'list']
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("validate")
def skills_validate(
path: str = typer.Argument(..., help="Skill directory path"),
):
"""Validate a skill."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['skills', 'validate', path]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("create")
def skills_create(
name: str = typer.Argument(..., help="Skill name"),
):
"""Create a new skill."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['skills', 'create', name]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("install")
def skills_install(
source: str = typer.Argument(..., help="Skill source (path or URL)"),
):
"""Install a skill."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['skills', 'install', source]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,54 @@
"""
Templates command group for PraisonAI CLI.
Provides template management commands.
"""
import typer
app = typer.Typer(help="Template management")
@app.command("list")
def templates_list():
"""List available templates."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['templates', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("create")
def templates_create(
name: str = typer.Argument(..., help="Template name"),
source: str = typer.Option(None, "--source", "-s", help="Source file to create template from"),
):
"""Create a new template."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['templates', 'create', name]
if source:
argv.extend(['--source', source])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,74 @@
"""
Todo command group for PraisonAI CLI.
Provides todo/task management commands.
"""
import typer
app = typer.Typer(help="Todo/task management")
@app.command("list")
def todo_list():
"""List todos."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['todo', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("add")
def todo_add(
task: str = typer.Argument(..., help="Task description"),
priority: str = typer.Option("medium", "--priority", "-p", help="Priority (low, medium, high)"),
):
"""Add a todo."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['todo', 'add', task]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("done")
def todo_done(
task_id: str = typer.Argument(..., help="Task ID to mark as done"),
):
"""Mark a todo as done."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['todo', 'done', task_id]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,77 @@
"""
Tools command group for PraisonAI CLI.
Provides tool management commands.
"""
import typer
app = typer.Typer(help="Tool management")
@app.command("list")
def tools_list(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed info"),
):
"""List available tools."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['tools', 'list']
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("info")
def tools_info(
name: str = typer.Argument(..., help="Tool name"),
):
"""Show tool information."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['tools', 'info', name]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("test")
def tools_test(
name: str = typer.Argument(..., help="Tool name to test"),
):
"""Test a tool."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['tools', 'test', name]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -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,48 @@
"""
Train command group for PraisonAI CLI.
Provides model training commands.
"""
from typing import Optional
import typer
app = typer.Typer(help="Model training and fine-tuning")
@app.callback(invoke_without_command=True)
def train_main(
ctx: typer.Context,
dataset: Optional[str] = typer.Argument(None, help="Training dataset path"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="Base model to fine-tune"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""
Train or fine-tune models.
Examples:
praisonai train dataset.json
praisonai train --model gpt-4o dataset.json
"""
from praisonai.cli.main import PraisonAI
import sys
argv = ['train']
if dataset:
argv.append(dataset)
if model:
argv.extend(['--model', model])
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,47 @@
"""
UI command group for PraisonAI CLI.
Provides web UI commands.
"""
from typing import Optional
import typer
app = typer.Typer(help="Web UI management")
@app.callback(invoke_without_command=True)
def ui_main(
ctx: typer.Context,
port: int = typer.Option(8080, "--port", "-p", help="Port to run UI on"),
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to"),
public: bool = typer.Option(False, "--public", help="Make UI publicly accessible"),
):
"""
Start the web UI.
Examples:
praisonai ui
praisonai ui --port 3000
praisonai ui --public
"""
from praisonai.cli.main import PraisonAI
import sys
argv = ['ui']
argv.extend(['--port', str(port)])
argv.extend(['--host', host])
if public:
argv.append('--public')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -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,82 @@
"""
Workflow command group for PraisonAI CLI.
Provides workflow management commands.
"""
import typer
app = typer.Typer(help="Workflow management")
@app.command("run")
def workflow_run(
file: str = typer.Argument(..., help="Workflow file path"),
model: str = typer.Option(None, "--model", "-m", help="LLM model to use"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Run a workflow."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['workflow', 'run', file]
if model:
argv.extend(['--model', model])
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("list")
def workflow_list():
"""List available workflows."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['workflow', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("create")
def workflow_create(
name: str = typer.Argument(..., help="Workflow name"),
template: str = typer.Option(None, "--template", "-t", help="Template to use"),
):
"""Create a new workflow."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['workflow', 'create', name]
if template:
argv.extend(['--template', template])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -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()
@@ -0,0 +1,99 @@
"""
PraisonAI Unified Execution Module.
Provides a single, unified execution primitive for all CLI modes:
- CLI direct prompt
- Profile command
- TUI workers
Design Principles:
1. Profiling observes, never alters execution
2. Zero overhead when profiling disabled
3. Sync and async are first-class citizens
4. Fail-safe, not fail-fast
5. Bounded resource usage
6. Schema stability with versioning
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .request import ExecutionRequest
from .result import ExecutionResult
from .core import execute_sync, execute_async
from .profiler import Profiler, ProfilerConfig, ProfileReport
# Lazy loading to maintain zero-overhead when not used
_lazy_cache = {}
def __getattr__(name: str):
"""Lazy load execution components."""
global _lazy_cache
if name in _lazy_cache:
return _lazy_cache[name]
if name == "ExecutionRequest":
from .request import ExecutionRequest
_lazy_cache[name] = ExecutionRequest
return ExecutionRequest
elif name == "ExecutionResult":
from .result import ExecutionResult
_lazy_cache[name] = ExecutionResult
return ExecutionResult
elif name == "execute_sync":
from .core import execute_sync
_lazy_cache[name] = execute_sync
return execute_sync
elif name == "execute_async":
from .core import execute_async
_lazy_cache[name] = execute_async
return execute_async
elif name == "Profiler":
from .profiler import Profiler
_lazy_cache[name] = Profiler
return Profiler
elif name == "ProfilerConfig":
from .profiler import ProfilerConfig
_lazy_cache[name] = ProfilerConfig
return ProfilerConfig
elif name == "ProfileReport":
from .profiler import ProfileReport
_lazy_cache[name] = ProfileReport
return ProfileReport
elif name == "TimingBreakdown":
from .profiler import TimingBreakdown
_lazy_cache[name] = TimingBreakdown
return TimingBreakdown
elif name == "InvocationInfo":
from .profiler import InvocationInfo
_lazy_cache[name] = InvocationInfo
return InvocationInfo
elif name == "DecisionTrace":
from .profiler import DecisionTrace
_lazy_cache[name] = DecisionTrace
return DecisionTrace
elif name == "ModuleBreakdown":
from .profiler import ModuleBreakdown
_lazy_cache[name] = ModuleBreakdown
return ModuleBreakdown
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
# Core execution
"ExecutionRequest",
"ExecutionResult",
"execute_sync",
"execute_async",
# Profiling
"Profiler",
"ProfilerConfig",
"ProfileReport",
"TimingBreakdown",
"InvocationInfo",
"DecisionTrace",
"ModuleBreakdown",
]
@@ -0,0 +1,210 @@
"""
Core Execution Module.
Provides the unified execution primitives:
- _execute_core(): Single implementation shared by all paths
- execute_sync(): Synchronous wrapper for CLI
- execute_async(): Asynchronous wrapper for TUI workers
"""
import asyncio
import time
import uuid
from typing import Optional, AsyncGenerator
from .request import ExecutionRequest
from .result import ExecutionResult
def _generate_run_id() -> str:
"""Generate a unique run ID."""
return str(uuid.uuid4())[:8]
def _execute_core(
request: ExecutionRequest,
timing: Optional[dict] = None,
) -> ExecutionResult:
"""
THE SINGLE EXECUTION IMPLEMENTATION.
All execution modes call this function.
Profiler wraps this function, never injects into it.
Args:
request: The execution request
timing: Optional dict to record timing phases (for profiling)
Returns:
ExecutionResult with output and metadata
"""
run_id = _generate_run_id()
start_time = time.perf_counter()
if timing is not None:
timing["core_start"] = start_time
try:
# Phase 1: Import (measured inline)
import_start = time.perf_counter()
from praisonaiagents import Agent
import_end = time.perf_counter()
if timing is not None:
timing["imports_ms"] = (import_end - import_start) * 1000
# Phase 2: Agent Construction
agent_start = time.perf_counter()
agent_config = {
"name": request.agent_name,
"verbose": False,
}
if request.agent_instructions:
agent_config["instructions"] = request.agent_instructions
if request.model:
agent_config["llm"] = request.model
if request.tools:
agent_config["tools"] = list(request.tools)
agent = Agent(**agent_config)
agent_end = time.perf_counter()
if timing is not None:
timing["agent_init_ms"] = (agent_end - agent_start) * 1000
# Phase 3: Execution
exec_start = time.perf_counter()
if request.stream:
# For streaming, collect all chunks
chunks = []
if hasattr(agent, '_start_stream'):
for chunk in agent._start_stream(request.prompt):
chunks.append(chunk)
else:
# Fallback to non-streaming
result = agent.start(request.prompt)
chunks = [result] if result else []
output = "".join(chunks)
else:
output = agent.start(request.prompt)
exec_end = time.perf_counter()
if timing is not None:
timing["execution_ms"] = (exec_end - exec_start) * 1000
end_time = time.perf_counter()
if timing is not None:
timing["total_ms"] = (end_time - start_time) * 1000
return ExecutionResult(
output=output or "",
run_id=run_id,
success=True,
start_time=start_time,
end_time=end_time,
metadata={
"model": request.model,
"stream": request.stream,
},
)
except Exception as e:
end_time = time.perf_counter()
if timing is not None:
timing["total_ms"] = (end_time - start_time) * 1000
timing["error"] = str(e)
return ExecutionResult.from_error(
error=str(e),
run_id=run_id,
)
def execute_sync(request: ExecutionRequest) -> ExecutionResult:
"""
Synchronous execution wrapper.
Used by:
- CLI direct prompt
- Profile command
Args:
request: The execution request
Returns:
ExecutionResult with output and metadata
"""
return _execute_core(request)
async def execute_async(request: ExecutionRequest) -> ExecutionResult:
"""
Asynchronous execution wrapper.
Used by:
- TUI workers
- Queue system
Args:
request: The execution request
Returns:
ExecutionResult with output and metadata
"""
# Run the synchronous core in a thread pool to avoid blocking
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _execute_core, request, None)
async def execute_async_stream(
request: ExecutionRequest,
) -> AsyncGenerator[str, None]:
"""
Asynchronous streaming execution.
Yields chunks as they arrive.
Args:
request: The execution request (stream=True recommended)
Yields:
String chunks of the response
"""
try:
from praisonaiagents import Agent
agent_config = {
"name": request.agent_name,
"verbose": False,
}
if request.agent_instructions:
agent_config["instructions"] = request.agent_instructions
if request.model:
agent_config["llm"] = request.model
if request.tools:
agent_config["tools"] = list(request.tools)
agent = Agent(**agent_config)
if hasattr(agent, '_start_stream'):
for chunk in agent._start_stream(request.prompt):
yield chunk
await asyncio.sleep(0) # Yield control
else:
result = agent.start(request.prompt)
if result:
yield result
except Exception as e:
yield f"Error: {e}"
@@ -0,0 +1,898 @@
"""
Profiler Module.
Provides external profiling that wraps execution.
Execution code has ZERO awareness of profiling.
Design Principles:
1. Profiler wraps execution, does not inject into it
2. Layered profiling with explicit overhead
3. Bounded resource usage
4. Versioned, stable output schema
"""
import cProfile
import pstats
import time
import platform
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from .request import ExecutionRequest
from .result import ExecutionResult
from .core import _execute_core
# Schema version - MUST be incremented for breaking changes
SCHEMA_VERSION = "1.0"
# Bounds for profile data
MAX_FUNCTION_STATS = 1000
MAX_CALL_GRAPH_EDGES = 5000
MAX_NETWORK_REQUESTS = 100
MAX_IMPORT_MODULES = 200
@dataclass
class ProfilerConfig:
"""
Profiler configuration with explicit layer control.
Layers:
- 0: Wall-clock phases only (<1ms overhead)
- 1: + cProfile function stats (~5% overhead)
- 2: + Call graph callers/callees (~15% overhead)
Network timing is orthogonal (opt-in via track_network).
"""
# Layer control (0, 1, or 2)
layer: int = 1
# Layer 1+ options
limit: int = 30 # Max functions to show
sort_by: str = "cumulative" # cumulative, time, calls
# Layer 2 options
show_callers: bool = False
show_callees: bool = False
# Network timing (orthogonal to layers)
track_network: bool = False
# Output options
output_format: str = "text" # text, json
save_path: Optional[str] = None
@classmethod
def from_flags(
cls,
profile: bool = False,
deep: bool = False,
network: bool = False,
) -> 'ProfilerConfig':
"""Create config from CLI flags."""
if deep:
return cls(
layer=2,
show_callers=True,
show_callees=True,
track_network=network,
)
elif profile:
return cls(layer=1, track_network=network)
else:
return cls(layer=0, track_network=network)
@dataclass
class TimingBreakdown:
"""
Phase timing breakdown with full timeline visibility.
All fields in milliseconds.
Timeline order: cli_entry -> cli_parse -> routing -> imports -> agent_init ->
network_start -> first_token -> first_output -> execution_end
"""
# MANDATORY fields (always present)
total_ms: float = 0.0
imports_ms: float = 0.0
agent_init_ms: float = 0.0
execution_ms: float = 0.0
# TIMELINE PHASES (new - for full visibility)
cli_entry_ms: float = 0.0 # Time from ENTER to CLI entry point
cli_parse_ms: float = 0.0 # Argparse/Typer routing time
routing_ms: float = 0.0 # Command routing time
network_start_ms: float = 0.0 # When network request started (relative to start)
first_token_ms: float = 0.0 # Time to first token (streaming)
first_output_ms: float = 0.0 # Time to First Response (TTFR) - first visible output
# OPTIONAL fields (may be 0)
tool_time_ms: float = 0.0 # If tools were called
@property
def time_to_first_response_ms(self) -> float:
"""Time to First Response (TTFR) - the key user-perceived latency metric."""
if self.first_output_ms > 0:
return self.first_output_ms
# Fallback: use execution end if first_output not captured
return self.total_ms
def to_dict(self) -> Dict[str, float]:
"""Convert to dictionary."""
return {
"total_ms": self.total_ms,
"imports_ms": self.imports_ms,
"agent_init_ms": self.agent_init_ms,
"execution_ms": self.execution_ms,
"cli_entry_ms": self.cli_entry_ms,
"cli_parse_ms": self.cli_parse_ms,
"routing_ms": self.routing_ms,
"network_start_ms": self.network_start_ms,
"first_token_ms": self.first_token_ms,
"first_output_ms": self.first_output_ms,
"time_to_first_response_ms": self.time_to_first_response_ms,
"tool_time_ms": self.tool_time_ms,
}
def to_timeline(self) -> List[Tuple[str, float, float]]:
"""
Get ordered timeline of phases.
Returns list of (phase_name, start_ms, duration_ms) tuples.
"""
timeline = []
cursor = 0.0
# CLI Entry (if captured)
if self.cli_entry_ms > 0:
timeline.append(("CLI Entry", 0.0, self.cli_entry_ms))
cursor = self.cli_entry_ms
# CLI Parse
if self.cli_parse_ms > 0:
timeline.append(("CLI Parse", cursor, self.cli_parse_ms))
cursor += self.cli_parse_ms
# Routing
if self.routing_ms > 0:
timeline.append(("Routing", cursor, self.routing_ms))
cursor += self.routing_ms
# Imports
if self.imports_ms > 0:
timeline.append(("Imports", cursor, self.imports_ms))
cursor += self.imports_ms
# Agent Init
if self.agent_init_ms > 0:
timeline.append(("Agent Init", cursor, self.agent_init_ms))
cursor += self.agent_init_ms
# Network to First Token (if streaming)
if self.network_start_ms > 0 and self.first_token_ms > 0:
network_duration = self.first_token_ms - self.network_start_ms
if network_duration > 0:
timeline.append(("Network → First Token", self.network_start_ms, network_duration))
# Execution
if self.execution_ms > 0:
timeline.append(("Execution", cursor, self.execution_ms))
return timeline
@dataclass
class FunctionStat:
"""Statistics for a single function."""
name: str
file: str
line: int
calls: int
total_time_ms: float
cumulative_time_ms: float
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"name": self.name,
"file": self.file,
"line": self.line,
"calls": self.calls,
"total_time_ms": self.total_time_ms,
"cumulative_time_ms": self.cumulative_time_ms,
}
@dataclass
class CallGraph:
"""Call graph data (callers and callees)."""
callers: Dict[str, List[str]] = field(default_factory=dict)
callees: Dict[str, List[str]] = field(default_factory=dict)
@property
def edges(self) -> List[Tuple[str, str]]:
"""Get all edges as (caller, callee) tuples."""
edges = []
for callee, callers in self.callers.items():
for caller in callers:
edges.append((caller, callee))
return edges
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"callers": self.callers,
"callees": self.callees,
"edge_count": len(self.edges),
}
@dataclass
class RequestTiming:
"""Single HTTP request timing."""
url: str
method: str
start_time: float
first_byte_time: float
end_time: float
status_code: int
@property
def ttfb_ms(self) -> float:
"""Time to first byte in milliseconds."""
return (self.first_byte_time - self.start_time) * 1000
@property
def total_ms(self) -> float:
"""Total request time in milliseconds."""
return (self.end_time - self.start_time) * 1000
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"url": self.url,
"method": self.method,
"ttfb_ms": self.ttfb_ms,
"total_ms": self.total_ms,
"status_code": self.status_code,
}
@dataclass
class InvocationInfo:
"""How the profile was invoked."""
method: str # "cli_direct", "profile_command", "tui"
flags: Dict[str, Any] = field(default_factory=dict)
praisonai_version: str = ""
python_version: str = ""
def __post_init__(self):
"""Set version info if not provided."""
if not self.python_version:
self.python_version = platform.python_version()
if not self.praisonai_version:
try:
from praisonai.version import __version__
self.praisonai_version = __version__
except ImportError:
self.praisonai_version = "unknown"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"method": self.method,
"flags": self.flags,
"praisonai_version": self.praisonai_version,
"python_version": self.python_version,
}
@dataclass
class DecisionTrace:
"""
Decision trace for deep profile visibility.
Explains what decisions were made during execution.
"""
agent_config: str = "" # Agent configuration chosen
model_selected: str = "" # Model used
streaming_mode: bool = False # Streaming vs non-streaming
profile_layer: int = 0 # Profile layer (0/1/2)
tools_enabled: List[str] = field(default_factory=list)
tools_disabled: List[str] = field(default_factory=list)
fallbacks_used: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"agent_config": self.agent_config,
"model_selected": self.model_selected,
"streaming_mode": self.streaming_mode,
"profile_layer": self.profile_layer,
"tools_enabled": self.tools_enabled,
"tools_disabled": self.tools_disabled,
"fallbacks_used": self.fallbacks_used,
}
def to_text(self) -> str:
"""Format as human-readable text."""
lines = []
lines.append(f" Agent Config: {self.agent_config or 'default'}")
lines.append(f" Model: {self.model_selected or 'default'}")
lines.append(f" Streaming: {'enabled' if self.streaming_mode else 'disabled'}")
lines.append(f" Profile Layer: {self.profile_layer}")
if self.tools_enabled:
lines.append(f" Tools Enabled: {', '.join(self.tools_enabled)}")
if self.tools_disabled:
lines.append(f" Tools Disabled: {', '.join(self.tools_disabled)}")
if self.fallbacks_used:
lines.append(f" Fallbacks: {', '.join(self.fallbacks_used)}")
return "\n".join(lines)
@dataclass
class ModuleBreakdown:
"""
Module/file breakdown for deep profile visibility.
Groups functions by module category.
"""
cli_modules: List[str] = field(default_factory=list)
execution_modules: List[str] = field(default_factory=list)
agent_modules: List[str] = field(default_factory=list)
tool_modules: List[str] = field(default_factory=list)
network_modules: List[str] = field(default_factory=list)
third_party_modules: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, List[str]]:
"""Convert to dictionary."""
return {
"cli": self.cli_modules,
"execution": self.execution_modules,
"agent": self.agent_modules,
"tools": self.tool_modules,
"network": self.network_modules,
"third_party": self.third_party_modules,
}
@dataclass
class ProfileReport:
"""
Canonical profile report.
VERSION 1.0 - Schema is frozen after release.
New fields MUST be optional with defaults.
"""
# Schema version (MANDATORY, NEVER CHANGES MEANING)
schema_version: str = SCHEMA_VERSION
# Identity (MANDATORY)
run_id: str = ""
timestamp: str = "" # ISO 8601
# Invocation (MANDATORY)
invocation: InvocationInfo = field(default_factory=lambda: InvocationInfo(method="unknown"))
# Timing (MANDATORY)
timing: TimingBreakdown = field(default_factory=TimingBreakdown)
# Function stats (OPTIONAL, present if layer >= 1)
functions: Optional[List[FunctionStat]] = None
# Call graph (OPTIONAL, present if layer >= 2)
call_graph: Optional[CallGraph] = None
# Network (OPTIONAL, present if track_network)
network: Optional[List[RequestTiming]] = None
# Response (MANDATORY but truncated)
response_preview: str = "" # Max 500 chars
# Decision trace (OPTIONAL, present if layer >= 2)
decision_trace: Optional[DecisionTrace] = None
# Module breakdown (OPTIONAL, present if layer >= 2)
module_breakdown: Optional[ModuleBreakdown] = None
def __post_init__(self):
"""Set timestamp if not provided."""
if not self.timestamp:
self.timestamp = datetime.utcnow().isoformat() + "Z"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result = {
"schema_version": self.schema_version,
"run_id": self.run_id,
"timestamp": self.timestamp,
"invocation": self.invocation.to_dict(),
"timing": self.timing.to_dict(),
"timeline": self.timing.to_timeline(),
"timeline_diagram": self.to_timeline_diagram(),
"response_preview": self.response_preview,
}
if self.functions is not None:
result["functions"] = [f.to_dict() for f in self.functions]
if self.call_graph is not None:
result["call_graph"] = self.call_graph.to_dict()
if self.network is not None:
result["network"] = [r.to_dict() for r in self.network]
if self.decision_trace is not None:
result["decision_trace"] = self.decision_trace.to_dict()
if self.module_breakdown is not None:
result["module_breakdown"] = self.module_breakdown.to_dict()
return result
def to_json(self) -> str:
"""Convert to JSON string."""
import json
return json.dumps(self.to_dict(), indent=2, default=str)
def to_timeline_diagram(self) -> str:
"""
Generate ASCII timeline diagram showing execution phases.
Format:
ENTER ─────────────────────────────────────────────────────────► RESPONSE
│ imports │ init │ network │
│ XXXms │ XXms │ XXXms │
└─────────┴──────┴───────────────────────┘
TOTAL: XXXXms
"""
lines = []
total = self.timing.total_ms
if total <= 0:
return "No timing data available"
# Calculate phase widths (proportional to time, min 8 chars)
phases = []
if self.timing.imports_ms > 0:
phases.append(("imports", self.timing.imports_ms))
if self.timing.agent_init_ms > 0:
phases.append(("init", self.timing.agent_init_ms))
if self.timing.execution_ms > 0:
phases.append(("network", self.timing.execution_ms))
if not phases:
phases = [("total", total)]
# Scale to fit in ~60 chars
scale = 50.0 / total if total > 0 else 1
# Build the diagram
lines.append("")
lines.append("## Timeline Diagram")
lines.append("")
# Top line with arrow
top_line = "ENTER "
for name, ms in phases:
width = max(8, int(ms * scale))
top_line += "─" * width
top_line += "► RESPONSE"
lines.append(top_line)
# Phase names line
name_line = " "
for name, ms in phases:
width = max(8, int(ms * scale))
name_line += "│" + name.center(width - 1)
name_line += "│"
lines.append(name_line)
# Phase times line
time_line = " "
for name, ms in phases:
width = max(8, int(ms * scale))
time_str = f"{ms:.0f}ms"
time_line += "│" + time_str.center(width - 1)
time_line += "│"
lines.append(time_line)
# Bottom line
bottom_line = " "
for i, (name, ms) in enumerate(phases):
width = max(8, int(ms * scale))
if i == 0:
bottom_line += "└" + "─" * (width - 1)
else:
bottom_line += "┴" + "─" * (width - 1)
bottom_line += "┘"
lines.append(bottom_line)
# Total line
total_line = " " * (len(bottom_line) - 15) + f"TOTAL: {total:.0f}ms"
lines.append(total_line)
lines.append("")
return "\n".join(lines)
def to_text(self) -> str:
"""Format as human-readable text."""
lines = []
lines.append("=" * 70)
lines.append("PraisonAI Profile Report")
lines.append("=" * 70)
lines.append("")
# Identity
lines.append(f"Run ID: {self.run_id}")
lines.append(f"Timestamp: {self.timestamp}")
lines.append(f"Method: {self.invocation.method}")
lines.append(f"Version: {self.invocation.praisonai_version}")
lines.append("")
# TIMELINE DIAGRAM (visual representation)
lines.append(self.to_timeline_diagram())
# TIMELINE SECTION (shows ENTER → First Response)
lines.append("## Execution Timeline")
lines.append("-" * 45)
timeline = self.timing.to_timeline()
if timeline:
for phase_name, start_ms, duration_ms in timeline:
lines.append(f" {phase_name:<25} : {duration_ms:>8.2f} ms")
else:
# Fallback to basic phases
if self.timing.imports_ms > 0:
lines.append(f" {'Imports':<25} : {self.timing.imports_ms:>8.2f} ms")
if self.timing.agent_init_ms > 0:
lines.append(f" {'Agent Init':<25} : {self.timing.agent_init_ms:>8.2f} ms")
if self.timing.execution_ms > 0:
lines.append(f" {'Execution':<25} : {self.timing.execution_ms:>8.2f} ms")
lines.append(" " + "─" * 43)
# TIME TO FIRST RESPONSE (TTFR) - the key metric
ttfr = self.timing.time_to_first_response_ms
lines.append(f" {'⏱ Time to First Response':<25} : {ttfr:>8.2f} ms")
lines.append(f" {'TOTAL':<25} : {self.timing.total_ms:>8.2f} ms")
lines.append("")
# Timing breakdown (legacy format for compatibility)
lines.append("## Timing Breakdown")
lines.append("-" * 40)
if self.timing.cli_entry_ms > 0:
lines.append(f" CLI Entry: {self.timing.cli_entry_ms:>10.2f} ms")
if self.timing.cli_parse_ms > 0:
lines.append(f" CLI Parse: {self.timing.cli_parse_ms:>10.2f} ms")
if self.timing.routing_ms > 0:
lines.append(f" Routing: {self.timing.routing_ms:>10.2f} ms")
lines.append(f" Imports: {self.timing.imports_ms:>10.2f} ms")
lines.append(f" Agent Init: {self.timing.agent_init_ms:>10.2f} ms")
lines.append(f" Execution: {self.timing.execution_ms:>10.2f} ms")
if self.timing.network_start_ms > 0:
lines.append(f" Network Start: {self.timing.network_start_ms:>10.2f} ms")
if self.timing.first_token_ms > 0:
lines.append(f" First Token: {self.timing.first_token_ms:>10.2f} ms")
if self.timing.first_output_ms > 0:
lines.append(f" First Output: {self.timing.first_output_ms:>10.2f} ms")
lines.append(" ─────────────────────────────────────")
lines.append(f" TOTAL: {self.timing.total_ms:>10.2f} ms")
lines.append("")
# Decision trace (deep profile only)
if self.decision_trace:
lines.append("## Decision Trace")
lines.append("-" * 40)
lines.append(self.decision_trace.to_text())
lines.append("")
# Function stats
if self.functions:
lines.append("## Top Functions by Cumulative Time")
lines.append("-" * 70)
lines.append(f"{'Function':<40} {'Calls':>8} {'Cumul (ms)':>12}")
lines.append("-" * 70)
for func in self.functions[:20]:
name = func.name[:38] if len(func.name) > 38 else func.name
lines.append(f"{name:<40} {func.calls:>8} {func.cumulative_time_ms:>12.2f}")
lines.append("")
# Module breakdown (deep profile only)
if self.module_breakdown:
lines.append("## Module Breakdown")
lines.append("-" * 40)
mb = self.module_breakdown
if mb.cli_modules:
lines.append(f" CLI: {len(mb.cli_modules)} modules")
if mb.execution_modules:
lines.append(f" Execution: {len(mb.execution_modules)} modules")
if mb.agent_modules:
lines.append(f" Agent: {len(mb.agent_modules)} modules")
if mb.tool_modules:
lines.append(f" Tools: {len(mb.tool_modules)} modules")
if mb.network_modules:
lines.append(f" Network: {len(mb.network_modules)} modules")
if mb.third_party_modules:
lines.append(f" Third-party:{len(mb.third_party_modules)} modules")
lines.append("")
# Call graph summary
if self.call_graph:
lines.append(f"## Call Graph: {len(self.call_graph.edges)} edges")
lines.append("")
# Network timing
if self.network:
lines.append("## Network Requests")
lines.append("-" * 70)
for req in self.network[:10]:
lines.append(f" {req.method} {req.url[:50]} - {req.total_ms:.2f}ms (TTFB: {req.ttfb_ms:.2f}ms)")
lines.append("")
# Response preview
if self.response_preview:
lines.append("## Response Preview")
lines.append("-" * 40)
preview = self.response_preview[:200]
if len(self.response_preview) > 200:
preview += "..."
lines.append(preview)
lines.append("")
lines.append("=" * 70)
return "\n".join(lines)
class Profiler:
"""
External profiler that wraps execution.
Execution code has ZERO awareness of profiling.
Profiler uses cProfile.runctx() or similar.
"""
def __init__(self, config: ProfilerConfig):
self.config = config
self._cprofile: Optional[cProfile.Profile] = None
self._timing: Dict[str, float] = {}
self._network_requests: List[RequestTiming] = []
def profile_sync(
self,
request: ExecutionRequest,
invocation_method: str = "profile_command",
) -> Tuple[ExecutionResult, ProfileReport]:
"""
Profile synchronous execution.
Args:
request: The execution request
invocation_method: How this was invoked (for report)
Returns:
Tuple of (ExecutionResult, ProfileReport)
"""
timing_dict: Dict[str, float] = {}
# Layer 0: Wall-clock timing (always)
start = time.perf_counter()
# Layer 1+: cProfile
if self.config.layer >= 1:
self._cprofile = cProfile.Profile()
self._cprofile.enable()
try:
result = _execute_core(request, timing_dict)
finally:
if self._cprofile:
self._cprofile.disable()
total_ms = (time.perf_counter() - start) * 1000
# Build report
report = self._build_report(
request=request,
result=result,
timing_dict=timing_dict,
total_ms=total_ms,
invocation_method=invocation_method,
)
return result, report
def _build_report(
self,
request: ExecutionRequest,
result: ExecutionResult,
timing_dict: Dict[str, float],
total_ms: float,
invocation_method: str,
) -> ProfileReport:
"""Build the profile report from collected data."""
# Calculate first_output_ms (TTFR) - time from start to when output is available
# This is imports + agent_init + execution (when output becomes available)
imports_ms = timing_dict.get("imports_ms", 0.0)
agent_init_ms = timing_dict.get("agent_init_ms", 0.0)
execution_ms = timing_dict.get("execution_ms", 0.0)
first_output_ms = imports_ms + agent_init_ms + execution_ms
# Timing breakdown with full timeline
timing = TimingBreakdown(
total_ms=total_ms,
imports_ms=imports_ms,
agent_init_ms=agent_init_ms,
execution_ms=execution_ms,
cli_entry_ms=timing_dict.get("cli_entry_ms", 0.0),
cli_parse_ms=timing_dict.get("cli_parse_ms", 0.0),
routing_ms=timing_dict.get("routing_ms", 0.0),
network_start_ms=timing_dict.get("network_start_ms", 0.0),
first_token_ms=timing_dict.get("first_token_ms", 0.0),
first_output_ms=first_output_ms,
)
# Invocation info
invocation = InvocationInfo(
method=invocation_method,
flags={
"layer": self.config.layer,
"stream": request.stream,
"model": request.model,
},
)
# Function stats (layer 1+)
functions = None
if self.config.layer >= 1 and self._cprofile:
functions = self._extract_function_stats()
# Call graph (layer 2)
call_graph = None
if self.config.layer >= 2 and self._cprofile:
call_graph = self._extract_call_graph()
# Decision trace (layer 2 - deep profile)
decision_trace = None
if self.config.layer >= 2:
decision_trace = DecisionTrace(
agent_config=request.agent_name or "default",
model_selected=request.model or "default",
streaming_mode=request.stream,
profile_layer=self.config.layer,
tools_enabled=list(request.tools) if request.tools else [],
tools_disabled=[],
fallbacks_used=[],
)
# Module breakdown (layer 2 - deep profile)
module_breakdown = None
if self.config.layer >= 2 and functions:
module_breakdown = self._extract_module_breakdown(functions)
# Response preview (truncated)
response_preview = result.output[:500] if result.output else ""
return ProfileReport(
run_id=result.run_id,
invocation=invocation,
timing=timing,
functions=functions,
call_graph=call_graph,
network=self._network_requests if self._network_requests else None,
response_preview=response_preview,
decision_trace=decision_trace,
module_breakdown=module_breakdown,
)
def _extract_module_breakdown(self, functions: List[FunctionStat]) -> ModuleBreakdown:
"""Extract module breakdown from function stats."""
cli_modules = set()
execution_modules = set()
agent_modules = set()
tool_modules = set()
network_modules = set()
third_party_modules = set()
for func in functions:
file_path = func.file.lower()
if "praisonai/cli" in file_path:
cli_modules.add(func.file)
elif "praisonai" in file_path and "execution" in file_path:
execution_modules.add(func.file)
elif "praisonaiagents" in file_path and "agent" in file_path:
agent_modules.add(func.file)
elif "praisonaiagents" in file_path and "tool" in file_path:
tool_modules.add(func.file)
elif any(x in file_path for x in ["httpx", "httpcore", "urllib", "requests", "aiohttp"]):
network_modules.add(func.file)
elif "site-packages" in file_path or not file_path.startswith("/"):
third_party_modules.add(func.file)
return ModuleBreakdown(
cli_modules=list(cli_modules)[:20],
execution_modules=list(execution_modules)[:20],
agent_modules=list(agent_modules)[:20],
tool_modules=list(tool_modules)[:20],
network_modules=list(network_modules)[:20],
third_party_modules=list(third_party_modules)[:20],
)
def _extract_function_stats(self) -> List[FunctionStat]:
"""Extract function statistics from cProfile."""
if not self._cprofile:
return []
stats = pstats.Stats(self._cprofile)
# Sort by cumulative time
stats.sort_stats(self.config.sort_by)
# Extract stats
function_stats = []
for (filename, line, name), (cc, nc, tt, ct, callers) in stats.stats.items():
if len(function_stats) >= MAX_FUNCTION_STATS:
break
function_stats.append(FunctionStat(
name=name,
file=filename,
line=line,
calls=nc,
total_time_ms=tt * 1000,
cumulative_time_ms=ct * 1000,
))
# Sort and limit
function_stats.sort(key=lambda x: x.cumulative_time_ms, reverse=True)
return function_stats[:self.config.limit]
def _extract_call_graph(self) -> CallGraph:
"""Extract call graph from cProfile."""
if not self._cprofile:
return CallGraph()
stats = pstats.Stats(self._cprofile)
callers: Dict[str, List[str]] = {}
callees: Dict[str, List[str]] = {}
edge_count = 0
for (filename, line, name), (cc, nc, tt, ct, caller_dict) in stats.stats.items():
if edge_count >= MAX_CALL_GRAPH_EDGES:
break
callee_key = f"{name}:{filename}:{line}"
for (caller_file, caller_line, caller_name), _ in caller_dict.items():
if edge_count >= MAX_CALL_GRAPH_EDGES:
break
caller_key = f"{caller_name}:{caller_file}:{caller_line}"
# Add to callers
if callee_key not in callers:
callers[callee_key] = []
if caller_key not in callers[callee_key]:
callers[callee_key].append(caller_key)
# Add to callees
if caller_key not in callees:
callees[caller_key] = []
if callee_key not in callees[caller_key]:
callees[caller_key].append(callee_key)
edge_count += 1
return CallGraph(callers=callers, callees=callees)
@@ -0,0 +1,85 @@
"""
ExecutionRequest - Immutable request for execution.
This is the minimal, immutable request that all execution paths use.
CLI/TUI translate their flags into this structure.
"""
from dataclasses import dataclass
from typing import Callable, Optional, Tuple, Any
@dataclass(frozen=True)
class ExecutionRequest:
"""
Minimal, immutable request for execution.
NOT a kitchen-sink of all CLI flags.
CLI/TUI translate their flags into this.
Attributes:
prompt: The user prompt to execute
agent_name: Name of the agent (default: "Assistant")
agent_instructions: Custom instructions for the agent
model: LLM model to use (None = use default)
tools: Tuple of tool callables (immutable)
stream: Whether to stream the response
Note: This is frozen (immutable) to:
- Prevent accidental mutation during profiling
- Enable safe sharing across threads/coroutines
"""
# Required
prompt: str
# Agent identity (optional, has defaults)
agent_name: str = "Assistant"
agent_instructions: Optional[str] = None
# Model (optional, has defaults)
model: Optional[str] = None # None = use default
# Tools (optional) - using tuple for immutability
tools: Tuple[Callable[..., Any], ...] = ()
# Execution mode
stream: bool = False
def __post_init__(self):
"""Validate request after initialization."""
if not self.prompt:
raise ValueError("prompt cannot be empty")
def with_model(self, model: str) -> 'ExecutionRequest':
"""Create a new request with a different model."""
return ExecutionRequest(
prompt=self.prompt,
agent_name=self.agent_name,
agent_instructions=self.agent_instructions,
model=model,
tools=self.tools,
stream=self.stream,
)
def with_tools(self, tools: Tuple[Callable[..., Any], ...]) -> 'ExecutionRequest':
"""Create a new request with different tools."""
return ExecutionRequest(
prompt=self.prompt,
agent_name=self.agent_name,
agent_instructions=self.agent_instructions,
model=self.model,
tools=tools,
stream=self.stream,
)
def to_dict(self) -> dict:
"""Convert to dictionary for serialization."""
return {
"prompt": self.prompt,
"agent_name": self.agent_name,
"agent_instructions": self.agent_instructions,
"model": self.model,
"tools_count": len(self.tools),
"stream": self.stream,
}
@@ -0,0 +1,74 @@
"""
ExecutionResult - Result from execution.
Contains the output and metadata from an execution.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@dataclass
class ExecutionResult:
"""
Result from executing a request.
Attributes:
output: The text output from the agent
run_id: Unique identifier for this execution
success: Whether execution completed successfully
error: Error message if execution failed
metadata: Additional metadata about the execution
tool_calls: List of tools that were called
start_time: When execution started (perf_counter)
end_time: When execution ended (perf_counter)
"""
# Primary output
output: str
# Identity
run_id: str = ""
# Status
success: bool = True
error: Optional[str] = None
# Metadata
metadata: Dict[str, Any] = field(default_factory=dict)
# Tool tracking
tool_calls: List[str] = field(default_factory=list)
# Timing (raw perf_counter values)
start_time: float = 0.0
end_time: float = 0.0
@property
def duration_ms(self) -> float:
"""Duration in milliseconds."""
if self.start_time and self.end_time:
return (self.end_time - self.start_time) * 1000
return 0.0
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"output": self.output,
"run_id": self.run_id,
"success": self.success,
"error": self.error,
"metadata": self.metadata,
"tool_calls": self.tool_calls,
"duration_ms": self.duration_ms,
}
@classmethod
def from_error(cls, error: str, run_id: str = "") -> 'ExecutionResult':
"""Create a result from an error."""
return cls(
output="",
run_id=run_id,
success=False,
error=error,
)
@@ -94,8 +94,21 @@ __all__ = [
'CapabilitiesHandler',
# Performance benchmarking
'PerformanceHandler',
# Comprehensive benchmarking
'BenchmarkHandler',
# Lite agent (BYO-LLM)
'LiteHandler',
# Agent-centric tools (LSP/ACP powered)
'create_agent_centric_tools',
'InteractiveRuntime',
'RuntimeConfig',
'CodeIntelligenceRouter',
'ActionOrchestrator',
# Interactive tools provider (canonical source)
'get_interactive_tools',
'resolve_tool_groups',
'ToolConfig',
'TOOL_GROUPS',
]
def __getattr__(name):
@@ -222,7 +235,39 @@ def __getattr__(name):
elif name == 'PerformanceHandler':
from .performance import PerformanceHandler
return PerformanceHandler
elif name == 'BenchmarkHandler':
from .benchmark import BenchmarkHandler
return BenchmarkHandler
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
# Interactive tools provider (canonical source)
elif name == 'get_interactive_tools':
from .interactive_tools import get_interactive_tools
return get_interactive_tools
elif name == 'resolve_tool_groups':
from .interactive_tools import resolve_tool_groups
return resolve_tool_groups
elif name == 'ToolConfig':
from .interactive_tools import ToolConfig
return ToolConfig
elif name == 'TOOL_GROUPS':
from .interactive_tools import TOOL_GROUPS
return TOOL_GROUPS
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -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",
}
File diff suppressed because it is too large. Load diff
@@ -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,220 @@
"""
ACP health checks for PraisonAI Doctor.
"""
import asyncio
import logging
import os
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(config=None) -> 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(
id="acp_module",
title="ACP Module Available",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="ACP module is available",
metadata={"module": "praisonai.acp"}
)
else:
return CheckResult(
id="acp_module",
title="ACP Module Available",
category=CheckCategory.TOOLS,
status=CheckStatus.WARN,
message="ACP module not found",
remediation="ACP module should be part of praisonai package"
)
except Exception as e:
return CheckResult(
id="acp_module",
title="ACP Module Available",
category=CheckCategory.TOOLS,
status=CheckStatus.FAIL,
message=f"Error checking ACP module: {e}",
metadata={"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(config=None) -> CheckResult:
"""Check if ACP SDK is available."""
try:
import importlib.util
spec = importlib.util.find_spec("acp")
if spec is not None:
return CheckResult(
id="acp_sdk",
title="ACP SDK Available",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="ACP SDK (agent-client-protocol) is installed",
metadata={"package": "agent-client-protocol"}
)
else:
return CheckResult(
id="acp_sdk",
title="ACP SDK Available",
category=CheckCategory.TOOLS,
status=CheckStatus.SKIP,
message="ACP SDK not installed (optional)",
remediation="Install with: pip install agent-client-protocol"
)
except Exception as e:
return CheckResult(
id="acp_sdk",
title="ACP SDK Available",
category=CheckCategory.TOOLS,
status=CheckStatus.FAIL,
message=f"Error checking ACP SDK: {e}",
metadata={"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(config=None) -> CheckResult:
"""Check if ACP server can be imported."""
try:
from praisonai.acp import ACPServer # noqa: F401
return CheckResult(
id="acp_server",
title="ACP Server Import",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="ACP server can be imported",
metadata={"class": "ACPServer"}
)
except ImportError as e:
return CheckResult(
id="acp_server",
title="ACP Server Import",
category=CheckCategory.TOOLS,
status=CheckStatus.WARN,
message=f"ACP server import failed: {e}",
metadata={"error": str(e)}
)
except Exception as e:
return CheckResult(
id="acp_server",
title="ACP Server Import",
category=CheckCategory.TOOLS,
status=CheckStatus.FAIL,
message=f"Error importing ACP server: {e}",
metadata={"error": str(e)}
)
@register_check(
id="acp_config",
title="ACP Configuration",
category=CheckCategory.CONFIG,
description="Check ACP configuration"
)
def check_acp_config(config=None) -> CheckResult:
"""Check ACP configuration."""
acp_conf = {
"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(
id="acp_config",
title="ACP Configuration",
category=CheckCategory.CONFIG,
status=CheckStatus.PASS,
message=f"ACP configured with approval_mode={acp_conf['approval_mode']}",
metadata=acp_conf
)
@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(config=None) -> 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
return ready, error
finally:
await runtime.stop()
ready, error = asyncio.run(_check())
if ready:
return CheckResult(
id="acp_runtime",
title="ACP Runtime Check",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="ACP runtime started successfully",
metadata={"ready": True}
)
else:
return CheckResult(
id="acp_runtime",
title="ACP Runtime Check",
category=CheckCategory.TOOLS,
status=CheckStatus.WARN,
message=f"ACP runtime failed to start: {error}",
metadata={"ready": False, "error": str(error)}
)
except Exception as e:
return CheckResult(
id="acp_runtime",
title="ACP Runtime Check",
category=CheckCategory.TOOLS,
status=CheckStatus.FAIL,
message=f"ACP runtime check failed: {e}",
metadata={"error": str(e)}
)
def register_acp_checks():
"""Register all ACP checks."""
pass
@@ -0,0 +1,231 @@
"""
LSP health checks for PraisonAI Doctor.
"""
import asyncio
import logging
import shutil
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(config=None) -> 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(
id="lsp_module",
title="LSP Module Available",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="LSP module is available",
metadata={"module": "praisonaiagents.lsp"}
)
else:
return CheckResult(
id="lsp_module",
title="LSP Module Available",
category=CheckCategory.TOOLS,
status=CheckStatus.WARN,
message="LSP module not found",
remediation="Install praisonaiagents with LSP support"
)
except Exception as e:
return CheckResult(
id="lsp_module",
title="LSP Module Available",
category=CheckCategory.TOOLS,
status=CheckStatus.FAIL,
message=f"Error checking LSP module: {e}",
metadata={"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(config=None) -> CheckResult:
"""Check if LSP client can be imported."""
try:
from praisonaiagents.lsp import LSPClient # noqa: F401
return CheckResult(
id="lsp_client",
title="LSP Client Import",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="LSP client can be imported",
metadata={"class": "LSPClient"}
)
except ImportError as e:
return CheckResult(
id="lsp_client",
title="LSP Client Import",
category=CheckCategory.TOOLS,
status=CheckStatus.WARN,
message=f"LSP client import failed: {e}",
metadata={"error": str(e)}
)
except Exception as e:
return CheckResult(
id="lsp_client",
title="LSP Client Import",
category=CheckCategory.TOOLS,
status=CheckStatus.FAIL,
message=f"Error importing LSP client: {e}",
metadata={"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(config=None) -> CheckResult:
"""Check if Python language server is available."""
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(
id="lsp_server_python",
title="Python LSP Server",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message=f"Python LSP server available: {found[0]['command']}",
metadata={"servers": found}
)
else:
return CheckResult(
id="lsp_server_python",
title="Python LSP Server",
category=CheckCategory.TOOLS,
status=CheckStatus.WARN,
message="No Python LSP server found",
remediation="Install with: npm install -g pyright OR pip install python-lsp-server"
)
@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(config=None) -> CheckResult:
"""Check if TypeScript language server is available."""
if shutil.which("typescript-language-server"):
return CheckResult(
id="lsp_server_typescript",
title="TypeScript LSP Server",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="TypeScript LSP server available",
metadata={"command": "typescript-language-server"}
)
elif shutil.which("tsserver"):
return CheckResult(
id="lsp_server_typescript",
title="TypeScript LSP Server",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="TypeScript server available (tsserver)",
metadata={"command": "tsserver"}
)
else:
return CheckResult(
id="lsp_server_typescript",
title="TypeScript LSP Server",
category=CheckCategory.TOOLS,
status=CheckStatus.SKIP,
message="TypeScript LSP server not found (optional)",
remediation="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(config=None) -> 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(
id="lsp_runtime",
title="LSP Runtime Check",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="LSP runtime started successfully",
metadata={"ready": True}
)
else:
return CheckResult(
id="lsp_runtime",
title="LSP Runtime Check",
category=CheckCategory.TOOLS,
status=CheckStatus.WARN,
message=f"LSP runtime failed to start: {error}",
metadata={"ready": False, "error": str(error)}
)
except Exception as e:
return CheckResult(
id="lsp_runtime",
title="LSP Runtime Check",
category=CheckCategory.TOOLS,
status=CheckStatus.FAIL,
message=f"LSP runtime check failed: {e}",
metadata={"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,380 @@
"""
Interactive Default Tools Provider for PraisonAI.
This module provides the canonical source of truth for default tools
in interactive modes (TUI and `praison "prompt"`).
Tool Groups:
- `acp`: ACP-powered file operations (create, edit, delete, execute)
- `lsp`: LSP-powered code intelligence (symbols, definitions, references)
- `basic`: Basic file tools (read, list, search)
- `interactive`: Union of all groups (default)
Usage:
from praisonai.cli.features.interactive_tools import (
get_interactive_tools,
resolve_tool_groups,
TOOL_GROUPS,
)
# Get all interactive tools (default)
tools = get_interactive_tools()
# Get specific groups
tools = get_interactive_tools(groups=["acp", "basic"])
# Disable specific groups
tools = get_interactive_tools(disable=["lsp"])
"""
import logging
import os
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional, Set
logger = logging.getLogger(__name__)
# Tool group definitions
TOOL_GROUPS = {
"acp": [
"acp_create_file",
"acp_edit_file",
"acp_delete_file",
"acp_execute_command",
],
"lsp": [
"lsp_list_symbols",
"lsp_find_definition",
"lsp_find_references",
"lsp_get_diagnostics",
],
"basic": [
"read_file",
"write_file",
"list_files",
"execute_command",
"internet_search",
],
}
# Default interactive group includes all
TOOL_GROUPS["interactive"] = (
TOOL_GROUPS["acp"] +
TOOL_GROUPS["lsp"] +
TOOL_GROUPS["basic"]
)
@dataclass
class ToolConfig:
"""Configuration for tool loading."""
workspace: str = field(default_factory=os.getcwd)
enable_acp: bool = True
enable_lsp: bool = True
enable_basic: bool = True
approval_mode: str = "auto" # auto, manual, scoped
lsp_enabled: bool = True
acp_enabled: bool = True
@classmethod
def from_env(cls) -> "ToolConfig":
"""Create config from environment variables."""
config = cls()
# Check env vars for disabling groups
disable_str = os.environ.get("PRAISON_TOOLS_DISABLE", "")
if disable_str:
disabled = [g.strip().lower() for g in disable_str.split(",")]
if "acp" in disabled:
config.enable_acp = False
if "lsp" in disabled:
config.enable_lsp = False
if "basic" in disabled:
config.enable_basic = False
# Check for workspace
workspace = os.environ.get("PRAISON_WORKSPACE", "")
if workspace:
config.workspace = workspace
# Check approval mode
approval = os.environ.get("PRAISON_APPROVAL_MODE", "")
if approval in ("auto", "manual", "scoped"):
config.approval_mode = approval
return config
def resolve_tool_groups(
groups: Optional[List[str]] = None,
disable: Optional[List[str]] = None,
config: Optional[ToolConfig] = None,
) -> Set[str]:
"""
Resolve which tool names should be included based on groups and config.
Args:
groups: Specific groups to include (None = all enabled)
disable: Groups to explicitly disable
config: Tool configuration
Returns:
Set of tool names to include
"""
if config is None:
config = ToolConfig.from_env()
disable = disable or []
# Start with requested groups or all enabled
if groups:
tool_names = set()
for group in groups:
if group in TOOL_GROUPS:
tool_names.update(TOOL_GROUPS[group])
else:
tool_names = set()
if config.enable_acp:
tool_names.update(TOOL_GROUPS["acp"])
if config.enable_lsp:
tool_names.update(TOOL_GROUPS["lsp"])
if config.enable_basic:
tool_names.update(TOOL_GROUPS["basic"])
# Remove disabled groups
for group in disable:
if group in TOOL_GROUPS:
tool_names -= set(TOOL_GROUPS[group])
return tool_names
def _load_basic_tools() -> Dict[str, Callable]:
"""Lazy load basic tools from praisonaiagents."""
tools = {}
try:
from praisonaiagents.tools import read_file
tools["read_file"] = read_file
except ImportError:
logger.debug("read_file not available")
try:
from praisonaiagents.tools import write_file
tools["write_file"] = write_file
except ImportError:
logger.debug("write_file not available")
try:
from praisonaiagents.tools import list_files
tools["list_files"] = list_files
except ImportError:
logger.debug("list_files not available")
try:
from praisonaiagents.tools import execute_command
tools["execute_command"] = execute_command
except ImportError:
logger.debug("execute_command not available")
try:
from praisonaiagents.tools import internet_search
tools["internet_search"] = internet_search
except ImportError:
logger.debug("internet_search not available")
return tools
def _load_acp_tools(config: ToolConfig) -> Dict[str, Callable]:
"""Lazy load ACP tools with runtime initialization."""
tools = {}
try:
from .interactive_runtime import InteractiveRuntime, RuntimeConfig
from .agent_tools import create_agent_centric_tools
# Create runtime config
runtime_config = RuntimeConfig(
workspace=config.workspace,
lsp_enabled=config.lsp_enabled,
acp_enabled=config.acp_enabled,
approval_mode=config.approval_mode,
)
# Create runtime (lazy - doesn't start until needed)
runtime = InteractiveRuntime(runtime_config)
# Get all agent-centric tools
all_tools = create_agent_centric_tools(runtime)
# Map by name
for tool in all_tools:
name = tool.__name__
if name.startswith("acp_"):
tools[name] = tool
logger.debug(f"Loaded {len(tools)} ACP tools")
except ImportError as e:
logger.debug(f"ACP tools not available: {e}")
except Exception as e:
logger.warning(f"Error loading ACP tools: {e}")
return tools
def _load_lsp_tools(config: ToolConfig) -> Dict[str, Callable]:
"""Lazy load LSP tools with runtime initialization."""
tools = {}
try:
from .interactive_runtime import InteractiveRuntime, RuntimeConfig
from .agent_tools import create_agent_centric_tools
# Create runtime config
runtime_config = RuntimeConfig(
workspace=config.workspace,
lsp_enabled=config.lsp_enabled,
acp_enabled=config.acp_enabled,
approval_mode=config.approval_mode,
)
# Create runtime
runtime = InteractiveRuntime(runtime_config)
# Get all agent-centric tools
all_tools = create_agent_centric_tools(runtime)
# Map by name
for tool in all_tools:
name = tool.__name__
if name.startswith("lsp_"):
tools[name] = tool
logger.debug(f"Loaded {len(tools)} LSP tools")
except ImportError as e:
logger.debug(f"LSP tools not available: {e}")
except Exception as e:
logger.warning(f"Error loading LSP tools: {e}")
return tools
def get_interactive_tools(
groups: Optional[List[str]] = None,
disable: Optional[List[str]] = None,
config: Optional[ToolConfig] = None,
workspace: Optional[str] = None,
) -> List[Callable]:
"""
Get the default interactive tools for TUI and prompt modes.
This is the canonical source of truth for interactive tool defaults.
Both `praisonai tui launch` and `praison "prompt"` should use this.
Args:
groups: Specific tool groups to include (default: all enabled)
disable: Tool groups to disable (e.g., ["acp", "lsp"])
config: Tool configuration (created from env if not provided)
workspace: Override workspace path
Returns:
List of tool callables
Example:
# Default (all tools)
tools = get_interactive_tools()
# Disable ACP tools
tools = get_interactive_tools(disable=["acp"])
# Only basic tools
tools = get_interactive_tools(groups=["basic"])
"""
if config is None:
config = ToolConfig.from_env()
if workspace:
config.workspace = workspace
# Resolve which tools to include
tool_names = resolve_tool_groups(groups, disable, config)
# Load tools by group
all_tools: Dict[str, Callable] = {}
# Load basic tools (always available, no runtime needed)
if config.enable_basic and not (disable and "basic" in disable):
basic_tools = _load_basic_tools()
all_tools.update(basic_tools)
# Load ACP tools (requires runtime)
if config.enable_acp and not (disable and "acp" in disable):
acp_tools = _load_acp_tools(config)
all_tools.update(acp_tools)
# Load LSP tools (requires runtime)
if config.enable_lsp and not (disable and "lsp" in disable):
lsp_tools = _load_lsp_tools(config)
all_tools.update(lsp_tools)
# Filter to requested tools
result = []
for name in sorted(tool_names): # Deterministic ordering
if name in all_tools:
result.append(all_tools[name])
logger.debug(f"Loaded {len(result)} interactive tools: {[t.__name__ for t in result]}")
return result
def merge_tool_overrides(
default_tools: List[Callable],
add_tools: Optional[List[Callable]] = None,
remove_names: Optional[List[str]] = None,
) -> List[Callable]:
"""
Merge tool overrides with default tools.
Args:
default_tools: Base list of tools
add_tools: Tools to add
remove_names: Tool names to remove
Returns:
Merged tool list
"""
# Build name -> tool map
tool_map = {t.__name__: t for t in default_tools}
# Remove specified tools
if remove_names:
for name in remove_names:
tool_map.pop(name, None)
# Add new tools
if add_tools:
for tool in add_tools:
tool_map[tool.__name__] = tool
return list(tool_map.values())
def get_tool_group_names() -> Dict[str, List[str]]:
"""Get available tool groups and their tool names."""
return TOOL_GROUPS.copy()
def print_tool_summary(tools: List[Callable]) -> None:
"""Print a summary of loaded tools (for debugging)."""
acp_count = sum(1 for t in tools if t.__name__.startswith("acp_"))
lsp_count = sum(1 for t in tools if t.__name__.startswith("lsp_"))
basic_count = len(tools) - acp_count - lsp_count
print(f"Interactive tools loaded: {len(tools)} total")
print(f" - ACP tools: {acp_count}")
print(f" - LSP tools: {lsp_count}")
print(f" - Basic tools: {basic_count}")
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more