Compare commits

...
38 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
Mervin Praison 9f2c4d10b5 Release v2.8.9 2025-12-31 14:05:30 +00:00
Mervin Praison 42a86c831f Release v2.8.8 2025-12-31 12:46:10 +00:00
Mervin Praison 48443b11d6 Release v2.8.7 2025-12-31 12:28:44 +00:00
Mervin Praison 71745aa14a Release v2.8.6 2025-12-31 12:27:20 +00:00
Mervin Praison 4f309ec16d Release v2.8.5 2025-12-31 12:00:29 +00:00
Mervin Praison e48afc8993 Release v2.8.4 2025-12-31 11:39:37 +00:00
192 changed files with 39276 additions and 1054 deletions

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
"""
Auto-generated API server for PraisonAI agents.
"""
from flask import Flask, request, jsonify
from flask_cors import CORS
from praisonai import PraisonAI
import os
app = Flask(__name__)
# CORS configuration
CORS(app)
# Authentication
AUTH_ENABLED = False
AUTH_TOKEN = None
def check_auth():
"""Check authentication if enabled."""
if not AUTH_ENABLED:
return True
token = request.headers.get('Authorization', '').replace('Bearer ', '')
return token == AUTH_TOKEN
@app.route('/health', methods=['GET'])
def health():
"""Health check endpoint."""
return jsonify({"status": "ok", "service": "praisonai-api"})
@app.route('/chat', methods=['POST'])
def chat():
"""Chat endpoint for agent interaction."""
if not check_auth():
return jsonify({"error": "Unauthorized"}), 401
data = request.get_json()
if not data or 'message' not in data:
return jsonify({"error": "Message required"}), 400
try:
praisonai = PraisonAI(agent_file="agents.yaml")
result = praisonai.run()
return jsonify({
"response": result,
"status": "success"
})
except Exception as e:
return jsonify({
"error": str(e),
"status": "error"
}), 500
@app.route('/agents', methods=['GET'])
def list_agents():
"""List available agents."""
if not check_auth():
return jsonify({"error": "Unauthorized"}), 401
return jsonify({
"agents": ["default"],
"agent_file": "agents.yaml"
})
if __name__ == '__main__':
app.run(
host='0.0.0.0',
port=8080,
debug=False
)
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.8.3" \
"praisonai>=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.3" \
"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.3" \
"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
+137
View File
@@ -0,0 +1,137 @@
# ACP (Agent Client Protocol) Examples
This directory contains examples for using PraisonAI with the Agent Client Protocol (ACP).
## What is ACP?
The Agent Client Protocol (ACP) is a standardized JSON-RPC 2.0 protocol that allows code editors and IDEs to communicate with AI coding agents. It enables seamless integration between PraisonAI and editors like:
- **Zed**
- **JetBrains IDEs** (IntelliJ, PyCharm, WebStorm, etc.)
- **VSCode** (via extensions)
- **Toad**
## Quick Start
### CLI Usage
```bash
# Start ACP server with defaults
praisonai acp
# Enable debug logging
praisonai acp --debug
# Allow file writes
praisonai acp --allow-write
# Use a specific model
praisonai acp -m gpt-4o
# Resume last session
praisonai acp --resume --last
```
### Python API
```python
from praisonai.acp import serve
serve(
workspace=".",
debug=True,
allow_write=True,
)
```
## Editor Configuration
### Zed
Add to `~/.config/zed/settings.json`:
```json
{
"agent_servers": {
"PraisonAI": {
"command": "praisonai",
"args": ["acp"],
"env": {}
}
}
}
```
### JetBrains
Add to `~/.jetbrains/acp.json`:
```json
{
"agent_servers": {
"PraisonAI": {
"command": "praisonai",
"args": ["acp"],
"env": {}
}
}
}
```
### Toad
```bash
toad acp "praisonai acp"
```
## Examples
- `basic_acp_server.py` - Simple ACP server with default settings
- `custom_agent_acp.py` - ACP server with a custom agent
## CLI Options
| Option | Description |
|--------|-------------|
| `-w, --workspace` | Workspace root directory |
| `-a, --agent` | Agent name or config file |
| `-m, --model` | LLM model to use |
| `-r, --resume` | Resume session by ID |
| `--last` | Resume the last session |
| `--approve` | Approval mode: manual, auto, scoped |
| `--read-only` | Read-only mode (default) |
| `--allow-write` | Allow file writes |
| `--allow-shell` | Allow shell commands |
| `--debug` | Enable debug logging |
## Installation
```bash
pip install praisonai[acp]
```
## Protocol Details
ACP uses JSON-RPC 2.0 over stdio:
- **stdin**: Receives JSON-RPC requests from the client
- **stdout**: Sends JSON-RPC responses to the client
- **stderr**: Debug/log output (never pollutes stdout)
### Supported Methods
**Agent Methods (client → agent):**
- `initialize` - Negotiate protocol version and capabilities
- `authenticate` - Optional authentication
- `session/new` - Create a new session
- `session/load` - Load an existing session
- `session/prompt` - Send user message
- `session/cancel` - Cancel ongoing operations
- `session/set_mode` - Change operating mode
**Client Methods (agent → client):**
- `session/update` - Send progress updates
- `session/request_permission` - Request user approval
- `fs/read_text_file` - Read file contents
- `fs/write_text_file` - Write file contents
- `terminal/create` - Create terminal
- `terminal/output` - Get terminal output
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""
Basic ACP Server Example
This example demonstrates how to run PraisonAI as an ACP server
that can be connected to by IDEs like Zed, JetBrains, VSCode, or Toad.
Usage:
# Run from command line
praisonai acp
# Or run this script directly
python basic_acp_server.py
"""
from praisonai.acp import serve
if __name__ == "__main__":
# Start ACP server with default settings
# This will listen on stdin/stdout for JSON-RPC messages
serve(
workspace=".", # Current directory as workspace
agent="default", # Use default agent
debug=False, # Set to True for debug logging to stderr
read_only=True, # Safe by default
approval_mode="manual", # Require approval for actions
)
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""
Custom Agent ACP Server Example
This example shows how to run a custom PraisonAI agent as an ACP server.
Usage:
python custom_agent_acp.py
"""
from praisonai.acp import serve, ACPServer, ACPConfig
from praisonaiagents import Agent
# Create a custom agent
agent = Agent(
name="CodeAssistant",
instructions="""You are an expert coding assistant. You help users:
- Write clean, efficient code
- Debug issues
- Explain complex concepts
- Review code for best practices
Always provide clear explanations with your code suggestions.""",
model="gpt-4o-mini",
)
if __name__ == "__main__":
# Create config
config = ACPConfig(
workspace=".",
debug=True,
allow_write=True, # Allow file modifications
approval_mode="manual",
)
# Create server with custom agent
server = ACPServer(config=config, agent=agent)
# Run the server
serve(
workspace=".",
debug=True,
allow_write=True,
approval_mode="manual",
)
@@ -0,0 +1,135 @@
"""
Agent-Centric Tools Example
This example demonstrates how to use LSP/ACP-powered tools that make
the Agent the central orchestrator for file operations.
How to run:
export OPENAI_API_KEY=your_key
python examples/agent_tools/agent_centric_example.py
Prerequisites:
pip install praisonai praisonaiagents
"""
import asyncio
import os
import sys
import tempfile
import shutil
# Add paths for development
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/praisonai'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/praisonai-agents'))
async def main():
print("=" * 60)
print("AGENT-CENTRIC TOOLS EXAMPLE")
print("=" * 60)
print()
# Create temporary workspace
workspace = tempfile.mkdtemp(prefix="praisonai_example_")
print(f"Workspace: {workspace}")
print()
try:
# Import after path setup
from praisonai.cli.features import (
create_agent_centric_tools,
InteractiveRuntime,
RuntimeConfig
)
from praisonaiagents import Agent
# 1. Create runtime with ACP enabled
print("1. Creating InteractiveRuntime with ACP...")
config = RuntimeConfig(
workspace=workspace,
lsp_enabled=False, # Skip LSP for this example
acp_enabled=True,
approval_mode="auto" # Auto-approve for demo
)
runtime = InteractiveRuntime(config)
status = await runtime.start()
print(f" ACP ready: {runtime.acp_ready}")
print(f" Read-only: {runtime.read_only}")
print()
# 2. Create agent-centric tools
print("2. Creating agent-centric tools...")
tools = create_agent_centric_tools(runtime)
print(f" Tools created: {len(tools)}")
for tool in tools:
print(f" - {tool.__name__}")
print()
# 3. Create Agent with ACP-powered tools
print("3. Creating Agent with ACP-powered tools...")
agent = Agent(
name="FileAgent",
instructions="""You help create and manage files.
Use acp_create_file to create files.
Use read_file to read files.
Use list_files to list directory contents.""",
tools=tools,
verbose=True
)
print()
# 4. Ask agent to create a file
print("4. Asking agent to create a Python file...")
print("-" * 40)
result = agent.start(
"Create a Python file called calculator.py with add and subtract functions"
)
print("-" * 40)
print(f"Agent response: {result[:200]}..." if len(str(result)) > 200 else f"Agent response: {result}")
print()
# 5. Verify file was created
print("5. Verifying file creation...")
calc_path = os.path.join(workspace, "calculator.py")
if os.path.exists(calc_path):
print(f" ✓ File created at {calc_path}")
with open(calc_path) as f:
content = f.read()
print(f" Content preview:")
for line in content.split('\n')[:10]:
print(f" {line}")
else:
# Check what files exist
files = os.listdir(workspace)
print(f" Files in workspace: {files}")
for f in files:
fp = os.path.join(workspace, f)
if os.path.isfile(fp):
print(f" ✓ Found: {f}")
print()
# 6. Ask agent to read the file
print("6. Asking agent to read the file...")
print("-" * 40)
result2 = agent.start("Read the calculator.py file and tell me what functions it has")
print("-" * 40)
print(f"Agent response: {result2[:300]}..." if len(str(result2)) > 300 else f"Agent response: {result2}")
print()
# Cleanup
await runtime.stop()
finally:
# Clean up workspace
shutil.rmtree(workspace, ignore_errors=True)
print(f"Cleaned up workspace: {workspace}")
print()
print("=" * 60)
print("EXAMPLE COMPLETE")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())
+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" },
+1
View File
@@ -0,0 +1 @@
chainlit.md
+137
View File
@@ -0,0 +1,137 @@
# ACP (Agent Client Protocol) Examples
This directory contains examples for using PraisonAI with the Agent Client Protocol (ACP).
## What is ACP?
The Agent Client Protocol (ACP) is a standardized JSON-RPC 2.0 protocol that allows code editors and IDEs to communicate with AI coding agents. It enables seamless integration between PraisonAI and editors like:
- **Zed**
- **JetBrains IDEs** (IntelliJ, PyCharm, WebStorm, etc.)
- **VSCode** (via extensions)
- **Toad**
## Quick Start
### CLI Usage
```bash
# Start ACP server with defaults
praisonai acp
# Enable debug logging
praisonai acp --debug
# Allow file writes
praisonai acp --allow-write
# Use a specific model
praisonai acp -m gpt-4o
# Resume last session
praisonai acp --resume --last
```
### Python API
```python
from praisonai.acp import serve
serve(
workspace=".",
debug=True,
allow_write=True,
)
```
## Editor Configuration
### Zed
Add to `~/.config/zed/settings.json`:
```json
{
"agent_servers": {
"PraisonAI": {
"command": "praisonai",
"args": ["acp"],
"env": {}
}
}
}
```
### JetBrains
Add to `~/.jetbrains/acp.json`:
```json
{
"agent_servers": {
"PraisonAI": {
"command": "praisonai",
"args": ["acp"],
"env": {}
}
}
}
```
### Toad
```bash
toad acp "praisonai acp"
```
## Examples
- `basic_acp_server.py` - Simple ACP server with default settings
- `custom_agent_acp.py` - ACP server with a custom agent
## CLI Options
| Option | Description |
|--------|-------------|
| `-w, --workspace` | Workspace root directory |
| `-a, --agent` | Agent name or config file |
| `-m, --model` | LLM model to use |
| `-r, --resume` | Resume session by ID |
| `--last` | Resume the last session |
| `--approve` | Approval mode: manual, auto, scoped |
| `--read-only` | Read-only mode (default) |
| `--allow-write` | Allow file writes |
| `--allow-shell` | Allow shell commands |
| `--debug` | Enable debug logging |
## Installation
```bash
pip install praisonai[acp]
```
## Protocol Details
ACP uses JSON-RPC 2.0 over stdio:
- **stdin**: Receives JSON-RPC requests from the client
- **stdout**: Sends JSON-RPC responses to the client
- **stderr**: Debug/log output (never pollutes stdout)
### Supported Methods
**Agent Methods (client → agent):**
- `initialize` - Negotiate protocol version and capabilities
- `authenticate` - Optional authentication
- `session/new` - Create a new session
- `session/load` - Load an existing session
- `session/prompt` - Send user message
- `session/cancel` - Cancel ongoing operations
- `session/set_mode` - Change operating mode
**Client Methods (agent → client):**
- `session/update` - Send progress updates
- `session/request_permission` - Request user approval
- `fs/read_text_file` - Read file contents
- `fs/write_text_file` - Write file contents
- `terminal/create` - Create terminal
- `terminal/output` - Get terminal output
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""
Basic ACP Server Example
This example demonstrates how to run PraisonAI as an ACP server
that can be connected to by IDEs like Zed, JetBrains, VSCode, or Toad.
Usage:
# Run from command line
praisonai acp
# Or run this script directly
python basic_acp_server.py
"""
from praisonai.acp import serve
if __name__ == "__main__":
# Start ACP server with default settings
# This will listen on stdin/stdout for JSON-RPC messages
serve(
workspace=".", # Current directory as workspace
agent="default", # Use default agent
debug=False, # Set to True for debug logging to stderr
read_only=True, # Safe by default
approval_mode="manual", # Require approval for actions
)
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""
Custom Agent ACP Server Example
This example shows how to run a custom PraisonAI agent as an ACP server.
Usage:
python custom_agent_acp.py
"""
from praisonai.acp import serve, ACPServer, ACPConfig
from praisonaiagents import Agent
# Create a custom agent
agent = Agent(
name="CodeAssistant",
instructions="""You are an expert coding assistant. You help users:
- Write clean, efficient code
- Debug issues
- Explain complex concepts
- Review code for best practices
Always provide clear explanations with your code suggestions.""",
model="gpt-4o-mini",
)
if __name__ == "__main__":
# Create config
config = ACPConfig(
workspace=".",
debug=True,
allow_write=True, # Allow file modifications
approval_mode="manual",
)
# Create server with custom agent
server = ACPServer(config=config, agent=agent)
# Run the server
serve(
workspace=".",
debug=True,
allow_write=True,
approval_mode="manual",
)
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.8.3.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.8.3.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/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()
+54
View File
@@ -0,0 +1,54 @@
"""
Agent Client Protocol (ACP) support for PraisonAI.
This module provides ACP server functionality that allows IDEs/editors
(Zed, JetBrains, VSCode, Toad) to connect to PraisonAI agents.
Usage:
# CLI
praisonai acp [OPTIONS]
# Python API
from praisonai.acp import serve
serve(workspace=".", agent="default")
"""
import importlib.util
__all__ = [
"serve",
"ACPServer",
"ACPSession",
"ACPConfig",
]
# Lazy imports for performance
def __getattr__(name: str):
"""Lazy import ACP components."""
if name == "serve":
from .server import serve
return serve
if name == "ACPServer":
from .server import ACPServer
return ACPServer
if name == "ACPSession":
from .session import ACPSession
return ACPSession
if name == "ACPConfig":
from .config import ACPConfig
return ACPConfig
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def _check_acp_available() -> bool:
"""Check if agent-client-protocol package is installed."""
return importlib.util.find_spec("acp") is not None
def _get_install_instructions() -> str:
"""Get installation instructions for ACP support."""
return (
"ACP support requires the agent-client-protocol package.\n"
"Install with: pip install praisonai[acp]\n"
"Or: pip install agent-client-protocol"
)
+159
View File
@@ -0,0 +1,159 @@
"""
ACP configuration management.
Handles configuration precedence:
1. CLI flags (highest)
2. Environment variables
3. Config file (~/.praison/config.yaml)
4. Defaults (lowest)
"""
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
@dataclass
class ACPConfig:
"""Configuration for ACP server."""
# Workspace settings
workspace: Path = field(default_factory=Path.cwd)
# Agent settings
agent: str = "default"
agents_config: Optional[Path] = None
router_enabled: bool = False
model: Optional[str] = None
# Session settings
resume_session: Optional[str] = None
resume_last: bool = False
# Permission settings
read_only: bool = True
allow_write: bool = False
allow_shell: bool = False
allow_network: bool = False
approval_mode: str = "manual" # manual, auto, scoped
# Workspace boundaries
allowed_paths: List[Path] = field(default_factory=list)
# Debug settings
debug: bool = False
# Profile
profile: Optional[str] = None
def __post_init__(self):
"""Validate and normalize configuration."""
if isinstance(self.workspace, str):
self.workspace = Path(self.workspace).resolve()
if self.agents_config and isinstance(self.agents_config, str):
self.agents_config = Path(self.agents_config)
# Set up default allowed paths
if not self.allowed_paths:
self.allowed_paths = [
self.workspace,
Path.home() / ".praison",
]
# Normalize allowed paths
self.allowed_paths = [
Path(p).resolve() if isinstance(p, str) else p.resolve()
for p in self.allowed_paths
]
@classmethod
def from_env(cls) -> "ACPConfig":
"""Create config from environment variables."""
return cls(
workspace=Path(os.environ.get("PRAISONAI_WORKSPACE", ".")).resolve(),
agent=os.environ.get("PRAISONAI_AGENT", "default"),
model=os.environ.get("PRAISONAI_MODEL") or os.environ.get("OPENAI_MODEL"),
debug=os.environ.get("PRAISONAI_DEBUG", "").lower() in ("1", "true", "yes"),
read_only=os.environ.get("PRAISONAI_READ_ONLY", "true").lower() in ("1", "true", "yes"),
approval_mode=os.environ.get("PRAISONAI_APPROVAL_MODE", "manual"),
)
@classmethod
def from_file(cls, path: Optional[Path] = None) -> "ACPConfig":
"""Load config from YAML file."""
if path is None:
path = Path.home() / ".praison" / "config.yaml"
if not path.exists():
return cls()
try:
import yaml
with open(path) as f:
data = yaml.safe_load(f) or {}
acp_config = data.get("acp", {})
return cls(
workspace=Path(acp_config.get("workspace", ".")).resolve(),
agent=acp_config.get("agent", "default"),
model=acp_config.get("model"),
debug=acp_config.get("debug", False),
read_only=acp_config.get("read_only", True),
approval_mode=acp_config.get("approval_mode", "manual"),
)
except Exception:
return cls()
@classmethod
def merge(cls, *configs: "ACPConfig") -> "ACPConfig":
"""Merge multiple configs, later configs override earlier ones."""
result = cls()
for config in configs:
for field_name in [
"workspace", "agent", "agents_config", "router_enabled",
"model", "resume_session", "resume_last", "read_only",
"allow_write", "allow_shell", "allow_network", "approval_mode",
"debug", "profile"
]:
value = getattr(config, field_name)
# Only override if value is not default/None
if value is not None and value != getattr(cls(), field_name, None):
setattr(result, field_name, value)
return result
def is_path_allowed(self, path: Path) -> bool:
"""Check if a path is within allowed boundaries."""
path = Path(path).resolve()
return any(
path == allowed or allowed in path.parents
for allowed in self.allowed_paths
)
def can_write(self) -> bool:
"""Check if write operations are allowed."""
return not self.read_only or self.allow_write
def can_execute_shell(self) -> bool:
"""Check if shell commands are allowed."""
return self.allow_shell
def to_dict(self) -> Dict[str, Any]:
"""Convert config to dictionary."""
return {
"workspace": str(self.workspace),
"agent": self.agent,
"agents_config": str(self.agents_config) if self.agents_config else None,
"router_enabled": self.router_enabled,
"model": self.model,
"resume_session": self.resume_session,
"resume_last": self.resume_last,
"read_only": self.read_only,
"allow_write": self.allow_write,
"allow_shell": self.allow_shell,
"allow_network": self.allow_network,
"approval_mode": self.approval_mode,
"debug": self.debug,
"profile": self.profile,
}
+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
+587
View File
@@ -0,0 +1,587 @@
"""
ACP Server implementation for PraisonAI.
This module implements the Agent Client Protocol server that allows
IDEs/editors to communicate with PraisonAI agents.
"""
import asyncio
import logging
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from .config import ACPConfig
from .session import ACPSession, SessionStore
# Configure logging to stderr only (stdout reserved for JSON-RPC)
logger = logging.getLogger(__name__)
def _setup_stderr_logging(debug: bool = False) -> None:
"""Configure logging to stderr only."""
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s: %(message)s"
))
root_logger = logging.getLogger("praisonai.acp")
root_logger.handlers.clear()
root_logger.addHandler(handler)
root_logger.setLevel(logging.DEBUG if debug else logging.INFO)
class ACPServer:
"""
ACP Server that exposes PraisonAI agents to IDE clients.
Implements the Agent Client Protocol (JSON-RPC 2.0 over stdio).
"""
def __init__(
self,
config: Optional[ACPConfig] = None,
agent: Optional[Any] = None,
agents: Optional[Any] = None,
):
"""
Initialize ACP server.
Args:
config: ACP configuration
agent: Optional pre-configured Agent instance
agents: Optional pre-configured PraisonAIAgents instance
"""
self.config = config or ACPConfig()
self._agent = agent
self._agents = agents
self._session_store = SessionStore()
self._sessions: Dict[str, ACPSession] = {}
self._client = None # ACP client connection
self._cancelled_sessions: set = set()
_setup_stderr_logging(self.config.debug)
def _get_agent(self):
"""Lazy load or create agent."""
if self._agent is not None:
return self._agent
if self._agents is not None:
return self._agents
# Create default agent
try:
from praisonaiagents import Agent
self._agent = Agent(
name="PraisonAI",
instructions="You are a helpful AI coding assistant.",
model=self.config.model or "gpt-4o-mini",
)
return self._agent
except ImportError:
logger.error("praisonaiagents not installed")
raise RuntimeError("praisonaiagents package required")
def on_connect(self, client) -> None:
"""Called when client connects."""
self._client = client
logger.info("Client connected")
async def initialize(
self,
protocol_version: int,
client_capabilities: Optional[Dict[str, Any]] = None,
client_info: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> Dict[str, Any]:
"""
Handle initialize request.
Negotiate protocol version and exchange capabilities.
"""
logger.info(f"Initialize: protocol_version={protocol_version}, client_info={client_info}")
# Import here to avoid circular imports and check availability
try:
from acp import PROTOCOL_VERSION
except ImportError:
PROTOCOL_VERSION = 1
return {
"protocolVersion": min(protocol_version, PROTOCOL_VERSION),
"agentCapabilities": {
"loadSession": True,
"promptCapabilities": {
"image": False,
"audio": False,
"embeddedContext": True,
},
"mcpCapabilities": {
"http": False,
"sse": False,
},
},
"agentInfo": {
"name": "praisonai",
"title": "PraisonAI",
"version": self._get_version(),
},
"authMethods": [],
}
async def authenticate(self, method_id: str, **kwargs: Any) -> Optional[Dict[str, Any]]:
"""Handle authenticate request (optional)."""
logger.info(f"Authenticate: method_id={method_id}")
return {}
async def new_session(
self,
cwd: str,
mcp_servers: List[Dict[str, Any]],
**kwargs: Any,
) -> Dict[str, Any]:
"""
Create a new conversation session.
Args:
cwd: Working directory for the session
mcp_servers: List of MCP server configurations
"""
logger.info(f"New session: cwd={cwd}")
workspace = Path(cwd).resolve()
session = ACPSession.create(
workspace=workspace,
agent_id=self.config.agent,
)
session.mcp_servers = mcp_servers
self._sessions[session.session_id] = session
self._session_store.save(session)
logger.info(f"Created session: {session.session_id}")
return {
"sessionId": session.session_id,
"modes": self._get_available_modes(),
}
async def load_session(
self,
cwd: str,
mcp_servers: List[Dict[str, Any]],
session_id: str,
**kwargs: Any,
) -> Optional[Dict[str, Any]]:
"""
Load an existing session.
Replays conversation history via session/update notifications.
"""
logger.info(f"Load session: session_id={session_id}")
# Try to load from store
session = self._session_store.load(session_id)
if session is None:
logger.warning(f"Session not found: {session_id}")
return None
# Update workspace and MCP servers
session.workspace = Path(cwd).resolve()
session.mcp_servers = mcp_servers
session.update_activity()
self._sessions[session_id] = session
# Replay conversation history
await self._replay_session_history(session)
return {}
async def list_sessions(
self,
cursor: Optional[str] = None,
cwd: Optional[str] = None,
**kwargs: Any,
) -> Dict[str, Any]:
"""List available sessions."""
sessions = self._session_store.list_sessions()
# Filter by cwd if provided
if cwd:
cwd_path = Path(cwd).resolve()
sessions = [s for s in sessions if s.workspace == cwd_path]
return {
"sessions": [
{
"sessionId": s.session_id,
"createdAt": s.created_at,
"lastActivity": s.last_activity,
}
for s in sessions
],
}
async def set_session_mode(
self,
mode_id: str,
session_id: str,
**kwargs: Any,
) -> Optional[Dict[str, Any]]:
"""Set the operating mode for a session."""
logger.info(f"Set session mode: session_id={session_id}, mode_id={mode_id}")
session = self._sessions.get(session_id)
if session:
session.mode = mode_id
self._session_store.save(session)
return {}
async def set_session_model(
self,
model_id: str,
session_id: str,
**kwargs: Any,
) -> Optional[Dict[str, Any]]:
"""Set the LLM model for a session."""
logger.info(f"Set session model: session_id={session_id}, model_id={model_id}")
session = self._sessions.get(session_id)
if session:
session.model = model_id
self._session_store.save(session)
return {}
async def prompt(
self,
prompt: List[Dict[str, Any]],
session_id: str,
**kwargs: Any,
) -> Dict[str, Any]:
"""
Handle user prompt.
This is the main interaction method where the user sends a message
and the agent processes it.
"""
logger.info(f"Prompt: session_id={session_id}")
session = self._sessions.get(session_id)
if session is None:
logger.error(f"Session not found: {session_id}")
return {"stopReason": "refusal"}
# Check if cancelled
if session_id in self._cancelled_sessions:
self._cancelled_sessions.discard(session_id)
return {"stopReason": "cancelled"}
# Extract text from prompt blocks
user_message = self._extract_prompt_text(prompt)
session.add_message("user", user_message)
try:
# Get agent response
agent = self._get_agent()
# Send thinking update
await self._send_thought(session_id, "Processing your request...")
# Generate response
response = await self._generate_response(agent, user_message, session)
# Send response as agent message chunks
await self._send_agent_message(session_id, response)
# Save session
session.add_message("assistant", response)
self._session_store.save(session)
return {"stopReason": "end_turn"}
except asyncio.CancelledError:
return {"stopReason": "cancelled"}
except Exception as e:
logger.exception(f"Error processing prompt: {e}")
await self._send_agent_message(session_id, f"Error: {str(e)}")
return {"stopReason": "refusal"}
async def fork_session(
self,
cwd: str,
session_id: str,
mcp_servers: Optional[List[Dict[str, Any]]] = None,
**kwargs: Any,
) -> Dict[str, Any]:
"""Fork an existing session."""
logger.info(f"Fork session: session_id={session_id}")
original = self._sessions.get(session_id)
if original is None:
original = self._session_store.load(session_id)
if original is None:
# Create new session if original not found
return await self.new_session(cwd, mcp_servers or [])
# Create forked session
forked = ACPSession.create(
workspace=Path(cwd).resolve(),
agent_id=original.agent_id,
)
forked.messages = original.messages.copy()
forked.mcp_servers = mcp_servers or original.mcp_servers
self._sessions[forked.session_id] = forked
self._session_store.save(forked)
return {"sessionId": forked.session_id}
async def resume_session(
self,
cwd: str,
session_id: str,
mcp_servers: Optional[List[Dict[str, Any]]] = None,
**kwargs: Any,
) -> Dict[str, Any]:
"""Resume an existing session."""
return await self.load_session(cwd, mcp_servers or [], session_id)
async def cancel(self, session_id: str, **kwargs: Any) -> None:
"""Cancel ongoing operations for a session."""
logger.info(f"Cancel: session_id={session_id}")
self._cancelled_sessions.add(session_id)
async def ext_method(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
"""Handle extension method calls."""
logger.info(f"Extension method: {method}")
return {"error": f"Unknown extension method: {method}"}
async def ext_notification(self, method: str, params: Dict[str, Any]) -> None:
"""Handle extension notifications."""
logger.info(f"Extension notification: {method}")
# Helper methods
def _get_version(self) -> str:
"""Get PraisonAI version."""
try:
from praisonai.version import __version__
return __version__
except ImportError:
return "0.0.0"
def _get_available_modes(self) -> Optional[List[Dict[str, Any]]]:
"""Get available operating modes."""
return [
{
"id": "manual",
"name": "Manual",
"description": "Requires approval for all actions",
},
{
"id": "auto",
"name": "Auto",
"description": "Auto-approve within workspace",
},
]
def _extract_prompt_text(self, prompt: List[Dict[str, Any]]) -> str:
"""Extract text content from prompt blocks."""
texts = []
for block in prompt:
if isinstance(block, dict):
block_type = block.get("type", "")
if block_type == "text":
texts.append(block.get("text", ""))
elif block_type == "resource":
resource = block.get("resource", {})
if "text" in resource:
texts.append(f"[File: {resource.get('uri', 'unknown')}]\n{resource['text']}")
return "\n".join(texts)
async def _generate_response(
self,
agent: Any,
message: str,
session: ACPSession,
) -> str:
"""Generate agent response."""
try:
# Try async chat first
if hasattr(agent, "achat"):
response = await agent.achat(message)
elif hasattr(agent, "chat"):
# Run sync chat in executor
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, agent.chat, message)
else:
response = "Agent does not support chat interface"
return str(response) if response else ""
except Exception as e:
logger.exception(f"Error generating response: {e}")
return f"Error generating response: {str(e)}"
async def _send_agent_message(self, session_id: str, text: str) -> None:
"""Send agent message chunk to client."""
if self._client is None:
return
try:
from acp import update_agent_message_text
update = update_agent_message_text(text)
await self._client.session_update(session_id=session_id, update=update)
except ImportError:
# Fallback without ACP SDK
logger.debug(f"Agent message: {text[:100]}...")
async def _send_thought(self, session_id: str, text: str) -> None:
"""Send agent thought to client."""
if self._client is None:
return
try:
from acp import update_agent_thought_text
update = update_agent_thought_text(text)
await self._client.session_update(session_id=session_id, update=update)
except ImportError:
logger.debug(f"Agent thought: {text}")
async def _replay_session_history(self, session: ACPSession) -> None:
"""Replay session history to client."""
if self._client is None:
return
try:
from acp import update_user_message_text, update_agent_message_text
for msg in session.messages:
role = msg.get("role", "")
content = msg.get("content", "")
if role == "user":
update = update_user_message_text(str(content))
elif role == "assistant":
update = update_agent_message_text(str(content))
else:
continue
await self._client.session_update(
session_id=session.session_id,
update=update,
)
except ImportError:
logger.debug("Skipping history replay (ACP SDK not available)")
async def _run_server(config: ACPConfig) -> None:
"""Run the ACP server."""
try:
from acp import Agent as ACPAgent, run_agent
except ImportError:
logger.error(
"agent-client-protocol package not installed.\n"
"Install with: pip install praisonai[acp]\n"
"Or: pip install agent-client-protocol"
)
sys.exit(1)
server = ACPServer(config=config)
# Create ACP-compatible agent wrapper
class PraisonACPAgent(ACPAgent):
def on_connect(self, conn):
server.on_connect(conn)
async def initialize(self, protocol_version, client_capabilities=None, client_info=None, **kwargs):
return await server.initialize(protocol_version, client_capabilities, client_info, **kwargs)
async def authenticate(self, method_id, **kwargs):
return await server.authenticate(method_id, **kwargs)
async def new_session(self, cwd, mcp_servers, **kwargs):
return await server.new_session(cwd, mcp_servers, **kwargs)
async def load_session(self, cwd, mcp_servers, session_id, **kwargs):
return await server.load_session(cwd, mcp_servers, session_id, **kwargs)
async def list_sessions(self, cursor=None, cwd=None, **kwargs):
return await server.list_sessions(cursor, cwd, **kwargs)
async def set_session_mode(self, mode_id, session_id, **kwargs):
return await server.set_session_mode(mode_id, session_id, **kwargs)
async def set_session_model(self, model_id, session_id, **kwargs):
return await server.set_session_model(model_id, session_id, **kwargs)
async def prompt(self, prompt, session_id, **kwargs):
return await server.prompt(prompt, session_id, **kwargs)
async def fork_session(self, cwd, session_id, mcp_servers=None, **kwargs):
return await server.fork_session(cwd, session_id, mcp_servers, **kwargs)
async def resume_session(self, cwd, session_id, mcp_servers=None, **kwargs):
return await server.resume_session(cwd, session_id, mcp_servers, **kwargs)
async def cancel(self, session_id, **kwargs):
return await server.cancel(session_id, **kwargs)
async def ext_method(self, method, params):
return await server.ext_method(method, params)
async def ext_notification(self, method, params):
return await server.ext_notification(method, params)
logger.info("Starting PraisonAI ACP server...")
await run_agent(PraisonACPAgent(), use_unstable_protocol=True)
def serve(
workspace: Union[str, Path] = ".",
agent: str = "default",
model: Optional[str] = None,
resume: Optional[str] = None,
resume_last: bool = False,
debug: bool = False,
read_only: bool = True,
allow_write: bool = False,
allow_shell: bool = False,
approval_mode: str = "manual",
**kwargs: Any,
) -> None:
"""
Start the ACP server.
This is the main entry point for running PraisonAI as an ACP agent.
Args:
workspace: Working directory for the session
agent: Agent name or configuration
model: LLM model to use
resume: Session ID to resume
resume_last: Resume the last session
debug: Enable debug logging
read_only: Read-only mode (default True)
allow_write: Allow file writes
allow_shell: Allow shell commands
approval_mode: Approval mode (manual, auto, scoped)
"""
config = ACPConfig(
workspace=Path(workspace).resolve(),
agent=agent,
model=model,
resume_session=resume,
resume_last=resume_last,
debug=debug,
read_only=read_only,
allow_write=allow_write,
allow_shell=allow_shell,
approval_mode=approval_mode,
)
asyncio.run(_run_server(config))
+219
View File
@@ -0,0 +1,219 @@
"""
ACP session management.
Handles session creation, persistence, and resume functionality.
"""
import json
import logging
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class ACPSession:
"""Represents an ACP conversation session."""
session_id: str
workspace: Path
created_at: float = field(default_factory=time.time)
last_activity: float = field(default_factory=time.time)
# Agent attribution
agent_id: Optional[str] = None
run_id: Optional[str] = None
trace_id: Optional[str] = None
# Session state
mode: str = "manual" # manual, auto, full_auto
model: Optional[str] = None
# Conversation history (for resume)
messages: List[Dict[str, Any]] = field(default_factory=list)
tool_calls: List[Dict[str, Any]] = field(default_factory=list)
# MCP servers
mcp_servers: List[Dict[str, Any]] = field(default_factory=list)
@classmethod
def create(cls, workspace: Path, agent_id: Optional[str] = None) -> "ACPSession":
"""Create a new session."""
session_id = f"sess_{uuid.uuid4().hex[:16]}"
return cls(
session_id=session_id,
workspace=workspace,
agent_id=agent_id,
run_id=f"run_{uuid.uuid4().hex[:8]}",
trace_id=f"trace_{uuid.uuid4().hex[:8]}",
)
def update_activity(self) -> None:
"""Update last activity timestamp."""
self.last_activity = time.time()
def add_message(self, role: str, content: Any) -> None:
"""Add a message to the conversation history."""
self.messages.append({
"role": role,
"content": content,
"timestamp": time.time(),
})
self.update_activity()
def add_tool_call(self, tool_call_id: str, title: str, status: str, **kwargs) -> None:
"""Add a tool call to the history."""
self.tool_calls.append({
"tool_call_id": tool_call_id,
"title": title,
"status": status,
"timestamp": time.time(),
**kwargs,
})
self.update_activity()
def to_dict(self) -> Dict[str, Any]:
"""Serialize session to dictionary."""
return {
"session_id": self.session_id,
"workspace": str(self.workspace),
"created_at": self.created_at,
"last_activity": self.last_activity,
"agent_id": self.agent_id,
"run_id": self.run_id,
"trace_id": self.trace_id,
"mode": self.mode,
"model": self.model,
"messages": self.messages,
"tool_calls": self.tool_calls,
"mcp_servers": self.mcp_servers,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ACPSession":
"""Deserialize session from dictionary."""
return cls(
session_id=data["session_id"],
workspace=Path(data["workspace"]),
created_at=data.get("created_at", time.time()),
last_activity=data.get("last_activity", time.time()),
agent_id=data.get("agent_id"),
run_id=data.get("run_id"),
trace_id=data.get("trace_id"),
mode=data.get("mode", "manual"),
model=data.get("model"),
messages=data.get("messages", []),
tool_calls=data.get("tool_calls", []),
mcp_servers=data.get("mcp_servers", []),
)
class SessionStore:
"""Persistent storage for ACP sessions."""
def __init__(self, storage_dir: Optional[Path] = None):
"""Initialize session store."""
if storage_dir is None:
storage_dir = Path.home() / ".praison" / "acp" / "sessions"
self.storage_dir = storage_dir
self.storage_dir.mkdir(parents=True, exist_ok=True)
self._last_session_file = self.storage_dir / ".last_session"
def _session_path(self, session_id: str) -> Path:
"""Get path for session file."""
return self.storage_dir / f"{session_id}.json"
def save(self, session: ACPSession) -> None:
"""Save session to disk."""
try:
path = self._session_path(session.session_id)
with open(path, "w") as f:
json.dump(session.to_dict(), f, indent=2)
# Update last session pointer
with open(self._last_session_file, "w") as f:
f.write(session.session_id)
logger.debug(f"Saved session {session.session_id}")
except Exception as e:
logger.error(f"Failed to save session: {e}")
def load(self, session_id: str) -> Optional[ACPSession]:
"""Load session from disk."""
try:
path = self._session_path(session_id)
if not path.exists():
logger.warning(f"Session not found: {session_id}")
return None
with open(path) as f:
data = json.load(f)
return ACPSession.from_dict(data)
except Exception as e:
logger.error(f"Failed to load session: {e}")
return None
def load_last(self) -> Optional[ACPSession]:
"""Load the most recent session."""
try:
if not self._last_session_file.exists():
return None
with open(self._last_session_file) as f:
session_id = f.read().strip()
if session_id:
return self.load(session_id)
return None
except Exception as e:
logger.error(f"Failed to load last session: {e}")
return None
def delete(self, session_id: str) -> bool:
"""Delete a session."""
try:
path = self._session_path(session_id)
if path.exists():
path.unlink()
logger.debug(f"Deleted session {session_id}")
return True
return False
except Exception as e:
logger.error(f"Failed to delete session: {e}")
return False
def list_sessions(self, limit: int = 50) -> List[ACPSession]:
"""List all sessions, sorted by last activity."""
sessions = []
try:
for path in self.storage_dir.glob("sess_*.json"):
try:
with open(path) as f:
data = json.load(f)
sessions.append(ACPSession.from_dict(data))
except Exception:
continue
# Sort by last activity, most recent first
sessions.sort(key=lambda s: s.last_activity, reverse=True)
return sessions[:limit]
except Exception as e:
logger.error(f"Failed to list sessions: {e}")
return []
def cleanup_old_sessions(self, max_age_days: int = 30) -> int:
"""Remove sessions older than max_age_days."""
cutoff = time.time() - (max_age_days * 24 * 60 * 60)
removed = 0
for session in self.list_sessions(limit=1000):
if session.last_activity < cutoff:
if self.delete(session.session_id):
removed += 1
return removed
+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}")
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more