Compare commits

...
31 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
161 changed files with 32380 additions and 1102 deletions

No files matched your search

+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.9.0" \
"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.9.0" \
"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.9.0" \
"praisonai>=3.1.2" \
"praisonai[ui]" \
"praisonai[crewai]"
+203
View File
@@ -0,0 +1,203 @@
# Profiling CLI
PraisonAI provides unified profiling capabilities for performance analysis and debugging, with **full visibility from ENTER to first response**.
## Quick Start
```bash
# Basic profiling
praisonai "What is 2+2?" --profile
# Deep profiling with call graph, decision trace, and module breakdown
praisonai "What is 2+2?" --profile --profile-deep
# JSON output format
praisonai "What is 2+2?" --profile --profile-format json
```
## Time to First Response (TTFR)
**TTFR** is the key user-perceived latency metric. It measures the time from when you press ENTER until the first visible output appears.
### What contributes to TTFR?
1. **CLI Entry** - Time to reach the CLI entry point
2. **CLI Parse** - Argparse/Typer routing time
3. **Routing** - Command routing to handler
4. **Imports** - Loading required modules
5. **Agent Init** - Initializing the agent
6. **Network Request** - Sending request to LLM API
7. **First Token** - Receiving first token (streaming)
8. **First Output** - Rendering first visible output
### How to reduce TTFR
1. **Warm runs** - Imports are cached after first run
2. **Streaming mode** - See output as tokens arrive
3. **Lighter models** - Faster response times
4. **Local models** - Eliminate network latency
## CLI Flags
| Flag | Description | Default |
|------|-------------|---------|
| `--profile` | Enable profiling with timing breakdown | Disabled |
| `--profile-deep` | Enable deep profiling with call graph (higher overhead) | Disabled |
| `--profile-format` | Output format: `text` or `json` | `text` |
## Profile Command
For more detailed profiling options, use the `profile` subcommand:
```bash
# Profile a query
praisonai profile query "What is 2+2?"
# With deep call tracing
praisonai profile query "Hello" --deep
# Limit functions shown
praisonai profile query "Test" --limit 20
# Save artifacts to directory
praisonai profile query "Test" --save ./profile_results
# JSON output
praisonai profile query "Test" --format json
```
### Profile Query Options
| Option | Description | Default |
|--------|-------------|---------|
| `--model, -m` | Model to use | Default model |
| `--stream/--no-stream` | Use streaming mode | No stream |
| `--deep` | Enable deep call tracing | Disabled |
| `--limit, -n` | Top N functions to show | 30 |
| `--sort, -s` | Sort by: `cumulative` or `tottime` | `cumulative` |
| `--show-callers` | Show caller functions | Disabled |
| `--show-callees` | Show callee functions | Disabled |
| `--save` | Save artifacts to path | None |
| `--format, -f` | Output format: `text` or `json` | `text` |
## Other Profile Commands
```bash
# Profile module import times
praisonai profile imports
# Profile CLI startup time
praisonai profile startup
# Run comprehensive profiling suite
praisonai profile suite
# Create performance snapshots for comparison
praisonai profile snapshot --baseline
praisonai profile snapshot current --compare
# Configure performance optimizations
praisonai profile optimize --show
```
## Output Format
### Text Report
```
======================================================================
PraisonAI Profile Report
======================================================================
Run ID: abc12345
Timestamp: 2024-01-02T12:00:00.000000Z
Method: cli_direct
Version: 3.0.1
## Execution Timeline
---------------------------------------------
Imports : 500.00 ms
Agent Init : 0.50 ms
Execution : 2000.00 ms
───────────────────────────────────────────
⏱ Time to First Response : 2500.50 ms
TOTAL : 2500.50 ms
## Timing Breakdown
----------------------------------------
Imports: 500.00 ms
Agent Init: 0.50 ms
Execution: 2000.00 ms
─────────────────────────────────────
TOTAL: 2500.50 ms
## Decision Trace (deep profile only)
----------------------------------------
Agent Config: default
Model: gpt-4
Streaming: disabled
Profile Layer: 2
## Top Functions by Cumulative Time
----------------------------------------------------------------------
Function Calls Cumul (ms)
----------------------------------------------------------------------
request 1 1800.00
send 1 1750.00
...
```
### JSON Report
```json
{
"schema_version": "1.0",
"run_id": "abc12345",
"timestamp": "2024-01-02T12:00:00.000000Z",
"invocation": {
"method": "cli_direct",
"flags": {"layer": 1, "stream": false, "model": null},
"praisonai_version": "3.0.1",
"python_version": "3.12.0"
},
"timing": {
"total_ms": 2500.5,
"imports_ms": 500.0,
"agent_init_ms": 0.5,
"execution_ms": 2000.0
},
"functions": [...],
"response_preview": "four"
}
```
## Profiling Layers
| Layer | Overhead | Features |
|-------|----------|----------|
| 0 | < 1ms | Wall-clock phases only |
| 1 | ~5% | + cProfile function stats |
| 2 | ~15% | + Call graph (callers/callees) |
- `--profile` uses Layer 1
- `--profile --profile-deep` uses Layer 2
## Performance Tips
1. **Use Layer 0 for production monitoring** - minimal overhead
2. **Use Layer 1 for development profiling** - good balance
3. **Use Layer 2 only for deep debugging** - higher overhead
## Observational Equivalence
The following commands produce equivalent profile reports:
```bash
# CLI direct
praisonai "prompt" --profile
# Profile command
praisonai profile query "prompt"
```
Both use the same unified profiling architecture and produce identical schema output.
+257
View File
@@ -0,0 +1,257 @@
# Profiling Module
PraisonAI provides a unified profiling module for programmatic performance analysis.
## Quick Start
```python
from praisonai.cli.execution import (
ExecutionRequest,
Profiler,
ProfilerConfig,
)
# Create execution request
request = ExecutionRequest(prompt="What is 2+2?")
# Configure profiler
config = ProfilerConfig(layer=1)
# Run with profiling
profiler = Profiler(config)
result, report = profiler.profile_sync(request)
# Access results
print(f"Output: {result.output}")
print(f"Total time: {report.timing.total_ms}ms")
```
## ExecutionRequest
Immutable request descriptor for execution.
```python
from praisonai.cli.execution import ExecutionRequest
# Basic request
request = ExecutionRequest(prompt="Hello")
# With model
request = ExecutionRequest(
prompt="Hello",
model="gpt-4",
agent_name="MyAgent",
)
# With streaming
request = ExecutionRequest(
prompt="Hello",
stream=True,
)
```
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `prompt` | str | Required | The prompt to execute |
| `agent_name` | str | "Assistant" | Name of the agent |
| `agent_instructions` | str | None | Custom instructions |
| `model` | str | None | Model to use |
| `stream` | bool | False | Enable streaming |
| `tools` | tuple | () | Tools to provide |
## ProfilerConfig
Configuration for profiling behavior.
```python
from praisonai.cli.execution import ProfilerConfig
# Layer 0: Wall-clock only (< 1ms overhead)
config = ProfilerConfig(layer=0)
# Layer 1: + cProfile stats (~5% overhead)
config = ProfilerConfig(layer=1)
# Layer 2: + Call graph (~15% overhead)
config = ProfilerConfig(
layer=2,
show_callers=True,
show_callees=True,
)
# From CLI flags
config = ProfilerConfig.from_flags(
profile=True,
deep=True,
network=False,
)
```
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `layer` | int | 1 | Profiling layer (0, 1, or 2) |
| `limit` | int | 30 | Max functions to show |
| `sort_by` | str | "cumulative" | Sort by: cumulative, time, calls |
| `show_callers` | bool | False | Show caller functions |
| `show_callees` | bool | False | Show callee functions |
| `track_network` | bool | False | Track network requests |
| `output_format` | str | "text" | Output format: text, json |
| `save_path` | str | None | Path to save artifacts |
## Profiler
The main profiler class that wraps execution.
```python
from praisonai.cli.execution import Profiler, ProfilerConfig, ExecutionRequest
config = ProfilerConfig(layer=1)
profiler = Profiler(config)
request = ExecutionRequest(prompt="Hello")
result, report = profiler.profile_sync(request, invocation_method="api")
```
### Methods
#### `profile_sync(request, invocation_method="profile_command")`
Profile synchronous execution.
**Parameters:**
- `request`: ExecutionRequest - The execution request
- `invocation_method`: str - How this was invoked (for report)
**Returns:** Tuple of (ExecutionResult, ProfileReport)
## ProfileReport
The canonical profile report with timing and statistics.
```python
# Access timing breakdown
print(f"Total: {report.timing.total_ms}ms")
print(f"Imports: {report.timing.imports_ms}ms")
print(f"Agent Init: {report.timing.agent_init_ms}ms")
print(f"Execution: {report.timing.execution_ms}ms")
# Access function stats (layer 1+)
if report.functions:
for func in report.functions[:5]:
print(f"{func.name}: {func.cumulative_time_ms}ms")
# Access call graph (layer 2)
if report.call_graph:
print(f"Edges: {len(report.call_graph.edges)}")
# Export formats
json_str = report.to_json()
text_str = report.to_text()
dict_data = report.to_dict()
```
### Schema Version
The report uses schema version `1.0`. Schema is frozen after release - new fields are always optional with defaults.
### Mandatory Fields
- `schema_version`: str
- `run_id`: str
- `timestamp`: str (ISO 8601)
- `invocation`: InvocationInfo
- `timing`: TimingBreakdown
- `response_preview`: str
### Optional Fields
- `functions`: List[FunctionStat] (layer 1+)
- `call_graph`: CallGraph (layer 2)
- `network`: List[RequestTiming] (if track_network)
## ExecutionResult
Result of execution with output and metadata.
```python
# Access output
print(result.output)
print(result.success)
print(result.run_id)
# Check for errors
if result.error:
print(f"Error: {result.error}")
# Duration
print(f"Duration: {result.duration_ms}ms")
```
## Data Bounds
Profile data is strictly bounded to prevent memory issues:
| Data Type | Maximum |
|-----------|---------|
| Function stats | 1000 |
| Call graph edges | 5000 |
| Network requests | 100 |
## Example: Full Profiling Workflow
```python
from praisonai.cli.execution import (
ExecutionRequest,
Profiler,
ProfilerConfig,
)
# Configure deep profiling
config = ProfilerConfig(
layer=2,
limit=50,
show_callers=True,
show_callees=True,
)
# Create request
request = ExecutionRequest(
prompt="Explain quantum computing in simple terms",
model="gpt-4",
)
# Profile execution
profiler = Profiler(config)
result, report = profiler.profile_sync(request)
# Print text report
print(report.to_text())
# Save JSON report
with open("profile.json", "w") as f:
f.write(report.to_json())
# Analyze timing
print(f"\nTiming Analysis:")
print(f" Import overhead: {report.timing.imports_ms / report.timing.total_ms * 100:.1f}%")
print(f" Execution time: {report.timing.execution_ms / report.timing.total_ms * 100:.1f}%")
# Top 5 hotspots
if report.functions:
print(f"\nTop 5 Hotspots:")
for func in report.functions[:5]:
print(f" {func.name}: {func.cumulative_time_ms:.2f}ms ({func.calls} calls)")
```
## Invariants
The profiling module guarantees:
1. **Output Equivalence**: Profiled execution produces identical output to non-profiled execution
2. **Schema Stability**: Schema version 1.0 is frozen; new fields are always optional
3. **Bounded Data**: All profile data respects maximum bounds
4. **Zero Overhead When Disabled**: No profiling code runs unless explicitly enabled
@@ -0,0 +1,135 @@
"""
Agent-Centric Tools Example
This example demonstrates how to use LSP/ACP-powered tools that make
the Agent the central orchestrator for file operations.
How to run:
export OPENAI_API_KEY=your_key
python examples/agent_tools/agent_centric_example.py
Prerequisites:
pip install praisonai praisonaiagents
"""
import asyncio
import os
import sys
import tempfile
import shutil
# Add paths for development
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/praisonai'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/praisonai-agents'))
async def main():
print("=" * 60)
print("AGENT-CENTRIC TOOLS EXAMPLE")
print("=" * 60)
print()
# Create temporary workspace
workspace = tempfile.mkdtemp(prefix="praisonai_example_")
print(f"Workspace: {workspace}")
print()
try:
# Import after path setup
from praisonai.cli.features import (
create_agent_centric_tools,
InteractiveRuntime,
RuntimeConfig
)
from praisonaiagents import Agent
# 1. Create runtime with ACP enabled
print("1. Creating InteractiveRuntime with ACP...")
config = RuntimeConfig(
workspace=workspace,
lsp_enabled=False, # Skip LSP for this example
acp_enabled=True,
approval_mode="auto" # Auto-approve for demo
)
runtime = InteractiveRuntime(config)
status = await runtime.start()
print(f" ACP ready: {runtime.acp_ready}")
print(f" Read-only: {runtime.read_only}")
print()
# 2. Create agent-centric tools
print("2. Creating agent-centric tools...")
tools = create_agent_centric_tools(runtime)
print(f" Tools created: {len(tools)}")
for tool in tools:
print(f" - {tool.__name__}")
print()
# 3. Create Agent with ACP-powered tools
print("3. Creating Agent with ACP-powered tools...")
agent = Agent(
name="FileAgent",
instructions="""You help create and manage files.
Use acp_create_file to create files.
Use read_file to read files.
Use list_files to list directory contents.""",
tools=tools,
verbose=True
)
print()
# 4. Ask agent to create a file
print("4. Asking agent to create a Python file...")
print("-" * 40)
result = agent.start(
"Create a Python file called calculator.py with add and subtract functions"
)
print("-" * 40)
print(f"Agent response: {result[:200]}..." if len(str(result)) > 200 else f"Agent response: {result}")
print()
# 5. Verify file was created
print("5. Verifying file creation...")
calc_path = os.path.join(workspace, "calculator.py")
if os.path.exists(calc_path):
print(f" ✓ File created at {calc_path}")
with open(calc_path) as f:
content = f.read()
print(f" Content preview:")
for line in content.split('\n')[:10]:
print(f" {line}")
else:
# Check what files exist
files = os.listdir(workspace)
print(f" Files in workspace: {files}")
for f in files:
fp = os.path.join(workspace, f)
if os.path.isfile(fp):
print(f" ✓ Found: {f}")
print()
# 6. Ask agent to read the file
print("6. Asking agent to read the file...")
print("-" * 40)
result2 = agent.start("Read the calculator.py file and tell me what functions it has")
print("-" * 40)
print(f"Agent response: {result2[:300]}..." if len(str(result2)) > 300 else f"Agent response: {result2}")
print()
# Cleanup
await runtime.stop()
finally:
# Clean up workspace
shutil.rmtree(workspace, ignore_errors=True)
print(f"Cleaned up workspace: {workspace}")
print()
print("=" * 60)
print("EXAMPLE COMPLETE")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""
PraisonAI Benchmark Example
This example demonstrates how to use the benchmark system programmatically
to compare performance across different execution paths.
Usage:
python benchmark_example.py
Requirements:
- OPENAI_API_KEY environment variable set
- praisonai package installed
"""
import os
import sys
# Ensure the package is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/praisonai'))
from praisonai.cli.features.benchmark import BenchmarkHandler, BenchmarkReport
def run_quick_comparison():
"""Run a quick comparison of key execution paths."""
print("=" * 60)
print("Quick Benchmark Comparison")
print("=" * 60)
handler = BenchmarkHandler()
# Run benchmark with 2 iterations on key paths
report = handler.run_full_benchmark(
prompt="What is 2+2?",
iterations=2,
paths=["openai_sdk", "praisonai_agent"],
verbose=True
)
# Print comparison table
print("\n" + handler.create_comparison_table(report))
return report
def run_full_benchmark():
"""Run full benchmark suite across all paths."""
print("=" * 60)
print("Full Benchmark Suite")
print("=" * 60)
handler = BenchmarkHandler()
# Run full benchmark
report = handler.run_full_benchmark(
prompt="Hi",
iterations=3,
verbose=True
)
# Print full report
handler.print_report(report)
return report
def run_agent_benchmark():
"""Benchmark PraisonAI Agent specifically."""
print("=" * 60)
print("Agent Benchmark")
print("=" * 60)
handler = BenchmarkHandler()
# Benchmark agent vs SDK
report = handler.run_full_benchmark(
prompt="Explain Python in one sentence",
iterations=3,
paths=["openai_sdk", "praisonai_agent"],
verbose=True
)
# Show timeline diagrams
for name, result in report.results.items():
print(f"\n### {name}")
print(handler.create_timeline_diagram(result))
# Show variance analysis
print(handler.create_variance_table(report))
return report
def save_benchmark_results(report: BenchmarkReport, output_path: str):
"""Save benchmark results to JSON file."""
import json
with open(output_path, 'w') as f:
json.dump(report.to_dict(), f, indent=2)
print(f"\nResults saved to: {output_path}")
def main():
"""Main entry point."""
import argparse
parser = argparse.ArgumentParser(description="PraisonAI Benchmark Example")
parser.add_argument(
"--mode",
choices=["quick", "full", "agent"],
default="quick",
help="Benchmark mode: quick (2 paths), full (all paths), agent (agent focus)"
)
parser.add_argument(
"--output",
type=str,
default=None,
help="Output JSON file path"
)
args = parser.parse_args()
# Check for API key
if not os.environ.get("OPENAI_API_KEY"):
print("Error: OPENAI_API_KEY environment variable not set")
sys.exit(1)
# Run selected benchmark
if args.mode == "quick":
report = run_quick_comparison()
elif args.mode == "full":
report = run_full_benchmark()
elif args.mode == "agent":
report = run_agent_benchmark()
# Save results if requested
if args.output:
save_benchmark_results(report, args.output)
print("\n✅ Benchmark complete!")
if __name__ == "__main__":
main()
@@ -0,0 +1,147 @@
"""
Interactive Default Tools Example
This example demonstrates how PraisonAI's interactive modes (TUI and prompt)
include ACP and LSP tools by default.
Tool Groups:
- ACP: acp_create_file, acp_edit_file, acp_delete_file, acp_execute_command
- LSP: lsp_list_symbols, lsp_find_definition, lsp_find_references, lsp_get_diagnostics
- Basic: read_file, write_file, list_files, execute_command, internet_search
Usage:
python interactive_tools_example.py
"""
import os
import sys
# Add paths for development
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/praisonai'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/praisonai-agents'))
def example_get_default_tools():
"""Example: Get all default interactive tools."""
from praisonai.cli.features.interactive_tools import (
get_interactive_tools,
ToolConfig,
TOOL_GROUPS,
)
print("=== Default Interactive Tools ===\n")
# Show tool groups
print("Tool Groups:")
for group, tools in TOOL_GROUPS.items():
if group != "interactive":
print(f" {group}: {tools}")
print()
# Get all tools
config = ToolConfig(workspace=os.getcwd())
tools = get_interactive_tools(config=config)
print(f"Total tools loaded: {len(tools)}")
print(f"Tool names: {[t.__name__ for t in tools]}")
print()
return tools
def example_disable_acp():
"""Example: Disable ACP tools."""
from praisonai.cli.features.interactive_tools import (
get_interactive_tools,
ToolConfig,
)
print("=== Disable ACP Tools (--no-acp) ===\n")
config = ToolConfig(workspace=os.getcwd(), enable_acp=False)
tools = get_interactive_tools(config=config, disable=["acp"])
acp_tools = [t for t in tools if t.__name__.startswith("acp_")]
print(f"ACP tools after --no-acp: {len(acp_tools)}")
print(f"Remaining tools: {[t.__name__ for t in tools]}")
print()
return tools
def example_disable_lsp():
"""Example: Disable LSP tools."""
from praisonai.cli.features.interactive_tools import (
get_interactive_tools,
ToolConfig,
)
print("=== Disable LSP Tools (--no-lsp) ===\n")
config = ToolConfig(workspace=os.getcwd(), enable_lsp=False)
tools = get_interactive_tools(config=config, disable=["lsp"])
lsp_tools = [t for t in tools if t.__name__.startswith("lsp_")]
print(f"LSP tools after --no-lsp: {len(lsp_tools)}")
print(f"Remaining tools: {[t.__name__ for t in tools]}")
print()
return tools
def example_agent_with_default_tools():
"""Example: Create an Agent with default interactive tools."""
from praisonai.cli.features.interactive_tools import get_interactive_tools, ToolConfig
from praisonaiagents import Agent
print("=== Agent with Default Interactive Tools ===\n")
# Get default tools
config = ToolConfig(workspace=os.getcwd())
tools = get_interactive_tools(config=config)
# Create agent with tools
agent = Agent(
name="InteractiveAgent",
instructions="You are a helpful coding assistant with file and code analysis capabilities.",
tools=tools,
verbose=False
)
print(f"Agent created with {len(tools)} tools")
print(f"Tools: {[t.__name__ for t in tools]}")
print()
# Simple test - list files
print("Testing agent with list_files tool...")
result = agent.start("List the Python files in the current directory using list_files tool.")
print(f"Result: {result[:200]}..." if len(str(result)) > 200 else f"Result: {result}")
print()
return agent
if __name__ == "__main__":
print("=" * 60)
print("PraisonAI Interactive Default Tools Example")
print("=" * 60)
print()
# Example 1: Get default tools
example_get_default_tools()
# Example 2: Disable ACP
example_disable_acp()
# Example 3: Disable LSP
example_disable_lsp()
# Example 4: Agent with default tools (requires API key)
if os.environ.get("OPENAI_API_KEY"):
example_agent_with_default_tools()
else:
print("=== Agent Example Skipped (no OPENAI_API_KEY) ===")
print("=" * 60)
print("Examples Complete")
print("=" * 60)
+130
View File
@@ -0,0 +1,130 @@
"""
LSP Code Intelligence Example
This example demonstrates how to use LSP-powered tools through an Agent
for semantic code analysis (listing symbols, finding definitions, etc.).
How to run:
export OPENAI_API_KEY=your_key
python examples/lsp/lsp_code_analysis_example.py
Prerequisites:
pip install praisonai praisonaiagents
pip install python-lsp-server # Optional, for full LSP support
"""
import asyncio
import os
import sys
# Add paths for development
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/praisonai'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/praisonai-agents'))
async def main():
print("=" * 60)
print("LSP CODE INTELLIGENCE EXAMPLE")
print("=" * 60)
print()
# Import after path setup
from praisonai.cli.features import (
create_agent_centric_tools,
InteractiveRuntime,
RuntimeConfig
)
from praisonaiagents import Agent
# Use the praisonaiagents source as workspace for analysis
workspace = os.path.join(
os.path.dirname(__file__),
'../../src/praisonai-agents/praisonaiagents'
)
workspace = os.path.abspath(workspace)
print(f"Workspace: {workspace}")
print()
# 1. Create runtime with LSP enabled
print("1. Creating InteractiveRuntime with LSP...")
config = RuntimeConfig(
workspace=workspace,
lsp_enabled=True, # Enable LSP for code intelligence
acp_enabled=False, # Disable ACP for this example
approval_mode="auto"
)
runtime = InteractiveRuntime(config)
status = await runtime.start()
print(f" LSP enabled: {status['lsp']['enabled']}")
print(f" LSP ready: {runtime.lsp_ready}")
print(f" LSP status: {status['lsp']['status']}")
if not runtime.lsp_ready:
print(" Note: LSP not available, will use regex fallback")
print()
# 2. Create agent-centric tools
print("2. Creating agent-centric tools...")
tools = create_agent_centric_tools(runtime)
# Filter to show only LSP tools
lsp_tools = [t for t in tools if t.__name__.startswith('lsp_')]
print(f" LSP tools available:")
for tool in lsp_tools:
print(f" - {tool.__name__}")
print()
# 3. Create Agent with LSP-powered tools
print("3. Creating Agent with LSP tools...")
agent = Agent(
name="CodeAnalyzer",
instructions="""You are a code analysis assistant.
Use lsp_list_symbols to list functions and classes in files.
Use lsp_find_definition to find where symbols are defined.
Use lsp_find_references to find where symbols are used.
Provide clear, organized summaries of code structure.""",
tools=tools,
verbose=True
)
print()
# 4. Test 1: List symbols in a file
print("4. TEST 1: List symbols in agent/agent.py")
print("-" * 50)
result1 = agent.start("List all the main classes and public methods in agent/agent.py. Give me a brief summary.")
print("-" * 50)
print(f"Summary: {str(result1)[:300]}...")
print()
# 5. Test 2: Find definition of a symbol
print("5. TEST 2: Find where 'Agent' class is defined")
print("-" * 50)
result2 = agent.start("Where is the Agent class defined? Use lsp_find_definition to find it.")
print("-" * 50)
print(f"Result: {str(result2)[:300]}...")
print()
# 6. Test 3: Analyze main.py
print("6. TEST 3: Analyze main.py structure")
print("-" * 50)
result3 = agent.start("List all the functions in main.py and briefly describe what the file does.")
print("-" * 50)
print(f"Analysis: {str(result3)[:300]}...")
print()
# Cleanup
await runtime.stop()
print("=" * 60)
print("EXAMPLE COMPLETE")
print("=" * 60)
print()
print("Key takeaways:")
print("- LSP tools work through the Agent (agentic LSP)")
print("- Falls back to regex if LSP server not available")
print("- Returns structured data with citations")
if __name__ == "__main__":
asyncio.run(main())
+133
View File
@@ -0,0 +1,133 @@
"""
PraisonAI Output UX Examples
This example demonstrates the various output modes and configurations
available in PraisonAI agents.
Features demonstrated:
- verbose=True: Full output with agent info, task, and response panels
- verbose=False: Clean output with just the result
- Tool call display with consolidated output
- Multi-agent output with clear attribution
"""
import sys
sys.path.insert(0, '/Users/praison/praisonai-package/src/praisonai-agents')
from praisonaiagents import Agent, Task, PraisonAIAgents
def get_weather(city: str) -> str:
"""Get the current weather for a city.
Args:
city: The name of the city to get weather for
Returns:
Weather information string
"""
return f"The weather in {city} is sunny and 72°F"
def example_verbose_true():
"""Example with verbose=True (default) - shows full output panels."""
print("\n" + "=" * 60)
print("EXAMPLE 1: verbose=True (Full Output)")
print("=" * 60)
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant. Be concise.",
verbose=True # Default - shows Agent Info, Task, and Response panels
)
result = agent.start("What is the capital of France? Answer in one sentence.")
print(f"\nReturned value: {result}")
def example_verbose_false():
"""Example with verbose=False - clean output, just the result."""
print("\n" + "=" * 60)
print("EXAMPLE 2: verbose=False (Clean Output)")
print("=" * 60)
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant. Be concise.",
verbose=False # No panels, just returns the result
)
result = agent.start("What is 2+2? Answer in one word.")
print(f"Result: {result}")
def example_with_tools():
"""Example showing tool call output."""
print("\n" + "=" * 60)
print("EXAMPLE 3: Agent with Tools")
print("=" * 60)
agent = Agent(
name="Weather Bot",
instructions="You help users check the weather. Use the get_weather tool.",
tools=[get_weather],
verbose=True
)
result = agent.start("What's the weather in Tokyo?")
print(f"\nReturned value: {result}")
def example_multi_agent():
"""Example showing multi-agent output with clear attribution."""
print("\n" + "=" * 60)
print("EXAMPLE 4: Multi-Agent Output")
print("=" * 60)
researcher = Agent(
name="Researcher",
role="Research Analyst",
instructions="You research topics and provide factual information.",
verbose=True
)
writer = Agent(
name="Writer",
role="Content Writer",
instructions="You write clear summaries based on research.",
verbose=True
)
task1 = Task(
description="Research what AI is in one sentence",
expected_output="A brief factual description",
agent=researcher
)
task2 = Task(
description="Write a one-sentence summary about AI",
expected_output="A clear summary",
agent=writer
)
agents = PraisonAIAgents(
agents=[researcher, writer],
tasks=[task1, task2],
process="sequential",
verbose=1
)
result = agents.start()
print(f"\nFinal result: {result}")
if __name__ == "__main__":
print("=" * 60)
print("PRAISONAI OUTPUT UX EXAMPLES")
print("=" * 60)
# Run examples
example_verbose_true()
example_verbose_false()
example_with_tools()
example_multi_agent()
print("\n" + "=" * 60)
print("ALL EXAMPLES COMPLETE")
print("=" * 60)
+83
View File
@@ -0,0 +1,83 @@
# PraisonAI Profiling Examples
This directory contains examples for profiling PraisonAI agent performance.
## Prerequisites
```bash
pip install praisonai
export OPENAI_API_KEY=your_key_here
```
## Examples
### 1. Basic Profiling (`basic_profiling.py`)
Demonstrates programmatic profiling of a single query:
```bash
python basic_profiling.py
```
### 2. Suite Profiling (`suite_profiling.py`)
Runs a comprehensive profiling suite with multiple scenarios:
```bash
python suite_profiling.py
```
### 3. Optimization Demo (`optimization_example.py`)
Demonstrates Tier 0/1/2 performance optimizations:
```bash
python optimization_example.py
```
## CLI Commands
You can also profile directly from the command line:
```bash
# Profile a query
praisonai profile query "What is 2+2?"
# Profile with file grouping
praisonai profile query "Hello" --show-files --limit 20
# Profile imports
praisonai profile imports
# Profile startup time
praisonai profile startup
# Run comprehensive suite
praisonai profile suite --quick
# Create performance baseline
praisonai profile snapshot --baseline
# Compare against baseline
praisonai profile snapshot current --compare
# Show optimization status
praisonai profile optimize --show
```
## Output
- **Text output**: Human-readable timing breakdown
- **JSON output**: Machine-readable for CI/CD (`--format json`)
- **Artifacts**: Binary cProfile data and reports (`--save ./results`)
- **Snapshots**: Performance baselines for regression detection
## Performance Optimizations
Enable opt-in optimizations via environment variables:
```bash
export PRAISONAI_LITE_MODE=1
export PRAISONAI_SKIP_TYPE_VALIDATION=1
export PRAISONAI_MINIMAL_IMPORTS=1
```
@@ -0,0 +1,71 @@
"""
Basic Profiling Example for PraisonAI
This example demonstrates how to use the unified profiler programmatically
to measure agent performance.
Run with:
python basic_profiling.py
"""
import os
# Ensure API key is set
if not os.environ.get("OPENAI_API_KEY"):
print("Please set OPENAI_API_KEY environment variable")
exit(1)
from praisonai.cli.execution import (
ExecutionRequest,
Profiler,
ProfilerConfig,
)
def main():
"""Run a profiled query and display results."""
# Configure profiler (Layer 1 = basic profiling with function stats)
config = ProfilerConfig(
layer=1, # 0=minimal, 1=basic, 2=deep
limit=15, # Show top 15 functions
show_callers=False, # Set True for caller info (layer 2)
show_callees=False, # Set True for callee info (layer 2)
)
# Create execution request
request = ExecutionRequest(
prompt="What is 2+2?",
agent_name="ProfiledAgent",
)
# Run profiled query
print("🔬 Running profiled query...")
profiler = Profiler(config)
result, report = profiler.profile_sync(request)
# Print formatted report
print("\n" + report.to_text())
# Access timing data programmatically
print("\n📊 Timing Summary:")
print(f" Imports: {report.timing.imports_ms:>10.2f} ms")
print(f" Agent Init: {report.timing.agent_init_ms:>10.2f} ms")
print(f" Execution: {report.timing.execution_ms:>10.2f} ms")
print(f" Total: {report.timing.total_ms:>10.2f} ms")
# Show top 5 functions
if report.functions:
print("\n🔥 Top 5 Functions by Cumulative Time:")
for i, func in enumerate(report.functions[:5], 1):
print(f" {i}. {func.name}: {func.cumulative_time_ms:.2f}ms")
# Show response
print(f"\n💬 Response: {result.output}")
# Export to JSON
print("\n📄 JSON Export available via: report.to_json()")
if __name__ == "__main__":
main()
@@ -0,0 +1,223 @@
"""
Performance Optimization Example for PraisonAI
This example demonstrates how to use the Tier 0/1/2 optimization features
programmatically.
Run with:
python optimization_example.py
"""
import os
# Ensure API key is set
if not os.environ.get("OPENAI_API_KEY"):
print("Please set OPENAI_API_KEY environment variable")
exit(1)
def demo_provider_cache():
"""Demonstrate provider caching (Tier 0)."""
print("\n" + "=" * 60)
print("Tier 0: Provider Cache Demo")
print("=" * 60)
from praisonai.cli.features.profiler import (
get_provider_cache,
resolve_model_provider,
)
cache = get_provider_cache()
cache.invalidate() # Start fresh
# Resolve some models
models = ['gpt-4o', 'claude-3-opus', 'gemini-pro', 'gpt-4o-mini']
print("\nResolving models:")
for model in models:
provider, name = resolve_model_provider(model)
print(f" {model} -> {provider}/{name}")
# Check cache stats
stats = cache.get_stats()
print("\nCache Stats:")
print(f" Hits: {stats['hits']}")
print(f" Misses: {stats['misses']}")
print(f" Size: {stats['size']}")
def demo_lazy_importer():
"""Demonstrate lazy imports (Tier 0)."""
print("\n" + "=" * 60)
print("Tier 0: Lazy Importer Demo")
print("=" * 60)
from praisonai.cli.features.profiler import LazyImporter
import time
LazyImporter.clear()
# Time lazy import
start = time.perf_counter()
json_module = LazyImporter.get('json')
first_import = (time.perf_counter() - start) * 1000
# Second import (cached)
start = time.perf_counter()
json_module2 = LazyImporter.get('json')
cached_import = (time.perf_counter() - start) * 1000
print(f"\nFirst import: {first_import:.3f}ms")
print(f"Cached import: {cached_import:.3f}ms")
print(f"Same object: {json_module is json_module2}")
def demo_client_pool():
"""Demonstrate client pooling (Tier 1)."""
print("\n" + "=" * 60)
print("Tier 1: Client Pool Demo")
print("=" * 60)
from praisonai.cli.features.profiler import ClientPool
pool = ClientPool(max_size=3)
# Create some mock clients
def create_client(name):
return {'name': name, 'created': True}
# Get or create clients
client1 = pool.get_or_create('openai', lambda: create_client('openai'))
_ = pool.get_or_create('anthropic', lambda: create_client('anthropic'))
# Reuse existing client
client1_again = pool.get_or_create('openai', lambda: create_client('openai_new'))
print(f"\nPool size: {pool.size()}")
print(f"Client reused: {client1 is client1_again}")
print(f"Client1 name: {client1['name']}")
def demo_prewarm():
"""Demonstrate pre-warming (Tier 1)."""
print("\n" + "=" * 60)
print("Tier 1: Pre-warming Demo")
print("=" * 60)
from praisonai.cli.features.profiler import PrewarmManager
import time
PrewarmManager.reset()
print(f"\nPre-warming enabled: {PrewarmManager.is_enabled()}")
# Enable and prewarm
PrewarmManager.enable()
print(f"After enable: {PrewarmManager.is_enabled()}")
# Prewarm OpenAI (runs in background)
PrewarmManager.prewarm_provider('openai')
print("Pre-warming OpenAI in background...")
# Wait a bit for background thread
time.sleep(0.5)
print(f"OpenAI prewarmed: {PrewarmManager.is_prewarmed('openai')}")
PrewarmManager.reset()
def demo_lite_mode():
"""Demonstrate lite mode (Tier 2)."""
print("\n" + "=" * 60)
print("Tier 2: Lite Mode Demo")
print("=" * 60)
from praisonai.cli.features.profiler import (
LiteModeConfig,
is_lite_mode,
)
# Default config
config = LiteModeConfig()
print(f"\nDefault lite mode: {config.enabled}")
# Config from env
print("\nTo enable lite mode, set environment variables:")
print(" export PRAISONAI_LITE_MODE=1")
print(" export PRAISONAI_SKIP_TYPE_VALIDATION=1")
print(f"\nCurrent is_lite_mode(): {is_lite_mode()}")
def demo_perf_snapshot():
"""Demonstrate performance snapshots (Tier 2)."""
print("\n" + "=" * 60)
print("Tier 2: Performance Snapshot Demo")
print("=" * 60)
from praisonai.cli.features.profiler import (
PerfSnapshot,
PerfComparison,
PerfSnapshotManager,
)
import tempfile
from pathlib import Path
# Create manager with temp directory
with tempfile.TemporaryDirectory() as tmpdir:
manager = PerfSnapshotManager(snapshot_dir=Path(tmpdir))
# Create baseline
baseline = PerfSnapshot(
timestamp='2025-01-01T00:00:00Z',
name='baseline',
startup_cold_ms=100.0,
startup_warm_ms=80.0,
import_time_ms=500.0,
query_time_ms=2000.0,
first_token_ms=300.0,
)
manager.save_baseline(baseline)
print("\nBaseline saved")
# Create current snapshot (simulated improvement)
current = PerfSnapshot(
timestamp='2025-01-02T00:00:00Z',
name='current',
startup_cold_ms=95.0, # 5% faster
startup_warm_ms=78.0,
import_time_ms=480.0, # 4% faster
query_time_ms=1900.0, # 5% faster
first_token_ms=280.0,
)
# Compare
comparison = PerfComparison(baseline=baseline, current=current)
print(f"\nStartup improvement: {-comparison.startup_cold_diff_pct:.1f}%")
print(f"Import improvement: {-comparison.import_time_diff_pct:.1f}%")
print(f"Query improvement: {-comparison.query_time_diff_pct:.1f}%")
print(f"Is regression: {comparison.is_regression()}")
def main():
"""Run all demos."""
print("=" * 60)
print("PraisonAI Performance Optimization Demo")
print("=" * 60)
demo_provider_cache()
demo_lazy_importer()
demo_client_pool()
demo_prewarm()
demo_lite_mode()
demo_perf_snapshot()
print("\n" + "=" * 60)
print("All demos complete!")
print("=" * 60)
if __name__ == "__main__":
main()
@@ -0,0 +1,227 @@
{
"timestamp": "2025-12-31T18:45:38.187539Z",
"metadata": {
"python_version": "3.12.11",
"platform": "macOS-15.7.4-arm64-arm-64bit",
"praisonai_version": "2.9.2",
"timestamp": "2025-12-31T18:45:38.226558Z"
},
"startup": {
"cold_ms": 80.9305000002496,
"warm_ms": 55.266541996388696
},
"import_analysis": [
{
"module": "praisonaiagents",
"self_us": 2419,
"cumulative_us": 1645390,
"self_ms": 2.419,
"cumulative_ms": 1645.39
},
{
"module": "praisonaiagents.workflows",
"self_us": 670,
"cumulative_us": 1375919,
"self_ms": 0.67,
"cumulative_ms": 1375.919
},
{
"module": "praisonaiagents.workflows.yaml_parser",
"self_us": 775,
"cumulative_us": 1369435,
"self_ms": 0.775,
"cumulative_ms": 1369.435
},
{
"module": "praisonaiagents.agent.agent",
"self_us": 19,
"cumulative_us": 1349174,
"self_ms": 0.019,
"cumulative_ms": 1349.174
},
{
"module": "praisonaiagents.agent",
"self_us": 537,
"cumulative_us": 1349155,
"self_ms": 0.537,
"cumulative_ms": 1349.155
},
{
"module": "praisonaiagents.agent.agent",
"self_us": 1453,
"cumulative_us": 1285299,
"self_ms": 1.453,
"cumulative_ms": 1285.299
},
{
"module": "praisonaiagents.llm.openai_client",
"self_us": 7887,
"cumulative_us": 1123578,
"self_ms": 7.887,
"cumulative_ms": 1123.578
},
{
"module": "openai",
"self_us": 1083,
"cumulative_us": 1115692,
"self_ms": 1.083,
"cumulative_ms": 1115.692
},
{
"module": "openai.types",
"self_us": 2606,
"cumulative_us": 957990,
"self_ms": 2.606,
"cumulative_ms": 957.99
},
{
"module": "openai.types.batch",
"self_us": 1787,
"cumulative_us": 485667,
"self_ms": 1.787,
"cumulative_ms": 485.667
},
{
"module": "openai._models",
"self_us": 13423,
"cumulative_us": 450798,
"self_ms": 13.423,
"cumulative_ms": 450.798
},
{
"module": "openai.types.eval_create_params",
"self_us": 644,
"cumulative_us": 393275,
"self_ms": 0.644,
"cumulative_ms": 393.275
},
{
"module": "openai.types.graders.grader_inputs_param",
"self_us": 17,
"cumulative_us": 359515,
"self_ms": 0.017,
"cumulative_ms": 359.515
},
{
"module": "openai.types.graders",
"self_us": 392,
"cumulative_us": 359498,
"self_ms": 0.392,
"cumulative_ms": 359.498
},
{
"module": "openai.types.graders.multi_grader",
"self_us": 3847,
"cumulative_us": 351251,
"self_ms": 3.847,
"cumulative_ms": 351.251
},
{
"module": "openai.types.graders.label_model_grader",
"self_us": 5450,
"cumulative_us": 337291,
"self_ms": 5.45,
"cumulative_ms": 337.291
},
{
"module": "openai.types.graders.grader_inputs",
"self_us": 545,
"cumulative_us": 331841,
"self_ms": 0.545,
"cumulative_ms": 331.841
},
{
"module": "openai.types.responses.response_input_text",
"self_us": 21,
"cumulative_us": 331297,
"self_ms": 0.021,
"cumulative_ms": 331.297
},
{
"module": "openai.types.responses",
"self_us": 1301,
"cumulative_us": 331277,
"self_ms": 1.301,
"cumulative_ms": 331.277
},
{
"module": "rich.logging",
"self_us": 1066,
"cumulative_us": 238256,
"self_ms": 1.066,
"cumulative_ms": 238.256
}
],
"scenarios": [
{
"name": "simple_query",
"config": {
"prompt": "What is 2+2?",
"model": "default",
"stream": false,
"iterations": 2
},
"import_time_stats": {
"mean": 0.008833496394800022,
"min": 0.005916997906751931,
"max": 0.011749994882848114,
"stdev": 0.0041245517164382375,
"median": 0.008833496394800022
},
"total_time_stats": {
"mean": 1727.4090415012324,
"min": 1367.2264170018025,
"max": 2087.5916660006624,
"stdev": 509.3751524982296,
"median": 1727.4090415012324
},
"first_token_stats": null,
"raw_import_times_ms": [
0.011749994882848114,
0.005916997906751931
],
"raw_total_times_ms": [
1367.2264170018025,
2087.5916660006624
]
},
{
"name": "simple_stream",
"config": {
"prompt": "What is 2+2?",
"model": "default",
"stream": true,
"iterations": 2
},
"import_time_stats": {
"mean": 0.008792001608526334,
"min": 0.006375004886649549,
"max": 0.01120899833040312,
"stdev": 0.0034181495442894613,
"median": 0.008792001608526334
},
"total_time_stats": {
"mean": 2321.0982290001994,
"min": 1697.1089580038097,
"max": 2945.087499996589,
"stdev": 882.4540898183949,
"median": 2321.0982290001994
},
"first_token_stats": {
"mean": 2312.8511880022415,
"min": 1681.2440420035273,
"max": 2944.458334000956,
"stdev": 893.2273919631452,
"median": 2312.8511880022415
},
"raw_import_times_ms": [
0.01120899833040312,
0.006375004886649549
],
"raw_total_times_ms": [
2945.087499996589,
1697.1089580038097
]
}
]
}
@@ -0,0 +1,80 @@
"""
Profile Suite Example for PraisonAI
This example demonstrates how to run a comprehensive profiling suite
programmatically with multiple scenarios.
Run with:
python suite_profiling.py
"""
import os
# Ensure API key is set
if not os.environ.get("OPENAI_API_KEY"):
print("Please set OPENAI_API_KEY environment variable")
exit(1)
from praisonai.cli.features.profiler import (
ScenarioConfig,
run_profile_suite,
)
def main():
"""Run a profiling suite with custom scenarios."""
# Define custom scenarios
scenarios = [
ScenarioConfig(
name="simple_query",
prompt="What is 2+2?",
stream=False,
iterations=2,
warmup=1,
),
ScenarioConfig(
name="simple_stream",
prompt="What is 2+2?",
stream=True,
iterations=2,
warmup=1,
),
]
# Run suite with custom output directory
print("🔬 Running Profile Suite...")
result = run_profile_suite(
output_dir="./profile_results",
scenarios=scenarios,
verbose=True,
)
# Print summary
print("\n" + "=" * 60)
print("Profile Suite Results")
print("=" * 60)
print("\n📊 Startup Times:")
print(f" Cold Start: {result.startup_cold_ms:.2f}ms")
print(f" Warm Start: {result.startup_warm_ms:.2f}ms")
if result.import_analysis:
print("\n📦 Top Import:")
print(f" {result.import_analysis[0]['module']}: {result.import_analysis[0]['cumulative_ms']:.2f}ms")
print("\n🎯 Scenario Results:")
for scenario in result.scenarios:
stats = scenario.get_stats(scenario.total_times)
print(f" {scenario.name}:")
print(f" Mean: {stats['mean']:.2f}ms")
print(f" Min: {stats['min']:.2f}ms")
print(f" Max: {stats['max']:.2f}ms")
print("\n✅ Results saved to: ./profile_results/")
print(" - suite_results.json (machine-readable)")
print(" - suite_report.txt (human-readable)")
if __name__ == "__main__":
main()
@@ -0,0 +1,165 @@
"""
Session Persistence Example
Demonstrates automatic session persistence with zero configuration.
When you provide a session_id to an Agent, conversation history is
automatically saved to disk and restored on subsequent runs.
Usage:
python session_persistence_example.py
Requirements:
- praisonaiagents package installed
- OPENAI_API_KEY environment variable set
"""
from praisonaiagents import Agent
def example_basic_persistence():
"""Basic session persistence example."""
print("=" * 60)
print("EXAMPLE 1: Basic Session Persistence")
print("=" * 60)
session_id = "demo-session-basic"
# Create agent with session_id
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant. Be very brief.",
session_id=session_id,
verbose=False,
)
# First message
print("\n[User]: My favorite color is blue and my lucky number is 7.")
response1 = agent.chat("My favorite color is blue and my lucky number is 7.")
print(f"[Assistant]: {response1}")
# Second message - agent remembers
print("\n[User]: What is my favorite color?")
response2 = agent.chat("What is my favorite color?")
print(f"[Assistant]: {response2}")
print(f"\n✅ Session saved to: ~/.praison/sessions/{session_id}.json")
print(f" Chat history length: {len(agent.chat_history)} messages")
def example_session_restoration():
"""Demonstrates session restoration across Agent instances."""
print("\n" + "=" * 60)
print("EXAMPLE 2: Session Restoration")
print("=" * 60)
session_id = "demo-session-restore"
# First agent instance
print("\n--- First Agent Instance ---")
agent1 = Agent(
name="MemoryBot",
instructions="You are a helpful assistant. Be very brief.",
session_id=session_id,
verbose=False,
)
print("[User]: Remember that my pet's name is Max.")
response1 = agent1.chat("Remember that my pet's name is Max.")
print(f"[Assistant]: {response1}")
# Simulate new process by creating new agent instance
print("\n--- New Agent Instance (simulating restart) ---")
agent2 = Agent(
name="MemoryBot",
instructions="You are a helpful assistant. Be very brief.",
session_id=session_id,
verbose=False,
)
print("[User]: What is my pet's name?")
response2 = agent2.chat("What is my pet's name?")
print(f"[Assistant]: {response2}")
if "max" in response2.lower():
print("\n✅ Session restored successfully! Agent remembered 'Max'.")
else:
print("\n⚠️ Agent may not have remembered correctly.")
def example_in_memory_only():
"""Demonstrates in-memory memory without persistence."""
print("\n" + "=" * 60)
print("EXAMPLE 3: In-Memory Only (No session_id)")
print("=" * 60)
# Agent without session_id - in-memory only
agent = Agent(
name="TempBot",
instructions="You are a helpful assistant. Be very brief.",
verbose=False,
)
print("\n[User]: My name is Alice.")
response1 = agent.chat("My name is Alice.")
print(f"[Assistant]: {response1}")
print("\n[User]: What is my name?")
response2 = agent.chat("What is my name?")
print(f"[Assistant]: {response2}")
print(f"\n✅ In-memory chat history: {len(agent.chat_history)} messages")
print(" (This will be lost when the Agent instance is garbage collected)")
def example_direct_store_access():
"""Demonstrates direct access to the session store."""
print("\n" + "=" * 60)
print("EXAMPLE 4: Direct Session Store Access")
print("=" * 60)
from praisonaiagents.session import get_default_session_store
store = get_default_session_store()
session_id = "demo-direct-access"
# Add messages directly
store.add_user_message(session_id, "Hello from direct access!")
store.add_assistant_message(session_id, "Hi! I received your message.")
# Get history
history = store.get_chat_history(session_id)
print(f"\n✅ Added {len(history)} messages directly to session store")
for msg in history:
print(f" [{msg['role']}]: {msg['content']}")
# List all sessions
sessions = store.list_sessions()
print(f"\n📋 Total sessions in store: {len(sessions)}")
# Clean up demo session
store.delete_session(session_id)
print(f" Deleted demo session: {session_id}")
def main():
"""Run all examples."""
print("\n" + "=" * 60)
print("SESSION PERSISTENCE EXAMPLES")
print("=" * 60)
# Run examples
example_basic_persistence()
example_session_restoration()
example_in_memory_only()
example_direct_store_access()
print("\n" + "=" * 60)
print("ALL EXAMPLES COMPLETE")
print("=" * 60)
print("\nSession files are stored in: ~/.praison/sessions/")
print("You can inspect them with: cat ~/.praison/sessions/<session_id>.json")
if __name__ == "__main__":
main()
+30
View File
@@ -0,0 +1,30 @@
"""
Basic TUI Usage Example for PraisonAI.
This example demonstrates how to launch the interactive TUI.
"""
import os
# Ensure API key is set
if not os.environ.get("OPENAI_API_KEY"):
print("Please set OPENAI_API_KEY environment variable")
exit(1)
def main():
"""Launch the TUI."""
try:
from praisonai.cli.features.tui import run_tui
# Launch with default settings
run_tui()
except ImportError:
print("Textual is required for TUI. Install with:")
print(" pip install praisonai[tui]")
exit(1)
if __name__ == "__main__":
main()
+134
View File
@@ -0,0 +1,134 @@
"""
Headless TUI Simulation Example for PraisonAI.
This example demonstrates how to run TUI simulations without
launching the interactive UI - useful for testing and CI.
"""
import asyncio
from praisonai.cli.features.tui import (
TuiOrchestrator,
SimulationRunner,
MockProvider,
)
from praisonai.cli.features.tui.orchestrator import OutputMode
from praisonai.cli.features.queue import QueueConfig
async def run_simulation():
"""Run a headless simulation."""
# Create orchestrator with mock provider (no real API calls)
config = QueueConfig(enable_persistence=False)
orchestrator = TuiOrchestrator(
queue_config=config,
output_mode=OutputMode.PRETTY,
debug=True,
)
# Create simulation runner
runner = SimulationRunner(orchestrator, assert_mode=True)
# Define simulation script
script = {
"session_id": "sim-example",
"model": "gpt-4o-mini",
"steps": [
# Navigate to different screens
{"action": "navigate", "args": {"screen": "main"}},
{"action": "focus", "args": {"widget": "composer"}},
# Change model
{
"action": "model",
"args": {"model": "gpt-4"},
"expected": {"model": "gpt-4"}
},
# Navigate to queue screen
{"action": "navigate", "args": {"screen": "queue"}},
# Take a snapshot
{"action": "snapshot"},
# Navigate back
{"action": "navigate", "args": {"screen": "main"}},
# Small delay
{"action": "sleep", "args": {"seconds": 0.5}},
]
}
# Run the simulation
print("Running headless TUI simulation...")
print("=" * 50)
success = await runner.run_script(script)
# Print results
print("=" * 50)
summary = runner.get_summary()
print(f"\nSimulation {'PASSED' if success else 'FAILED'}")
print(f" Assertions passed: {summary['assertions_passed']}")
print(f" Assertions failed: {summary['assertions_failed']}")
if summary['errors']:
print("\nErrors:")
for error in summary['errors']:
print(f" - {error}")
return success
async def run_with_events():
"""Run simulation and capture all events."""
config = QueueConfig(enable_persistence=False)
orchestrator = TuiOrchestrator(
queue_config=config,
output_mode=OutputMode.SILENT,
)
# Capture events
events = []
orchestrator.add_event_callback(lambda e: events.append(e))
await orchestrator.start(session_id="event-capture")
# Perform some actions
orchestrator.set_model("gpt-4")
orchestrator.navigate_screen("queue")
orchestrator.set_focus("queue-panel")
orchestrator.navigate_screen("main")
await orchestrator.stop()
# Print captured events
print("\nCaptured Events:")
print("-" * 40)
for event in events:
print(f" {event.event_type.value}")
return events
def main():
"""Main entry point."""
print("PraisonAI TUI Headless Simulation Example")
print("=" * 50)
# Run simulation
success = asyncio.run(run_simulation())
# Run event capture
print("\n" + "=" * 50)
events = asyncio.run(run_with_events())
print(f"\nTotal events captured: {len(events)}")
return 0 if success else 1
if __name__ == "__main__":
exit(main())
+122
View File
@@ -0,0 +1,122 @@
"""
Queue Management Example for PraisonAI.
This example demonstrates how to use the queue system programmatically.
"""
import asyncio
from praisonai.cli.features.queue import (
QueueManager,
QueueConfig,
QueuedRun,
RunState,
RunPriority,
)
async def main():
"""Demonstrate queue management."""
print("PraisonAI Queue Management Example")
print("=" * 50)
# Create queue configuration
config = QueueConfig(
max_concurrent_global=2,
max_concurrent_per_agent=1,
max_queue_size=10,
enable_persistence=False, # Use in-memory for demo
)
# Track outputs
outputs = {}
async def on_output(run_id: str, chunk: str):
if run_id not in outputs:
outputs[run_id] = ""
outputs[run_id] += chunk
print(f" [{run_id[:8]}] {chunk}", end="", flush=True)
async def on_complete(run_id: str, run: QueuedRun):
print(f"\n [{run_id[:8]}] Completed: {run.state.value}")
async def on_error(run_id: str, error: Exception):
print(f"\n [{run_id[:8]}] Error: {error}")
# Create queue manager
manager = QueueManager(
config=config,
on_output=on_output,
on_complete=on_complete,
on_error=on_error,
)
# Start the manager
await manager.start(recover=False)
print("Queue manager started")
# Submit some runs with different priorities
print("\nSubmitting runs...")
run1 = await manager.submit(
input_content="Low priority task",
agent_name="Agent1",
priority=RunPriority.LOW,
)
print(f" Submitted run1: {run1} (LOW priority)")
run2 = await manager.submit(
input_content="High priority task",
agent_name="Agent2",
priority=RunPriority.HIGH,
)
print(f" Submitted run2: {run2} (HIGH priority)")
run3 = await manager.submit(
input_content="Normal priority task",
agent_name="Agent1",
priority=RunPriority.NORMAL,
)
print(f" Submitted run3: {run3} (NORMAL priority)")
# Check queue status
print(f"\nQueue status:")
print(f" Queued: {manager.queued_count}")
print(f" Running: {manager.running_count}")
# List runs
runs = manager.list_runs()
print(f"\nAll runs ({len(runs)}):")
for run in runs:
print(f" {run.run_id[:8]}: {run.agent_name} - {run.state.value} ({run.priority.name})")
# Wait a bit for processing
print("\nWaiting for processing...")
await asyncio.sleep(2)
# Cancel a run
if runs:
cancel_id = runs[-1].run_id
print(f"\nCancelling run: {cancel_id[:8]}")
await manager.cancel(cancel_id)
# Get stats
stats = manager.get_stats()
print(f"\nQueue statistics:")
print(f" Total runs: {stats.total_runs}")
print(f" Queued: {stats.queued_count}")
print(f" Running: {stats.running_count}")
print(f" Succeeded: {stats.succeeded_count}")
print(f" Failed: {stats.failed_count}")
print(f" Cancelled: {stats.cancelled_count}")
# Stop the manager
await manager.stop()
print("\nQueue manager stopped")
print("\n" + "=" * 50)
print("Example complete!")
if __name__ == "__main__":
asyncio.run(main())
@@ -687,6 +687,11 @@ Your Goal: {self.goal}
self._db = db
self._session_id = session_id
self._db_initialized = False
# Session store for JSON-based persistence (lazy - no imports until used)
# Used when session_id is provided but no DB adapter
self._session_store = None
self._session_store_initialized = False
@property
def console(self):
@@ -2094,6 +2099,49 @@ Your Goal: {self.goal}"""
self._db_initialized = True
self._current_run_id = None # Track current run
def _init_session_store(self):
"""
Initialize session store for JSON-based persistence (lazy, first chat only).
This is used when session_id is provided but no DB adapter.
Enables automatic session persistence with zero configuration.
"""
if self._session_store_initialized:
return
# Only initialize if session_id is provided and no DB adapter
if self._session_id is None or self._db is not None:
self._session_store_initialized = True
return
try:
from ..session import get_default_session_store
self._session_store = get_default_session_store()
# Restore chat history from previous session
history = self._session_store.get_chat_history(self._session_id)
if history:
# Only restore if chat_history is empty (avoid duplicates)
if not self.chat_history:
for msg in history:
self.chat_history.append({
"role": msg["role"],
"content": msg["content"]
})
logging.info(f"Restored session {self._session_id} with {len(history)} messages from JSON store")
# Set agent info
self._session_store.set_agent_info(
self._session_id,
agent_name=self.name,
user_id=self.user_id,
)
except Exception as e:
logging.warning(f"Failed to initialize session store: {e}")
self._session_store = None
self._session_store_initialized = True
def _start_run(self, input_content: str):
"""Start a new run (turn) for persistence tracking."""
@@ -2135,17 +2183,27 @@ Your Goal: {self.goal}"""
self._current_run_id = None
def _persist_message(self, role: str, content: str):
"""Persist a message to the DB if adapter is provided."""
if self._db is None:
"""Persist a message to the DB or session store."""
# Try DB adapter first
if self._db is not None:
try:
if role == "user":
self._db.on_user_message(self._session_id, content)
elif role == "assistant":
self._db.on_agent_message(self._session_id, content)
except Exception as e:
logging.warning(f"Failed to persist message to DB: {e}")
return
try:
if role == "user":
self._db.on_user_message(self._session_id, content)
elif role == "assistant":
self._db.on_agent_message(self._session_id, content)
except Exception as e:
logging.warning(f"Failed to persist message: {e}")
# Fall back to session store (JSON-based)
if self._session_store is not None and self._session_id is not None:
try:
if role == "user":
self._session_store.add_user_message(self._session_id, content)
elif role == "assistant":
self._session_store.add_assistant_message(self._session_id, content)
except Exception as e:
logging.warning(f"Failed to persist message to session store: {e}")
@property
def session_id(self) -> Optional[str]:
@@ -2160,6 +2218,10 @@ Your Goal: {self.goal}"""
# Initialize DB session on first chat (lazy)
self._init_db_session()
# Initialize session store for JSON-based persistence (lazy)
# This enables automatic session persistence when session_id is provided
self._init_session_store()
# Start a new run for this chat turn
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
self._start_run(prompt_str)
+22 -13
View File
@@ -58,6 +58,12 @@ class LLM:
# Class-level flag for one-time logging configuration
_logging_configured = False
# Class-level cache for LiteLLM module (avoids repeated import overhead)
_litellm_module = None
# Class-level flag for LiteLLM global settings (set once)
_litellm_configured = False
# Default window sizes for different models (75% of actual to be safe)
MODEL_WINDOWS = {
# OpenAI
@@ -272,14 +278,16 @@ Respond with ONLY a valid JSON tool call in this format:
if not LLM._logging_configured:
LLM._configure_logging()
# Import litellm after logging is configured
try:
import litellm
except ImportError:
raise ImportError(
"LiteLLM is required but not installed. "
"Please install with: pip install 'praisonaiagents[llm]'"
)
# Use cached LiteLLM module or import once (avoids repeated import overhead)
if LLM._litellm_module is None:
try:
import litellm
LLM._litellm_module = litellm
except ImportError:
raise ImportError(
"LiteLLM is required but not installed. "
"Please install with: pip install 'praisonaiagents[llm]'"
)
self.model = model
self.timeout = timeout
@@ -341,11 +349,12 @@ Respond with ONLY a valid JSON tool call in this format:
self._formatted_tools_cache = {}
self._max_cache_size = 100
# Enable error dropping for cleaner output
import litellm
litellm.drop_params = True
# Enable parameter modification for providers like Anthropic
litellm.modify_params = True
# Configure LiteLLM global settings only once (avoids repeated setup cost)
if not LLM._litellm_configured:
litellm = LLM._litellm_module
litellm.drop_params = True
litellm.modify_params = True
LLM._litellm_configured = True
self._setup_event_tracking(events)
# Log all initialization parameters when in debug mode or verbose >= 10
+32 -8
View File
@@ -150,7 +150,10 @@ def _clean_display_content(content: str, max_length: int = 20000) -> str:
return content.strip()
def display_interaction(message, response, markdown=True, generation_time=None, console=None, agent_name=None, agent_role=None, agent_tools=None, task_name=None, task_description=None, task_id=None):
"""Synchronous version of display_interaction."""
"""Synchronous version of display_interaction.
Displays the task/message and response in clean panels with response time in title.
"""
if console is None:
console = Console()
@@ -179,16 +182,18 @@ def display_interaction(message, response, markdown=True, generation_time=None,
task_description=task_description,
task_id=task_id
)
# Rest of the display logic...
# Build response title with time (Agno-style: "Response (1.2s)")
response_title = "Response"
if generation_time:
console.print(Text(f"Response generated in {generation_time:.1f}s", style="dim"))
response_title = f"Response ({generation_time:.1f}s)"
if markdown:
console.print(Panel.fit(Markdown(message), title="Task", border_style="cyan"))
console.print(Panel.fit(Markdown(response), title="Response", border_style="cyan"))
console.print(Panel.fit(Markdown(response), title=response_title, border_style="cyan"))
else:
console.print(Panel.fit(Text(message, style="bold green"), title="Task", border_style="cyan"))
console.print(Panel.fit(Text(response, style="bold blue"), title="Response", border_style="cyan"))
console.print(Panel.fit(Text(response, style="bold blue"), title=response_title, border_style="cyan"))
def display_self_reflection(message: str, console=None):
if not message or not message.strip():
@@ -226,7 +231,16 @@ def display_instruction(message: str, console=None, agent_name: str = None, agen
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
console.print(Panel.fit(Text(message, style="bold blue"), title="Instruction", border_style="cyan"))
def display_tool_call(message: str, console=None):
def display_tool_call(message: str, console=None, tool_name: str = None, tool_input: dict = None, tool_output: str = None):
"""Display tool call information in a consolidated panel.
Args:
message: The tool call message (legacy format)
console: Rich console for output
tool_name: Name of the tool being called
tool_input: Input arguments to the tool
tool_output: Output from the tool (if available)
"""
logging.debug(f"display_tool_call called with message: {repr(message)}")
if not message or not message.strip():
logging.debug("Empty message in display_tool_call, returning early")
@@ -235,11 +249,21 @@ def display_tool_call(message: str, console=None):
logging.debug(f"Cleaned message in display_tool_call: {repr(message)}")
# Execute synchronous callbacks (always, even when console is None)
execute_sync_callback('tool_call', message=message)
execute_sync_callback('tool_call', message=message, tool_name=tool_name, tool_input=tool_input, tool_output=tool_output)
# Only print panel if console is provided (verbose mode)
if console is not None:
console.print(Panel.fit(Text(message, style="bold cyan"), title="Tool Call", border_style="green"))
# Build consolidated tool call display (Agno-style)
if tool_name and tool_input is not None:
# Format: tool_name(arg1=val1, arg2=val2)
args_str = ", ".join(f"{k}={v}" for k, v in tool_input.items()) if tool_input else ""
tool_display = f"• {tool_name}({args_str})"
if tool_output:
tool_display += f"\n → {tool_output}"
console.print(Panel.fit(Text(tool_display, style="bold cyan"), title="Tool Call", border_style="green"))
else:
# Legacy format
console.print(Panel.fit(Text(message, style="bold cyan"), title="Tool Call", border_style="green"))
def display_error(message: str, console=None):
if not message or not message.strip():
@@ -0,0 +1,185 @@
# Session Persistence for PraisonAI Agents
This module provides automatic session persistence with zero configuration.
## Quick Start
```python
from praisonaiagents import Agent
# With session persistence (auto-enabled)
agent = Agent(
name="Assistant",
session_id="my-session-123"
)
agent.start("Hello, my name is Alice")
# Later, in a new process - history is restored automatically
agent = Agent(
name="Assistant",
session_id="my-session-123"
)
agent.start("What is my name?") # Agent remembers: "Alice"
```
## How It Works
When you provide a `session_id` to an Agent:
1. **Automatic Persistence**: Conversation history is automatically saved to disk
2. **Automatic Restoration**: When a new Agent is created with the same `session_id`, history is restored
3. **Zero Configuration**: No database setup required - uses JSON files by default
### Default Storage Location
Sessions are stored in: `~/.praison/sessions/{session_id}.json`
### Session File Format
```json
{
"session_id": "my-session-123",
"messages": [
{"role": "user", "content": "Hello", "timestamp": 1704153600.0},
{"role": "assistant", "content": "Hi there!", "timestamp": 1704153601.5}
],
"created_at": "2026-01-02T04:00:00+00:00",
"updated_at": "2026-01-02T04:01:00+00:00",
"agent_name": "Assistant"
}
```
## Behavior Matrix
| Scenario | Behavior |
|----------|----------|
| `session_id` provided, no DB | JSON persistence (auto) |
| `session_id` provided, with DB | DB adapter used |
| No `session_id`, same Agent instance | In-memory only |
| No `session_id`, new Agent instance | No history |
## Advanced Usage
### Direct Session Store Access
```python
from praisonaiagents.session import get_default_session_store
store = get_default_session_store()
# Add messages
store.add_user_message("session-123", "Hello")
store.add_assistant_message("session-123", "Hi there!")
# Get history
history = store.get_chat_history("session-123")
# [{"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi there!"}]
# List all sessions
sessions = store.list_sessions()
# Delete a session
store.delete_session("session-123")
```
### Custom Session Directory
```python
from praisonaiagents.session import DefaultSessionStore
store = DefaultSessionStore(
session_dir="/custom/path/sessions",
max_messages=200, # Default: 100
lock_timeout=10.0, # Default: 5.0 seconds
)
```
### Using with DB Adapter
When a DB adapter is provided, it takes precedence over JSON persistence:
```python
from praisonaiagents import Agent
from praisonai.db import PostgresAdapter
agent = Agent(
name="Assistant",
session_id="my-session",
db=PostgresAdapter(connection_string="...")
)
```
## Multi-Process Safety
The session store uses file locking to ensure safe concurrent access:
- **Unix**: Uses `fcntl.flock()` for file locking
- **Windows**: Uses `msvcrt.locking()` for file locking
- **Atomic Writes**: Uses temp file + rename to prevent corruption
Multiple processes can safely read/write to the same session.
## Context Caching
For cost optimization, use `prompt_caching=True` with Anthropic models:
```python
agent = Agent(
name="Assistant",
session_id="my-session",
prompt_caching=True, # Enables Anthropic prompt caching
)
```
This caches the system prompt, reducing token costs for repeated conversations.
## API Reference
### DefaultSessionStore
```python
class DefaultSessionStore:
def __init__(
self,
session_dir: Optional[str] = None, # Default: ~/.praison/sessions/
max_messages: int = 100,
lock_timeout: float = 5.0,
): ...
def add_message(self, session_id: str, role: str, content: str) -> bool: ...
def add_user_message(self, session_id: str, content: str) -> bool: ...
def add_assistant_message(self, session_id: str, content: str) -> bool: ...
def get_chat_history(self, session_id: str, max_messages: int = None) -> List[Dict]: ...
def get_session(self, session_id: str) -> SessionData: ...
def clear_session(self, session_id: str) -> bool: ...
def delete_session(self, session_id: str) -> bool: ...
def list_sessions(self, limit: int = 50) -> List[Dict]: ...
def session_exists(self, session_id: str) -> bool: ...
```
### SessionData
```python
@dataclass
class SessionData:
session_id: str
messages: List[SessionMessage]
created_at: str
updated_at: str
agent_name: Optional[str]
user_id: Optional[str]
metadata: Dict[str, Any]
def get_chat_history(self, max_messages: int = None) -> List[Dict[str, str]]: ...
```
### SessionMessage
```python
@dataclass
class SessionMessage:
role: str # "user", "assistant", "system"
content: str
timestamp: float
metadata: Dict[str, Any]
```
@@ -0,0 +1,70 @@
"""
Session persistence module for PraisonAI Agents.
Provides automatic session persistence with zero configuration.
When a session_id is provided to an Agent, conversation history
is automatically persisted to disk and restored on subsequent runs.
Usage:
from praisonaiagents import Agent
# With session persistence (auto-enabled)
agent = Agent(
name="Assistant",
session_id="my-session-123"
)
agent.start("Hello")
# Later, new process - history is restored
agent = Agent(
name="Assistant",
session_id="my-session-123"
)
agent.start("What did I say before?") # Remembers!
Default storage: ~/.praison/sessions/{session_id}.json
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .store import DefaultSessionStore, SessionMessage, SessionData
# Lazy loading for zero import overhead
_module_cache = {}
def __getattr__(name: str):
"""Lazy load session components."""
if name in _module_cache:
return _module_cache[name]
if name == "DefaultSessionStore":
from .store import DefaultSessionStore
_module_cache[name] = DefaultSessionStore
return DefaultSessionStore
if name == "SessionMessage":
from .store import SessionMessage
_module_cache[name] = SessionMessage
return SessionMessage
if name == "SessionData":
from .store import SessionData
_module_cache[name] = SessionData
return SessionData
if name == "get_default_session_store":
from .store import get_default_session_store
_module_cache[name] = get_default_session_store
return get_default_session_store
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"DefaultSessionStore",
"SessionMessage",
"SessionData",
"get_default_session_store",
]
@@ -0,0 +1,521 @@
"""
Default Session Store for PraisonAI Agents.
JSON-based session persistence with file locking and atomic writes.
Zero dependencies beyond stdlib.
"""
import fcntl
import json
import logging
import os
import sys
import tempfile
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Default session directory
DEFAULT_SESSION_DIR = os.path.expanduser("~/.praison/sessions")
# Default limits
DEFAULT_MAX_MESSAGES = 100
DEFAULT_LOCK_TIMEOUT = 5.0 # seconds
@dataclass
class SessionMessage:
"""A single message in a session."""
role: str # "user", "assistant", "system"
content: str
timestamp: float = field(default_factory=time.time)
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"role": self.role,
"content": self.content,
"timestamp": self.timestamp,
"metadata": self.metadata,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SessionMessage":
"""Create from dictionary."""
return cls(
role=data.get("role", "user"),
content=data.get("content", ""),
timestamp=data.get("timestamp", time.time()),
metadata=data.get("metadata", {}),
)
@dataclass
class SessionData:
"""Complete session data structure."""
session_id: str
messages: List[SessionMessage] = field(default_factory=list)
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
updated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
agent_name: Optional[str] = None
user_id: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"session_id": self.session_id,
"messages": [m.to_dict() for m in self.messages],
"created_at": self.created_at,
"updated_at": self.updated_at,
"agent_name": self.agent_name,
"user_id": self.user_id,
"metadata": self.metadata,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SessionData":
"""Create from dictionary."""
messages = [
SessionMessage.from_dict(m)
for m in data.get("messages", [])
]
return cls(
session_id=data.get("session_id", ""),
messages=messages,
created_at=data.get("created_at", datetime.now(timezone.utc).isoformat()),
updated_at=data.get("updated_at", datetime.now(timezone.utc).isoformat()),
agent_name=data.get("agent_name"),
user_id=data.get("user_id"),
metadata=data.get("metadata", {}),
)
def get_chat_history(self, max_messages: Optional[int] = None) -> List[Dict[str, str]]:
"""
Get chat history in LLM-compatible format.
Returns list of {"role": "user/assistant", "content": "..."} dicts.
"""
messages = self.messages
if max_messages and len(messages) > max_messages:
messages = messages[-max_messages:]
return [{"role": m.role, "content": m.content} for m in messages]
class FileLock:
"""
Cross-platform file locking.
Uses fcntl on Unix and msvcrt on Windows.
"""
def __init__(self, filepath: str, timeout: float = DEFAULT_LOCK_TIMEOUT):
self.filepath = filepath
self.timeout = timeout
self._lock_file = None
self._lock_path = filepath + ".lock"
def __enter__(self):
self.acquire()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.release()
return False
def acquire(self) -> bool:
"""Acquire the file lock."""
# Ensure directory exists
os.makedirs(os.path.dirname(self._lock_path) or ".", exist_ok=True)
start_time = time.time()
while True:
try:
self._lock_file = open(self._lock_path, "w")
if sys.platform == "win32":
# Windows locking
import msvcrt
msvcrt.locking(self._lock_file.fileno(), msvcrt.LK_NBLCK, 1)
else:
# Unix locking
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
except (IOError, OSError, BlockingIOError):
if self._lock_file:
self._lock_file.close()
self._lock_file = None
if time.time() - start_time > self.timeout:
logger.warning(f"Failed to acquire lock for {self.filepath} after {self.timeout}s")
return False
time.sleep(0.05) # Wait 50ms before retry
def release(self) -> None:
"""Release the file lock."""
if self._lock_file:
try:
if sys.platform == "win32":
import msvcrt
msvcrt.locking(self._lock_file.fileno(), msvcrt.LK_UNLCK, 1)
else:
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_UN)
except (IOError, OSError):
pass
finally:
self._lock_file.close()
self._lock_file = None
# Clean up lock file
try:
os.remove(self._lock_path)
except (IOError, OSError):
pass
class DefaultSessionStore:
"""
JSON-based session persistence with file locking.
Features:
- Zero configuration required
- Automatic file locking for multi-process safety
- Atomic writes to prevent corruption
- Configurable message limits
- Thread-safe operations
Usage:
store = DefaultSessionStore()
# Add messages
store.add_message("session-123", "user", "Hello")
store.add_message("session-123", "assistant", "Hi there!")
# Get history
history = store.get_chat_history("session-123")
# [{"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi there!"}]
# Restore in new process
store2 = DefaultSessionStore()
history = store2.get_chat_history("session-123") # Same history!
"""
def __init__(
self,
session_dir: Optional[str] = None,
max_messages: int = DEFAULT_MAX_MESSAGES,
lock_timeout: float = DEFAULT_LOCK_TIMEOUT,
):
"""
Initialize session store.
Args:
session_dir: Directory for session files. Defaults to ~/.praison/sessions/
max_messages: Maximum messages to keep per session.
lock_timeout: Timeout for file lock acquisition.
"""
self.session_dir = session_dir or DEFAULT_SESSION_DIR
self.max_messages = max_messages
self.lock_timeout = lock_timeout
self._lock = threading.RLock()
self._cache: Dict[str, SessionData] = {}
# Ensure session directory exists
os.makedirs(self.session_dir, exist_ok=True)
def _get_session_path(self, session_id: str) -> str:
"""Get the file path for a session."""
# Sanitize session_id for filesystem
safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id)
return os.path.join(self.session_dir, f"{safe_id}.json")
def _load_session(self, session_id: str) -> SessionData:
"""Load session from disk with file locking."""
filepath = self._get_session_path(session_id)
# Check cache first
with self._lock:
if session_id in self._cache:
return self._cache[session_id]
# Load from disk
if not os.path.exists(filepath):
session = SessionData(session_id=session_id)
with self._lock:
self._cache[session_id] = session
return session
with FileLock(filepath, self.lock_timeout):
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
session = SessionData.from_dict(data)
except (json.JSONDecodeError, IOError) as e:
logger.warning(f"Failed to load session {session_id}: {e}")
session = SessionData(session_id=session_id)
with self._lock:
self._cache[session_id] = session
return session
def _save_session(self, session: SessionData) -> bool:
"""Save session to disk with atomic write."""
filepath = self._get_session_path(session.session_id)
session.updated_at = datetime.now(timezone.utc).isoformat()
# Trim messages if over limit
if len(session.messages) > self.max_messages:
session.messages = session.messages[-self.max_messages:]
with FileLock(filepath, self.lock_timeout):
try:
# Atomic write: write to temp file, then rename
dir_path = os.path.dirname(filepath)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=dir_path,
delete=False,
suffix=".tmp"
) as f:
json.dump(session.to_dict(), f, indent=2, ensure_ascii=False)
temp_path = f.name
# Atomic rename
os.replace(temp_path, filepath)
return True
except (IOError, OSError) as e:
logger.error(f"Failed to save session {session.session_id}: {e}")
# Clean up temp file if it exists
try:
if 'temp_path' in locals():
os.remove(temp_path)
except (IOError, OSError):
pass
return False
def add_message(
self,
session_id: str,
role: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
"""
Add a message to a session.
Args:
session_id: The session ID.
role: Message role ("user", "assistant", "system").
content: Message content.
metadata: Optional metadata.
Returns:
True if saved successfully.
"""
filepath = self._get_session_path(session_id)
message = SessionMessage(
role=role,
content=content,
timestamp=time.time(),
metadata=metadata or {},
)
# Use file lock for atomic read-modify-write
with FileLock(filepath, self.lock_timeout):
# Always reload from disk inside lock to avoid race conditions
if os.path.exists(filepath):
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
session = SessionData.from_dict(data)
except (json.JSONDecodeError, IOError):
session = SessionData(session_id=session_id)
else:
session = SessionData(session_id=session_id)
# Add message
session.messages.append(message)
session.updated_at = datetime.now(timezone.utc).isoformat()
# Trim messages if over limit
if len(session.messages) > self.max_messages:
session.messages = session.messages[-self.max_messages:]
# Write atomically
try:
dir_path = os.path.dirname(filepath) or "."
os.makedirs(dir_path, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=dir_path,
delete=False,
suffix=".tmp"
) as f:
json.dump(session.to_dict(), f, indent=2, ensure_ascii=False)
temp_path = f.name
os.replace(temp_path, filepath)
# Update cache
with self._lock:
self._cache[session_id] = session
return True
except (IOError, OSError) as e:
logger.error(f"Failed to save session {session_id}: {e}")
try:
if 'temp_path' in locals():
os.remove(temp_path)
except (IOError, OSError):
pass
return False
def add_user_message(
self,
session_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
"""Add a user message to a session."""
return self.add_message(session_id, "user", content, metadata)
def add_assistant_message(
self,
session_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
"""Add an assistant message to a session."""
return self.add_message(session_id, "assistant", content, metadata)
def get_chat_history(
self,
session_id: str,
max_messages: Optional[int] = None,
) -> List[Dict[str, str]]:
"""
Get chat history for a session in LLM-compatible format.
Args:
session_id: The session ID.
max_messages: Maximum messages to return (defaults to store limit).
Returns:
List of {"role": "user/assistant", "content": "..."} dicts.
"""
session = self._load_session(session_id)
limit = max_messages or self.max_messages
return session.get_chat_history(limit)
def get_session(self, session_id: str) -> SessionData:
"""Get full session data."""
return self._load_session(session_id)
def set_agent_info(
self,
session_id: str,
agent_name: Optional[str] = None,
user_id: Optional[str] = None,
) -> bool:
"""Set agent info for a session."""
session = self._load_session(session_id)
with self._lock:
if agent_name:
session.agent_name = agent_name
if user_id:
session.user_id = user_id
self._cache[session_id] = session
return self._save_session(session)
def clear_session(self, session_id: str) -> bool:
"""Clear all messages from a session."""
session = self._load_session(session_id)
with self._lock:
session.messages.clear()
self._cache[session_id] = session
return self._save_session(session)
def delete_session(self, session_id: str) -> bool:
"""Delete a session completely."""
filepath = self._get_session_path(session_id)
with self._lock:
if session_id in self._cache:
del self._cache[session_id]
try:
if os.path.exists(filepath):
os.remove(filepath)
return True
except (IOError, OSError) as e:
logger.error(f"Failed to delete session {session_id}: {e}")
return False
def list_sessions(self, limit: int = 50) -> List[Dict[str, Any]]:
"""List all sessions with metadata."""
sessions = []
try:
for filename in os.listdir(self.session_dir):
if filename.endswith(".json"):
filepath = os.path.join(self.session_dir, filename)
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
sessions.append({
"session_id": data.get("session_id", filename[:-5]),
"agent_name": data.get("agent_name"),
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"message_count": len(data.get("messages", [])),
})
except (json.JSONDecodeError, IOError):
continue
except (IOError, OSError):
pass
# Sort by updated_at descending
sessions.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
return sessions[:limit]
def session_exists(self, session_id: str) -> bool:
"""Check if a session exists."""
filepath = self._get_session_path(session_id)
return os.path.exists(filepath)
def invalidate_cache(self, session_id: Optional[str] = None) -> None:
"""Invalidate cache for a session or all sessions."""
with self._lock:
if session_id:
self._cache.pop(session_id, None)
else:
self._cache.clear()
# Global session store instance (lazy initialized)
_default_store: Optional[DefaultSessionStore] = None
_store_lock = threading.Lock()
def get_default_session_store() -> DefaultSessionStore:
"""Get the global default session store instance."""
global _default_store
if _default_store is None:
with _store_lock:
if _default_store is None:
_default_store = DefaultSessionStore()
return _default_store
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.5.2"
version = "0.6.2"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1 @@
# Session module tests
@@ -0,0 +1,404 @@
"""
TDD Tests for Session Store.
Tests for DefaultSessionStore with JSON-based persistence.
"""
import json
import os
import tempfile
import threading
import time
import pytest
from concurrent.futures import ThreadPoolExecutor
from praisonaiagents.session.store import (
DefaultSessionStore,
SessionMessage,
SessionData,
FileLock,
DEFAULT_MAX_MESSAGES,
)
class TestSessionMessage:
"""Tests for SessionMessage dataclass."""
def test_create_message(self):
"""Test creating a session message."""
msg = SessionMessage(role="user", content="Hello")
assert msg.role == "user"
assert msg.content == "Hello"
assert msg.timestamp > 0
assert msg.metadata == {}
def test_message_to_dict(self):
"""Test converting message to dict."""
msg = SessionMessage(role="assistant", content="Hi there!")
d = msg.to_dict()
assert d["role"] == "assistant"
assert d["content"] == "Hi there!"
assert "timestamp" in d
assert "metadata" in d
def test_message_from_dict(self):
"""Test creating message from dict."""
d = {"role": "user", "content": "Test", "timestamp": 123.0, "metadata": {"key": "value"}}
msg = SessionMessage.from_dict(d)
assert msg.role == "user"
assert msg.content == "Test"
assert msg.timestamp == 123.0
assert msg.metadata == {"key": "value"}
class TestSessionData:
"""Tests for SessionData dataclass."""
def test_create_session(self):
"""Test creating session data."""
session = SessionData(session_id="test-123")
assert session.session_id == "test-123"
assert session.messages == []
assert session.created_at is not None
assert session.updated_at is not None
def test_session_to_dict(self):
"""Test converting session to dict."""
session = SessionData(session_id="test-123", agent_name="Assistant")
session.messages.append(SessionMessage(role="user", content="Hello"))
d = session.to_dict()
assert d["session_id"] == "test-123"
assert d["agent_name"] == "Assistant"
assert len(d["messages"]) == 1
def test_session_from_dict(self):
"""Test creating session from dict."""
d = {
"session_id": "test-456",
"messages": [{"role": "user", "content": "Hi"}],
"agent_name": "Bot",
}
session = SessionData.from_dict(d)
assert session.session_id == "test-456"
assert session.agent_name == "Bot"
assert len(session.messages) == 1
def test_get_chat_history(self):
"""Test getting chat history in LLM format."""
session = SessionData(session_id="test")
session.messages.append(SessionMessage(role="user", content="Hello"))
session.messages.append(SessionMessage(role="assistant", content="Hi!"))
history = session.get_chat_history()
assert len(history) == 2
assert history[0] == {"role": "user", "content": "Hello"}
assert history[1] == {"role": "assistant", "content": "Hi!"}
def test_get_chat_history_with_limit(self):
"""Test getting limited chat history."""
session = SessionData(session_id="test")
for i in range(10):
session.messages.append(SessionMessage(role="user", content=f"Message {i}"))
history = session.get_chat_history(max_messages=3)
assert len(history) == 3
assert history[0]["content"] == "Message 7"
assert history[2]["content"] == "Message 9"
class TestFileLock:
"""Tests for FileLock."""
def test_acquire_release(self):
"""Test basic lock acquire and release."""
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, "test.json")
lock = FileLock(filepath, timeout=1.0)
assert lock.acquire()
lock.release()
def test_context_manager(self):
"""Test lock as context manager."""
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, "test.json")
with FileLock(filepath, timeout=1.0):
# Lock is held
pass
# Lock is released
def test_concurrent_locks(self):
"""Test that concurrent locks are serialized."""
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, "test.json")
results = []
def worker(worker_id):
with FileLock(filepath, timeout=5.0):
results.append(f"start-{worker_id}")
time.sleep(0.1)
results.append(f"end-{worker_id}")
threads = [threading.Thread(target=worker, args=(i,)) for i in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()
# Check that starts and ends are paired (no interleaving)
for i in range(3):
start_idx = results.index(f"start-{i}")
end_idx = results.index(f"end-{i}")
# No other start should be between this start and end
for j in range(3):
if i != j:
other_start = results.index(f"start-{j}")
if start_idx < other_start < end_idx:
pytest.fail(f"Lock interleaving detected: {results}")
class TestDefaultSessionStore:
"""Tests for DefaultSessionStore."""
@pytest.fixture
def temp_store(self):
"""Create a temporary session store."""
with tempfile.TemporaryDirectory() as tmpdir:
store = DefaultSessionStore(session_dir=tmpdir)
yield store
def test_add_message(self, temp_store):
"""Test adding a message."""
result = temp_store.add_message("session-1", "user", "Hello")
assert result is True
history = temp_store.get_chat_history("session-1")
assert len(history) == 1
assert history[0]["role"] == "user"
assert history[0]["content"] == "Hello"
def test_add_user_message(self, temp_store):
"""Test adding a user message."""
temp_store.add_user_message("session-1", "Hello")
history = temp_store.get_chat_history("session-1")
assert history[0]["role"] == "user"
def test_add_assistant_message(self, temp_store):
"""Test adding an assistant message."""
temp_store.add_assistant_message("session-1", "Hi there!")
history = temp_store.get_chat_history("session-1")
assert history[0]["role"] == "assistant"
def test_persistence_across_instances(self, temp_store):
"""Test that sessions persist across store instances."""
session_dir = temp_store.session_dir
# Add messages with first store
temp_store.add_user_message("session-1", "Hello")
temp_store.add_assistant_message("session-1", "Hi!")
# Create new store instance
store2 = DefaultSessionStore(session_dir=session_dir)
# Verify messages are restored
history = store2.get_chat_history("session-1")
assert len(history) == 2
assert history[0]["content"] == "Hello"
assert history[1]["content"] == "Hi!"
def test_session_file_created(self, temp_store):
"""Test that session file is created on disk."""
temp_store.add_user_message("test-session", "Hello")
filepath = os.path.join(temp_store.session_dir, "test-session.json")
assert os.path.exists(filepath)
with open(filepath, "r") as f:
data = json.load(f)
assert data["session_id"] == "test-session"
assert len(data["messages"]) == 1
def test_max_messages_limit(self):
"""Test that messages are trimmed to max limit."""
with tempfile.TemporaryDirectory() as tmpdir:
store = DefaultSessionStore(session_dir=tmpdir, max_messages=5)
for i in range(10):
store.add_user_message("session-1", f"Message {i}")
history = store.get_chat_history("session-1")
assert len(history) == 5
assert history[0]["content"] == "Message 5"
assert history[4]["content"] == "Message 9"
def test_clear_session(self, temp_store):
"""Test clearing a session."""
temp_store.add_user_message("session-1", "Hello")
temp_store.add_assistant_message("session-1", "Hi!")
temp_store.clear_session("session-1")
history = temp_store.get_chat_history("session-1")
assert len(history) == 0
def test_delete_session(self, temp_store):
"""Test deleting a session."""
temp_store.add_user_message("session-1", "Hello")
filepath = os.path.join(temp_store.session_dir, "session-1.json")
assert os.path.exists(filepath)
temp_store.delete_session("session-1")
assert not os.path.exists(filepath)
def test_list_sessions(self, temp_store):
"""Test listing sessions."""
temp_store.add_user_message("session-1", "Hello")
temp_store.add_user_message("session-2", "Hi")
temp_store.add_user_message("session-3", "Hey")
sessions = temp_store.list_sessions()
assert len(sessions) == 3
session_ids = [s["session_id"] for s in sessions]
assert "session-1" in session_ids
assert "session-2" in session_ids
assert "session-3" in session_ids
def test_session_exists(self, temp_store):
"""Test checking if session exists."""
assert not temp_store.session_exists("session-1")
temp_store.add_user_message("session-1", "Hello")
assert temp_store.session_exists("session-1")
def test_set_agent_info(self, temp_store):
"""Test setting agent info."""
temp_store.add_user_message("session-1", "Hello")
temp_store.set_agent_info("session-1", agent_name="TestBot", user_id="user-123")
session = temp_store.get_session("session-1")
assert session.agent_name == "TestBot"
assert session.user_id == "user-123"
def test_concurrent_writes(self, temp_store):
"""Test concurrent writes to same session."""
def writer(msg_id):
temp_store.add_user_message("session-1", f"Message {msg_id}")
with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(writer, i) for i in range(10)]
for f in futures:
f.result()
history = temp_store.get_chat_history("session-1")
assert len(history) == 10
def test_sanitize_session_id(self, temp_store):
"""Test that session IDs are sanitized for filesystem."""
temp_store.add_user_message("session/with/slashes", "Hello")
temp_store.add_user_message("session:with:colons", "Hi")
# Should not raise errors
history1 = temp_store.get_chat_history("session/with/slashes")
history2 = temp_store.get_chat_history("session:with:colons")
assert len(history1) == 1
assert len(history2) == 1
def test_empty_session_returns_empty_history(self, temp_store):
"""Test that non-existent session returns empty history."""
history = temp_store.get_chat_history("nonexistent")
assert history == []
def test_invalidate_cache(self, temp_store):
"""Test cache invalidation."""
temp_store.add_user_message("session-1", "Hello")
# Modify file directly
filepath = os.path.join(temp_store.session_dir, "session-1.json")
with open(filepath, "r") as f:
data = json.load(f)
data["messages"].append({"role": "user", "content": "Direct edit", "timestamp": time.time(), "metadata": {}})
with open(filepath, "w") as f:
json.dump(data, f)
# Without invalidation, cache returns old data
history = temp_store.get_chat_history("session-1")
assert len(history) == 1 # Cached
# After invalidation, new data is loaded
temp_store.invalidate_cache("session-1")
history = temp_store.get_chat_history("session-1")
assert len(history) == 2
class TestAgentSessionIntegration:
"""Tests for Agent integration with session store."""
def test_agent_session_persistence(self):
"""Test that Agent persists session with session_id."""
from praisonaiagents import Agent
with tempfile.TemporaryDirectory() as tmpdir:
# Patch the default session directory
import praisonaiagents.session.store as store_module
original_dir = store_module.DEFAULT_SESSION_DIR
store_module.DEFAULT_SESSION_DIR = tmpdir
# Reset global store
store_module._default_store = None
try:
# Create agent with session_id
agent = Agent(
name="TestAgent",
instructions="You are a test agent.",
session_id="test-session-123",
verbose=False,
)
# Verify session store is initialized after first chat
# (We can't actually call chat without API key, so we test init)
agent._init_session_store()
assert agent._session_store is not None
assert agent._session_store_initialized is True
finally:
store_module.DEFAULT_SESSION_DIR = original_dir
store_module._default_store = None
def test_agent_no_session_id_no_persistence(self):
"""Test that Agent without session_id doesn't use session store."""
from praisonaiagents import Agent
agent = Agent(
name="TestAgent",
instructions="You are a test agent.",
verbose=False,
)
agent._init_session_store()
assert agent._session_store is None
assert agent._session_store_initialized is True
def test_agent_with_db_no_session_store(self):
"""Test that Agent with DB adapter doesn't use session store."""
from praisonaiagents import Agent
# Mock DB adapter
class MockDbAdapter:
def on_agent_start(self, **kwargs):
return []
agent = Agent(
name="TestAgent",
instructions="You are a test agent.",
session_id="test-session",
db=MockDbAdapter(),
verbose=False,
)
agent._init_session_store()
# Session store should not be used when DB is provided
assert agent._session_store is None
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.5.2"
version = "0.6.2"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.9.0.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.9.0.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.1.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.1.2.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+8 -3
View File
@@ -1,10 +1,15 @@
# __main__.py
"""
PraisonAI CLI Entry Point.
from .cli import PraisonAI
Supports both new Typer-based CLI and legacy argparse CLI.
"""
def main():
praison_ai = PraisonAI()
praison_ai.main()
"""Main entry point with legacy support."""
from .cli.legacy import main_with_legacy_support
main_with_legacy_support()
if __name__ == "__main__":
main()
+442
View File
@@ -0,0 +1,442 @@
"""
Safe Edit Pipeline for ACP.
Implements a propose → diff → approve → apply workflow for file edits.
Ensures no silent file writes - all changes require explicit approval.
"""
import difflib
import logging
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
class EditStatus(str, Enum):
"""Status of an edit proposal."""
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
APPLIED = "applied"
FAILED = "failed"
@dataclass
class EditProposal:
"""
A proposed file edit with diff preview.
Represents a change that has been proposed but not yet applied.
"""
proposal_id: str
file_path: Path
original_content: str
proposed_content: str
description: str
diff_lines: List[str] = field(default_factory=list)
status: EditStatus = EditStatus.PENDING
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
approved_at: Optional[str] = None
applied_at: Optional[str] = None
error: Optional[str] = None
def __post_init__(self):
"""Generate diff on creation."""
if not self.diff_lines:
self.diff_lines = self._generate_diff()
def _generate_diff(self) -> List[str]:
"""Generate unified diff between original and proposed content."""
original_lines = self.original_content.splitlines(keepends=True)
proposed_lines = self.proposed_content.splitlines(keepends=True)
diff = difflib.unified_diff(
original_lines,
proposed_lines,
fromfile=f"a/{self.file_path.name}",
tofile=f"b/{self.file_path.name}",
lineterm=""
)
return list(diff)
@property
def diff_text(self) -> str:
"""Get diff as a single string."""
return "\n".join(self.diff_lines)
@property
def is_new_file(self) -> bool:
"""Check if this is a new file creation."""
return not self.original_content
@property
def is_deletion(self) -> bool:
"""Check if this is a file deletion."""
return not self.proposed_content and bool(self.original_content)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"proposal_id": self.proposal_id,
"file_path": str(self.file_path),
"description": self.description,
"status": self.status.value,
"is_new_file": self.is_new_file,
"is_deletion": self.is_deletion,
"diff": self.diff_text,
"created_at": self.created_at,
"approved_at": self.approved_at,
"applied_at": self.applied_at,
"error": self.error,
}
class SafeEditPipeline:
"""
Safe file editing with diff preview and approval workflow.
Workflow:
1. propose_edit() - Create an edit proposal with diff
2. show_diff() - Display the diff to user
3. approve() / reject() - User decision
4. apply_edit() - Apply approved edit
ACP remains read-only by default. Writes only happen after explicit approval.
"""
def __init__(
self,
workspace: Optional[Path] = None,
auto_approve: bool = False,
approval_callback: Optional[callable] = None,
):
"""
Initialize safe edit pipeline.
Args:
workspace: Root workspace directory for path validation
auto_approve: If True, auto-approve all edits (dangerous, for testing only)
approval_callback: Optional callback for approval (async or sync)
"""
self.workspace = Path(workspace) if workspace else Path.cwd()
self.auto_approve = auto_approve
self.approval_callback = approval_callback
self._proposals: Dict[str, EditProposal] = {}
def _validate_path(self, file_path: Path) -> Path:
"""
Validate and resolve file path.
Ensures path is within workspace and resolves to absolute path.
"""
if not file_path.is_absolute():
file_path = self.workspace / file_path
resolved = file_path.resolve()
# Security check: ensure path is within workspace
try:
resolved.relative_to(self.workspace.resolve())
except ValueError:
raise ValueError(f"Path {file_path} is outside workspace {self.workspace}")
return resolved
def propose_edit(
self,
file_path: Path,
new_content: str,
description: str,
) -> EditProposal:
"""
Create an edit proposal with diff preview.
Args:
file_path: Path to file to edit
new_content: Proposed new content
description: Human-readable description of the change
Returns:
EditProposal with diff
"""
resolved_path = self._validate_path(Path(file_path))
# Read original content if file exists
original_content = ""
if resolved_path.exists():
try:
original_content = resolved_path.read_text()
except Exception as e:
logger.warning(f"Could not read {resolved_path}: {e}")
# Create proposal
proposal = EditProposal(
proposal_id=str(uuid.uuid4())[:8],
file_path=resolved_path,
original_content=original_content,
proposed_content=new_content,
description=description,
)
self._proposals[proposal.proposal_id] = proposal
logger.info(f"Created edit proposal {proposal.proposal_id} for {file_path}")
return proposal
def propose_create(
self,
file_path: Path,
content: str,
description: str,
) -> EditProposal:
"""
Create a proposal for a new file.
Args:
file_path: Path for new file
content: File content
description: Description of the new file
Returns:
EditProposal for file creation
"""
return self.propose_edit(file_path, content, description)
def propose_delete(
self,
file_path: Path,
description: str,
) -> EditProposal:
"""
Create a proposal to delete a file.
Args:
file_path: Path to file to delete
description: Reason for deletion
Returns:
EditProposal for file deletion
"""
return self.propose_edit(file_path, "", description)
def get_proposal(self, proposal_id: str) -> Optional[EditProposal]:
"""Get a proposal by ID."""
return self._proposals.get(proposal_id)
def list_proposals(self, status: Optional[EditStatus] = None) -> List[EditProposal]:
"""
List all proposals, optionally filtered by status.
Args:
status: Optional status filter
Returns:
List of proposals
"""
proposals = list(self._proposals.values())
if status:
proposals = [p for p in proposals if p.status == status]
return proposals
def approve(self, proposal_id: str) -> bool:
"""
Approve an edit proposal.
Args:
proposal_id: ID of proposal to approve
Returns:
True if approved, False if not found or already processed
"""
proposal = self._proposals.get(proposal_id)
if not proposal:
logger.warning(f"Proposal {proposal_id} not found")
return False
if proposal.status != EditStatus.PENDING:
logger.warning(f"Proposal {proposal_id} is not pending (status: {proposal.status})")
return False
proposal.status = EditStatus.APPROVED
proposal.approved_at = datetime.now().isoformat()
logger.info(f"Approved proposal {proposal_id}")
return True
def reject(self, proposal_id: str, reason: Optional[str] = None) -> bool:
"""
Reject an edit proposal.
Args:
proposal_id: ID of proposal to reject
reason: Optional rejection reason
Returns:
True if rejected, False if not found or already processed
"""
proposal = self._proposals.get(proposal_id)
if not proposal:
logger.warning(f"Proposal {proposal_id} not found")
return False
if proposal.status != EditStatus.PENDING:
logger.warning(f"Proposal {proposal_id} is not pending (status: {proposal.status})")
return False
proposal.status = EditStatus.REJECTED
proposal.error = reason
logger.info(f"Rejected proposal {proposal_id}: {reason}")
return True
def apply_edit(self, proposal_id: str) -> bool:
"""
Apply an approved edit.
Args:
proposal_id: ID of approved proposal to apply
Returns:
True if applied successfully, False otherwise
"""
proposal = self._proposals.get(proposal_id)
if not proposal:
logger.error(f"Proposal {proposal_id} not found")
return False
if proposal.status != EditStatus.APPROVED:
logger.error(f"Proposal {proposal_id} is not approved (status: {proposal.status})")
return False
try:
if proposal.is_deletion:
# Delete file
if proposal.file_path.exists():
proposal.file_path.unlink()
logger.info(f"Deleted {proposal.file_path}")
else:
# Create parent directories if needed
proposal.file_path.parent.mkdir(parents=True, exist_ok=True)
# Write new content
proposal.file_path.write_text(proposal.proposed_content)
logger.info(f"Applied edit to {proposal.file_path}")
proposal.status = EditStatus.APPLIED
proposal.applied_at = datetime.now().isoformat()
return True
except Exception as e:
logger.error(f"Failed to apply proposal {proposal_id}: {e}")
proposal.status = EditStatus.FAILED
proposal.error = str(e)
return False
def propose_and_apply(
self,
file_path: Path,
new_content: str,
description: str,
) -> EditProposal:
"""
Convenience method: propose, auto-approve if enabled, and apply.
Only applies if auto_approve is True or approval_callback returns True.
Args:
file_path: Path to file
new_content: New content
description: Description
Returns:
EditProposal (may be pending, approved, or applied)
"""
proposal = self.propose_edit(file_path, new_content, description)
if self.auto_approve:
self.approve(proposal.proposal_id)
self.apply_edit(proposal.proposal_id)
elif self.approval_callback:
try:
if self.approval_callback(proposal):
self.approve(proposal.proposal_id)
self.apply_edit(proposal.proposal_id)
else:
self.reject(proposal.proposal_id, "Rejected by callback")
except Exception as e:
logger.error(f"Approval callback failed: {e}")
proposal.error = str(e)
return proposal
def format_diff_for_display(self, proposal: EditProposal) -> str:
"""
Format diff for terminal display with colors.
Args:
proposal: Edit proposal
Returns:
Formatted diff string with ANSI colors
"""
lines = []
lines.append(f"📝 Edit Proposal: {proposal.proposal_id}")
lines.append(f" File: {proposal.file_path}")
lines.append(f" Description: {proposal.description}")
lines.append(f" Status: {proposal.status.value}")
lines.append("")
if proposal.is_new_file:
lines.append(" [NEW FILE]")
elif proposal.is_deletion:
lines.append(" [DELETE FILE]")
lines.append(" --- Diff ---")
for line in proposal.diff_lines:
if line.startswith("+") and not line.startswith("+++"):
lines.append(f" \033[32m{line}\033[0m") # Green for additions
elif line.startswith("-") and not line.startswith("---"):
lines.append(f" \033[31m{line}\033[0m") # Red for deletions
elif line.startswith("@@"):
lines.append(f" \033[36m{line}\033[0m") # Cyan for line numbers
else:
lines.append(f" {line}")
return "\n".join(lines)
def clear_proposals(self, status: Optional[EditStatus] = None) -> int:
"""
Clear proposals, optionally filtered by status.
Args:
status: Optional status filter (None = clear all)
Returns:
Number of proposals cleared
"""
if status is None:
count = len(self._proposals)
self._proposals.clear()
else:
to_remove = [pid for pid, p in self._proposals.items() if p.status == status]
for pid in to_remove:
del self._proposals[pid]
count = len(to_remove)
logger.info(f"Cleared {count} proposals")
return count
# Global pipeline instance for ACP
_safe_edit_pipeline: Optional[SafeEditPipeline] = None
def get_safe_edit_pipeline(workspace: Optional[Path] = None) -> SafeEditPipeline:
"""Get or create the global safe edit pipeline."""
global _safe_edit_pipeline
if _safe_edit_pipeline is None:
_safe_edit_pipeline = SafeEditPipeline(workspace=workspace)
return _safe_edit_pipeline
+271
View File
@@ -0,0 +1,271 @@
"""
PraisonAI CLI Typer Application.
Main Typer app that registers all command groups and handles global options.
"""
import os
import sys
from enum import Enum
from typing import Optional
import typer
from .output.console import OutputController, OutputMode, set_output_controller
from .state.identifiers import create_context
class OutputFormat(str, Enum):
"""Output format options."""
text = "text"
json = "json"
stream_json = "stream-json"
# Create main Typer app
app = typer.Typer(
name="praisonai",
help="PraisonAI - AI Agents Framework CLI",
add_completion=False, # We handle completion manually
no_args_is_help=False, # Allow running without args for legacy compatibility
rich_markup_mode="rich",
)
# Global state for options
class GlobalState:
"""Global state for CLI options."""
output_format: OutputFormat = OutputFormat.text
no_color: bool = False
quiet: bool = False
verbose: bool = False
screen_reader: bool = False
output_controller: Optional[OutputController] = None
state = GlobalState()
def version_callback(value: bool):
"""Show version and exit."""
if value:
from praisonai.version import __version__
typer.echo(f"PraisonAI version {__version__}")
raise typer.Exit()
@app.callback(invoke_without_command=True)
def main_callback(
ctx: typer.Context,
version: bool = typer.Option(
False,
"--version",
"-V",
callback=version_callback,
is_eager=True,
help="Show version and exit",
),
output_format: OutputFormat = typer.Option(
OutputFormat.text,
"--output-format",
"-o",
help="Output format",
envvar="PRAISONAI_OUTPUT_FORMAT",
),
json_output: bool = typer.Option(
False,
"--json",
help="Output in JSON format (alias for --output-format json)",
),
no_color: bool = typer.Option(
False,
"--no-color",
help="Disable colored output",
envvar="NO_COLOR",
),
quiet: bool = typer.Option(
False,
"--quiet",
"-q",
help="Minimal output",
),
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
help="Verbose output with debug details",
),
screen_reader: bool = typer.Option(
False,
"--screen-reader",
help="Screen reader friendly output (no spinners/panels)",
),
):
"""
PraisonAI - AI Agents Framework CLI.
Run agents, manage configuration, and more.
"""
# Store global options
state.output_format = output_format
state.no_color = no_color
state.quiet = quiet
state.verbose = verbose
state.screen_reader = screen_reader
# Handle --json alias
if json_output:
state.output_format = OutputFormat.json
# Determine output mode
if state.quiet:
mode = OutputMode.QUIET
elif state.verbose:
mode = OutputMode.VERBOSE
elif state.screen_reader:
mode = OutputMode.SCREEN_READER
elif state.output_format == OutputFormat.json:
mode = OutputMode.JSON
elif state.output_format == OutputFormat.stream_json:
mode = OutputMode.STREAM_JSON
else:
mode = OutputMode.TEXT
# Create run context
context = create_context()
# Create and set output controller
state.output_controller = OutputController(
mode=mode,
no_color=state.no_color,
run_id=context.run_id,
trace_id=context.trace_id,
)
set_output_controller(state.output_controller)
# If no command provided, show help or handle legacy behavior
if ctx.invoked_subcommand is None:
# Check if there are remaining args (legacy behavior)
# This is handled by the legacy adapter in main.py
pass
def get_output_controller() -> OutputController:
"""Get the current output controller."""
if state.output_controller is None:
state.output_controller = OutputController()
return state.output_controller
# Import and register command groups
def register_commands():
"""Register all command groups."""
# Import command modules - Core commands
from .commands.config import app as config_app
from .commands.traces import app as traces_app
from .commands.environment import app as env_app
from .commands.session import app as session_app
from .commands.completion import app as completion_app
from .commands.version import app as version_app
from .commands.debug import app as debug_app
from .commands.lsp import app as lsp_app
from .commands.diag import app as diag_app
from .commands.doctor import app as doctor_app
from .commands.acp import app as acp_app
from .commands.mcp import app as mcp_app
from .commands.serve import app as serve_app
from .commands.schedule import app as schedule_app
from .commands.run import app as run_app
from .commands.profile import app as profile_app
from .commands.benchmark import app as benchmark_app
# Import new command modules - Previously legacy-only commands
from .commands.chat import app as chat_app
from .commands.code import app as code_app
from .commands.call import app as call_app
from .commands.realtime import app as realtime_app
from .commands.train import app as train_app
from .commands.ui import app as ui_app
from .commands.context import app as context_app
from .commands.research import app as research_app
from .commands.memory import app as memory_app
from .commands.workflow import app as workflow_app
from .commands.tools import app as tools_app
from .commands.knowledge import app as knowledge_app
from .commands.deploy import app as deploy_app
from .commands.agents import app as agents_app
from .commands.skills import app as skills_app
from .commands.eval import app as eval_app
from .commands.templates import app as templates_app
from .commands.recipe import app as recipe_app
from .commands.todo import app as todo_app
from .commands.docs import app as docs_app
from .commands.commit import app as commit_app
from .commands.hooks import app as hooks_app
from .commands.rules import app as rules_app
from .commands.registry import app as registry_app
from .commands.package import app as package_app
from .commands.endpoints import app as endpoints_app
# Import TUI and queue commands
from .features.tui.debug import create_debug_app as create_tui_debug_app
from .features.tui.cli import create_queue_app
# Register sub-apps - Core commands
app.add_typer(config_app, name="config", help="Configuration management")
app.add_typer(traces_app, name="traces", help="Trace collection management")
app.add_typer(env_app, name="env", help="Environment and diagnostics")
app.add_typer(session_app, name="session", help="Session management")
app.add_typer(completion_app, name="completion", help="Shell completion scripts")
app.add_typer(version_app, name="version", help="Version information")
app.add_typer(debug_app, name="debug", help="Debug and test interactive flows")
app.add_typer(lsp_app, name="lsp", help="LSP service lifecycle")
app.add_typer(diag_app, name="diag", help="Diagnostics export")
app.add_typer(doctor_app, name="doctor", help="Health checks and diagnostics")
app.add_typer(acp_app, name="acp", help="Agent Client Protocol server")
app.add_typer(mcp_app, name="mcp", help="MCP server management")
app.add_typer(serve_app, name="serve", help="API server management")
app.add_typer(schedule_app, name="schedule", help="Scheduler management")
app.add_typer(run_app, name="run", help="Run agents")
app.add_typer(profile_app, name="profile", help="Performance profiling and diagnostics")
app.add_typer(benchmark_app, name="benchmark", help="Comprehensive performance benchmarking")
# Register sub-apps - Previously legacy-only commands (now visible in --help)
app.add_typer(chat_app, name="chat", help="Interactive chat mode")
app.add_typer(code_app, name="code", help="Code assistant mode")
app.add_typer(call_app, name="call", help="Voice/call interaction mode")
app.add_typer(realtime_app, name="realtime", help="Realtime interaction mode")
app.add_typer(train_app, name="train", help="Model training and fine-tuning")
app.add_typer(ui_app, name="ui", help="Web UI management")
app.add_typer(context_app, name="context", help="Context management")
app.add_typer(research_app, name="research", help="Research and analysis")
app.add_typer(memory_app, name="memory", help="Memory management")
app.add_typer(workflow_app, name="workflow", help="Workflow management")
app.add_typer(tools_app, name="tools", help="Tool management")
app.add_typer(knowledge_app, name="knowledge", help="Knowledge base management")
app.add_typer(deploy_app, name="deploy", help="Deployment management")
app.add_typer(agents_app, name="agents", help="Agent management")
app.add_typer(skills_app, name="skills", help="Skill management")
app.add_typer(eval_app, name="eval", help="Evaluation and testing")
app.add_typer(templates_app, name="templates", help="Template management")
app.add_typer(recipe_app, name="recipe", help="Recipe management")
app.add_typer(todo_app, name="todo", help="Todo/task management")
app.add_typer(docs_app, name="docs", help="Documentation management")
app.add_typer(commit_app, name="commit", help="AI-assisted git commits")
app.add_typer(hooks_app, name="hooks", help="Hook management")
app.add_typer(rules_app, name="rules", help="Rules management")
app.add_typer(registry_app, name="registry", help="Registry management")
app.add_typer(package_app, name="package", help="Package management")
app.add_typer(endpoints_app, name="endpoints", help="API endpoint management")
# Register TUI and queue commands
tui_app = create_tui_debug_app()
queue_app = create_queue_app()
if tui_app:
app.add_typer(tui_app, name="tui", help="Interactive TUI and simulation")
if queue_app:
app.add_typer(queue_app, name="queue", help="Queue management")
# Register commands on import
register_commands()
@@ -0,0 +1,75 @@
"""
PraisonAI CLI Commands Module.
This module contains all Typer command groups and subcommands.
Each file represents a command group (e.g., config, traces, session).
"""
# Lazy imports to avoid loading all commands at startup
__all__ = [
'run_app',
'config_app',
'traces_app',
'env_app',
'session_app',
'schedule_app',
'serve_app',
'completion_app',
'version_app',
'debug_app',
'lsp_app',
'diag_app',
'doctor_app',
'acp_app',
'mcp_app',
]
def __getattr__(name: str):
"""Lazy load command apps."""
if name == 'run_app':
from .run import app as run_app
return run_app
elif name == 'config_app':
from .config import app as config_app
return config_app
elif name == 'traces_app':
from .traces import app as traces_app
return traces_app
elif name == 'env_app':
from .environment import app as env_app
return env_app
elif name == 'session_app':
from .session import app as session_app
return session_app
elif name == 'schedule_app':
from .schedule import app as schedule_app
return schedule_app
elif name == 'serve_app':
from .serve import app as serve_app
return serve_app
elif name == 'completion_app':
from .completion import app as completion_app
return completion_app
elif name == 'version_app':
from .version import app as version_app
return version_app
elif name == 'debug_app':
from .debug import app as debug_app
return debug_app
elif name == 'lsp_app':
from .lsp import app as lsp_app
return lsp_app
elif name == 'diag_app':
from .diag import app as diag_app
return diag_app
elif name == 'doctor_app':
from .doctor import app as doctor_app
return doctor_app
elif name == 'acp_app':
from .acp import app as acp_app
return acp_app
elif name == 'mcp_app':
from .mcp import app as mcp_app
return mcp_app
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,70 @@
"""
ACP command group for PraisonAI CLI.
Wraps existing ACP functionality from features/acp.py.
Provides Agent Client Protocol server for IDE integration.
"""
from typing import Optional
import typer
from ..output.console import get_output_controller
app = typer.Typer(help="Agent Client Protocol server")
@app.callback(invoke_without_command=True)
def acp_main(
ctx: typer.Context,
workspace: str = typer.Option(".", "--workspace", "-w", help="Workspace root directory"),
agent: str = typer.Option("default", "--agent", "-a", help="Agent name or configuration file"),
agents_file: Optional[str] = typer.Option(None, "--agents", help="Multi-agent configuration YAML file"),
router: bool = typer.Option(False, "--router", help="Enable router agent for task delegation"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
resume: Optional[str] = typer.Option(None, "--resume", "-r", help="Resume session by ID"),
last: bool = typer.Option(False, "--last", help="Resume the last session"),
approve: str = typer.Option("manual", "--approve", help="Approval mode: manual, auto, scoped"),
read_only: bool = typer.Option(True, "--read-only/--allow-write", help="Read-only mode"),
allow_shell: bool = typer.Option(False, "--allow-shell", help="Allow shell command execution"),
allow_network: bool = typer.Option(False, "--allow-network", help="Allow network requests"),
debug: bool = typer.Option(False, "--debug", help="Enable debug logging"),
profile: Optional[str] = typer.Option(None, "--profile", help="Use named profile from config"),
):
"""Start ACP server for IDE integration."""
# Build args for existing handler
args = [
"--workspace", workspace,
"--agent", agent,
"--approve", approve,
]
if agents_file:
args.extend(["--agents", agents_file])
if router:
args.append("--router")
if model:
args.extend(["--model", model])
if resume:
args.extend(["--resume", resume])
if last:
args.append("--last")
if not read_only:
args.append("--allow-write")
if allow_shell:
args.append("--allow-shell")
if allow_network:
args.append("--allow-network")
if debug:
args.append("--debug")
if profile:
args.extend(["--profile", profile])
try:
from ..features.acp import run_acp_command
exit_code = run_acp_command(args)
raise typer.Exit(exit_code)
except ImportError as e:
output = get_output_controller()
output.print_error(f"ACP module not available: {e}")
raise typer.Exit(4)
@@ -0,0 +1,80 @@
"""
Agents command group for PraisonAI CLI.
Provides agent management commands.
"""
import typer
app = typer.Typer(help="Agent management")
@app.command("list")
def agents_list(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed info"),
):
"""List available agents."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['agents', 'list']
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("create")
def agents_create(
name: str = typer.Argument(..., help="Agent name"),
template: str = typer.Option(None, "--template", "-t", help="Template to use"),
):
"""Create a new agent."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['agents', 'create', name]
if template:
argv.extend(['--template', template])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("info")
def agents_info(
name: str = typer.Argument(..., help="Agent name"),
):
"""Show agent information."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['agents', 'info', name]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,286 @@
"""
Benchmark command group for PraisonAI CLI.
Provides comprehensive performance benchmarking across all execution paths.
"""
from typing import Optional
import typer
app = typer.Typer(help="Comprehensive performance benchmarking")
@app.command("profile")
def benchmark_profile(
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations per path"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
output_file: Optional[str] = typer.Option(None, "--output", "-o", help="Save results to file"),
deep: bool = typer.Option(False, "--deep", help="Enable deep cProfile profiling (per-function timing, call graphs)"),
limit: int = typer.Option(30, "--limit", "-l", help="Top N functions to show in deep profile"),
):
"""
Run full benchmark suite across all execution paths.
Benchmarks:
- OpenAI SDK (baseline)
- PraisonAI Agent
- PraisonAI CLI
- PraisonAI CLI with profiling
- PraisonAI Workflow (single agent)
- PraisonAI Workflow (multi-agent)
- PraisonAI via LiteLLM
- LiteLLM standalone
With --deep flag, includes:
- Per-function timing (cProfile stats)
- Self time vs cumulative time
- Call counts per function
- Caller/callee relationships
- Module breakdown by category
- Call graph data
Examples:
praisonai benchmark profile "What is 2+2?"
praisonai benchmark profile "Hi" --iterations 5
praisonai benchmark profile "Hi" --deep --limit 50
praisonai benchmark profile "Hi" --deep --format json --output results.json
"""
try:
from ..features.benchmark import BenchmarkHandler
except ImportError as e:
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
raise typer.Exit(1)
if deep:
typer.echo("⚠️ Deep profiling enabled - this adds overhead to measurements", err=True)
handler = BenchmarkHandler()
report = handler.run_full_benchmark(
prompt=prompt,
iterations=iterations,
verbose=True,
deep=deep,
limit=limit
)
if output_format == "json":
import json
output = json.dumps(report.to_dict(), indent=2)
if output_file:
with open(output_file, "w") as f:
f.write(output)
typer.echo(f"Results saved to {output_file}")
else:
typer.echo(output)
else:
handler.print_report(report, deep=deep, limit=limit)
if output_file:
import json
with open(output_file, "w") as f:
f.write(json.dumps(report.to_dict(), indent=2))
typer.echo(f"\nJSON results saved to {output_file}")
@app.command("compare")
def benchmark_compare(
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
iterations: int = typer.Option(2, "--iterations", "-n", help="Number of iterations"),
):
"""
Quick comparison of key execution paths.
Compares OpenAI SDK, PraisonAI Agent, PraisonAI CLI, and LiteLLM.
Examples:
praisonai benchmark compare "Hi"
praisonai benchmark compare "What is 2+2?" --iterations 3
"""
try:
from ..features.benchmark import BenchmarkHandler
except ImportError as e:
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
raise typer.Exit(1)
handler = BenchmarkHandler()
report = handler.run_full_benchmark(
prompt=prompt,
iterations=iterations,
paths=["openai_sdk", "praisonai_agent", "praisonai_cli", "litellm_standalone"],
verbose=True
)
typer.echo("\n" + handler.create_comparison_table(report))
@app.command("sdk")
def benchmark_sdk(
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Benchmark OpenAI SDK only (baseline).
Examples:
praisonai benchmark sdk "Hi"
praisonai benchmark sdk "What is 2+2?" --iterations 5
"""
try:
from ..features.benchmark import BenchmarkHandler
except ImportError as e:
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
raise typer.Exit(1)
handler = BenchmarkHandler()
report = handler.run_full_benchmark(prompt=prompt, iterations=iterations, paths=["openai_sdk"], verbose=True)
if output_format == "json":
import json
typer.echo(json.dumps(report.to_dict(), indent=2))
else:
handler.print_report(report)
@app.command("agent")
def benchmark_agent(
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Benchmark PraisonAI Agent vs SDK baseline.
Examples:
praisonai benchmark agent "Hi"
praisonai benchmark agent "What is 2+2?" --iterations 5
"""
try:
from ..features.benchmark import BenchmarkHandler
except ImportError as e:
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
raise typer.Exit(1)
handler = BenchmarkHandler()
report = handler.run_full_benchmark(
prompt=prompt,
iterations=iterations,
paths=["openai_sdk", "praisonai_agent"],
verbose=True
)
if output_format == "json":
import json
typer.echo(json.dumps(report.to_dict(), indent=2))
else:
handler.print_report(report)
@app.command("cli")
def benchmark_cli(
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Benchmark PraisonAI CLI vs SDK baseline.
Examples:
praisonai benchmark cli "Hi"
praisonai benchmark cli "What is 2+2?" --iterations 5
"""
try:
from ..features.benchmark import BenchmarkHandler
except ImportError as e:
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
raise typer.Exit(1)
handler = BenchmarkHandler()
report = handler.run_full_benchmark(
prompt=prompt,
iterations=iterations,
paths=["openai_sdk", "praisonai_cli", "praisonai_cli_profile"],
verbose=True
)
if output_format == "json":
import json
typer.echo(json.dumps(report.to_dict(), indent=2))
else:
handler.print_report(report)
@app.command("workflow")
def benchmark_workflow(
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Benchmark PraisonAI Workflow (single and multi-agent) vs SDK baseline.
Examples:
praisonai benchmark workflow "Hi"
praisonai benchmark workflow "What is 2+2?" --iterations 5
"""
try:
from ..features.benchmark import BenchmarkHandler
except ImportError as e:
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
raise typer.Exit(1)
handler = BenchmarkHandler()
report = handler.run_full_benchmark(
prompt=prompt,
iterations=iterations,
paths=["openai_sdk", "praisonai_workflow_single", "praisonai_workflow_multi"],
verbose=True
)
if output_format == "json":
import json
typer.echo(json.dumps(report.to_dict(), indent=2))
else:
handler.print_report(report)
@app.command("litellm")
def benchmark_litellm(
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Benchmark LiteLLM paths vs SDK baseline.
Examples:
praisonai benchmark litellm "Hi"
praisonai benchmark litellm "What is 2+2?" --iterations 5
"""
try:
from ..features.benchmark import BenchmarkHandler
except ImportError as e:
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
raise typer.Exit(1)
handler = BenchmarkHandler()
report = handler.run_full_benchmark(
prompt=prompt,
iterations=iterations,
paths=["openai_sdk", "praisonai_litellm", "litellm_standalone"],
verbose=True
)
if output_format == "json":
import json
typer.echo(json.dumps(report.to_dict(), indent=2))
else:
handler.print_report(report)
@app.callback(invoke_without_command=True)
def benchmark_callback(ctx: typer.Context):
"""Show benchmark help if no subcommand."""
if ctx.invoked_subcommand is None:
typer.echo(ctx.get_help())
@@ -0,0 +1,45 @@
"""
Call command group for PraisonAI CLI.
Provides voice/call interaction commands.
"""
from typing import Optional
import typer
app = typer.Typer(help="Voice/call interaction mode")
@app.callback(invoke_without_command=True)
def call_main(
ctx: typer.Context,
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""
Start voice/call interaction mode.
Examples:
praisonai call
praisonai call --model gpt-4o
"""
from praisonai.cli.main import PraisonAI
import sys
argv = ['call']
if model:
argv.extend(['--model', model])
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,60 @@
"""
Chat command group for PraisonAI CLI.
Provides interactive chat commands.
"""
from typing import Optional
import typer
app = typer.Typer(help="Interactive chat mode")
@app.callback(invoke_without_command=True)
def chat_main(
ctx: typer.Context,
prompt: Optional[str] = typer.Argument(None, help="Initial prompt for chat"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Tools file path"),
user_id: Optional[str] = typer.Option(None, "--user-id", help="User ID for memory isolation"),
session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID to resume"),
):
"""
Start interactive chat mode.
Examples:
praisonai chat
praisonai chat "Hello, how are you?"
praisonai chat --model gpt-4o --memory
"""
from praisonai.cli.main import PraisonAI
import sys
# Build args for legacy handler
argv = ['chat']
if prompt:
argv.append(prompt)
if model:
argv.extend(['--model', model])
if verbose:
argv.append('--verbose')
if memory:
argv.append('--memory')
if tools:
argv.extend(['--tools', tools])
if user_id:
argv.extend(['--user-id', user_id])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,53 @@
"""
Code command group for PraisonAI CLI.
Provides code assistant commands.
"""
from typing import Optional
import typer
app = typer.Typer(help="Code assistant mode")
@app.callback(invoke_without_command=True)
def code_main(
ctx: typer.Context,
prompt: Optional[str] = typer.Argument(None, help="Code task or question"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Tools file path"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
):
"""
Start code assistant mode.
Examples:
praisonai code
praisonai code "Refactor this function"
praisonai code --model gpt-4o --workspace ./src
"""
from praisonai.cli.main import PraisonAI
import sys
argv = ['code']
if prompt:
argv.append(prompt)
if model:
argv.extend(['--model', model])
if verbose:
argv.append('--verbose')
if tools:
argv.extend(['--tools', tools])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,47 @@
"""
Commit command group for PraisonAI CLI.
Provides git commit commands with AI assistance.
"""
import typer
app = typer.Typer(help="AI-assisted git commits")
@app.callback(invoke_without_command=True)
def commit_main(
ctx: typer.Context,
message: str = typer.Option(None, "--message", "-m", help="Commit message (auto-generated if not provided)"),
all_files: bool = typer.Option(False, "--all", "-a", help="Stage all changes"),
push: bool = typer.Option(False, "--push", "-p", help="Push after commit"),
):
"""
Create AI-assisted git commits.
Examples:
praisonai commit
praisonai commit -m "Fix bug"
praisonai commit --all --push
"""
from praisonai.cli.main import PraisonAI
import sys
argv = ['commit']
if message:
argv.extend(['--message', message])
if all_files:
argv.append('--all')
if push:
argv.append('--push')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,333 @@
"""
Completion command group for PraisonAI CLI.
Provides shell completion script generation:
- completion bash: Generate bash completions
- completion zsh: Generate zsh completions
- completion fish: Generate fish completions
"""
import typer
from ..output.console import get_output_controller
app = typer.Typer(help="Shell completion scripts")
BASH_COMPLETION = '''
# PraisonAI bash completion
_praisonai_completion() {
local cur prev opts
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
# Top-level commands
opts="run config traces env session schedule serve completion version debug lsp diag doctor acp mcp chat code call realtime train ui context research memory rules workflow hooks knowledge tools todo docs commit skills profile eval templates recipe endpoints agents thinking compaction output deploy registry package install uninstall"
# Subcommands for specific commands
case "${prev}" in
config)
COMPREPLY=( $(compgen -W "list get set reset path" -- ${cur}) )
return 0
;;
traces)
COMPREPLY=( $(compgen -W "enable disable status list" -- ${cur}) )
return 0
;;
env)
COMPREPLY=( $(compgen -W "view check doctor" -- ${cur}) )
return 0
;;
session)
COMPREPLY=( $(compgen -W "list resume delete export show" -- ${cur}) )
return 0
;;
schedule)
COMPREPLY=( $(compgen -W "start stop list logs restart delete describe save stop-all stats" -- ${cur}) )
return 0
;;
serve)
COMPREPLY=( $(compgen -W "start stop status" -- ${cur}) )
return 0
;;
completion)
COMPREPLY=( $(compgen -W "bash zsh fish" -- ${cur}) )
return 0
;;
debug)
COMPREPLY=( $(compgen -W "interactive lsp acp trace" -- ${cur}) )
return 0
;;
lsp)
COMPREPLY=( $(compgen -W "start stop status logs" -- ${cur}) )
return 0
;;
diag)
COMPREPLY=( $(compgen -W "export" -- ${cur}) )
return 0
;;
doctor)
COMPREPLY=( $(compgen -W "env config tools db mcp obs skills memory permissions network performance ci selftest" -- ${cur}) )
return 0
;;
mcp)
COMPREPLY=( $(compgen -W "list add remove test" -- ${cur}) )
return 0
;;
*)
;;
esac
# Global options
if [[ ${cur} == -* ]] ; then
COMPREPLY=( $(compgen -W "--help --version --output-format --json --no-color --quiet --verbose --screen-reader" -- ${cur}) )
return 0
fi
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
return 0
}
complete -F _praisonai_completion praisonai
'''
ZSH_COMPLETION = '''
#compdef praisonai
_praisonai() {
local -a commands
local -a subcommands
commands=(
'run:Run agents'
'config:Configuration management'
'traces:Trace collection management'
'env:Environment and diagnostics'
'session:Session management'
'schedule:Scheduler management'
'serve:API server management'
'completion:Shell completion scripts'
'version:Version information'
'debug:Debug and test interactive flows'
'lsp:LSP service lifecycle'
'diag:Diagnostics export'
'doctor:Health checks and diagnostics'
'acp:Agent Client Protocol server'
'mcp:MCP server management'
'chat:Start chat UI'
'code:Start code UI'
'call:Start call server'
'realtime:Start realtime interface'
'train:Training commands'
'ui:Start UI'
'context:Context engineering'
'research:Deep research'
'memory:Memory management'
'rules:Rules management'
'workflow:Workflow management'
'hooks:Hooks management'
'knowledge:Knowledge management'
'tools:Tools management'
'todo:Todo management'
'docs:Documentation'
'commit:Git commit'
'skills:Skills management'
'profile:Profile management'
'eval:Evaluation'
'templates:Templates'
'recipe:Recipe management'
'endpoints:Endpoints'
'agents:Agents management'
'deploy:Deployment'
'registry:Registry'
'package:Package management'
'install:Install packages'
'uninstall:Uninstall packages'
)
_arguments -C \\
'--help[Show help]' \\
'--version[Show version]' \\
'--output-format[Output format]:format:(text json stream-json)' \\
'--json[JSON output]' \\
'--no-color[Disable colors]' \\
'--quiet[Minimal output]' \\
'--verbose[Verbose output]' \\
'--screen-reader[Screen reader mode]' \\
'1: :->command' \\
'*::arg:->args'
case $state in
command)
_describe 'command' commands
;;
args)
case $words[1] in
config)
subcommands=('list:List config' 'get:Get value' 'set:Set value' 'reset:Reset config' 'path:Show path')
_describe 'subcommand' subcommands
;;
traces)
subcommands=('enable:Enable traces' 'disable:Disable traces' 'status:Show status' 'list:List traces')
_describe 'subcommand' subcommands
;;
env)
subcommands=('view:View env vars' 'check:Check API keys' 'doctor:Run diagnostics')
_describe 'subcommand' subcommands
;;
session)
subcommands=('list:List sessions' 'resume:Resume session' 'delete:Delete session' 'export:Export session' 'show:Show session')
_describe 'subcommand' subcommands
;;
schedule)
subcommands=('start:Start scheduler' 'stop:Stop scheduler' 'list:List jobs' 'logs:View logs' 'restart:Restart' 'delete:Delete job' 'describe:Describe job' 'save:Save config' 'stop-all:Stop all' 'stats:Show stats')
_describe 'subcommand' subcommands
;;
serve)
subcommands=('start:Start server' 'stop:Stop server' 'status:Show status')
_describe 'subcommand' subcommands
;;
completion)
subcommands=('bash:Bash completion' 'zsh:Zsh completion' 'fish:Fish completion')
_describe 'subcommand' subcommands
;;
debug)
subcommands=('interactive:Interactive debug' 'lsp:LSP debug' 'acp:ACP debug' 'trace:Trace debug')
_describe 'subcommand' subcommands
;;
lsp)
subcommands=('start:Start LSP' 'stop:Stop LSP' 'status:LSP status' 'logs:LSP logs')
_describe 'subcommand' subcommands
;;
diag)
subcommands=('export:Export diagnostics')
_describe 'subcommand' subcommands
;;
doctor)
subcommands=('env:Check env' 'config:Check config' 'tools:Check tools' 'db:Check DB' 'mcp:Check MCP' 'obs:Check observability' 'skills:Check skills' 'memory:Check memory' 'permissions:Check permissions' 'network:Check network' 'performance:Check performance' 'ci:CI checks' 'selftest:Self test')
_describe 'subcommand' subcommands
;;
mcp)
subcommands=('list:List servers' 'add:Add server' 'remove:Remove server' 'test:Test server')
_describe 'subcommand' subcommands
;;
esac
;;
esac
}
_praisonai "$@"
'''
FISH_COMPLETION = '''
# PraisonAI fish completion
# Top-level commands
complete -c praisonai -f -n "__fish_use_subcommand" -a "run" -d "Run agents"
complete -c praisonai -f -n "__fish_use_subcommand" -a "config" -d "Configuration management"
complete -c praisonai -f -n "__fish_use_subcommand" -a "traces" -d "Trace collection management"
complete -c praisonai -f -n "__fish_use_subcommand" -a "env" -d "Environment and diagnostics"
complete -c praisonai -f -n "__fish_use_subcommand" -a "session" -d "Session management"
complete -c praisonai -f -n "__fish_use_subcommand" -a "schedule" -d "Scheduler management"
complete -c praisonai -f -n "__fish_use_subcommand" -a "serve" -d "API server management"
complete -c praisonai -f -n "__fish_use_subcommand" -a "completion" -d "Shell completion scripts"
complete -c praisonai -f -n "__fish_use_subcommand" -a "version" -d "Version information"
complete -c praisonai -f -n "__fish_use_subcommand" -a "debug" -d "Debug and test interactive flows"
complete -c praisonai -f -n "__fish_use_subcommand" -a "lsp" -d "LSP service lifecycle"
complete -c praisonai -f -n "__fish_use_subcommand" -a "diag" -d "Diagnostics export"
complete -c praisonai -f -n "__fish_use_subcommand" -a "doctor" -d "Health checks and diagnostics"
complete -c praisonai -f -n "__fish_use_subcommand" -a "acp" -d "Agent Client Protocol server"
complete -c praisonai -f -n "__fish_use_subcommand" -a "mcp" -d "MCP server management"
complete -c praisonai -f -n "__fish_use_subcommand" -a "chat" -d "Start chat UI"
complete -c praisonai -f -n "__fish_use_subcommand" -a "code" -d "Start code UI"
# Global options
complete -c praisonai -l help -d "Show help"
complete -c praisonai -l version -d "Show version"
complete -c praisonai -l output-format -x -a "text json stream-json" -d "Output format"
complete -c praisonai -l json -d "JSON output"
complete -c praisonai -l no-color -d "Disable colors"
complete -c praisonai -l quiet -s q -d "Minimal output"
complete -c praisonai -l verbose -s v -d "Verbose output"
complete -c praisonai -l screen-reader -d "Screen reader mode"
# config subcommands
complete -c praisonai -f -n "__fish_seen_subcommand_from config" -a "list" -d "List config"
complete -c praisonai -f -n "__fish_seen_subcommand_from config" -a "get" -d "Get value"
complete -c praisonai -f -n "__fish_seen_subcommand_from config" -a "set" -d "Set value"
complete -c praisonai -f -n "__fish_seen_subcommand_from config" -a "reset" -d "Reset config"
complete -c praisonai -f -n "__fish_seen_subcommand_from config" -a "path" -d "Show path"
# traces subcommands
complete -c praisonai -f -n "__fish_seen_subcommand_from traces" -a "enable" -d "Enable traces"
complete -c praisonai -f -n "__fish_seen_subcommand_from traces" -a "disable" -d "Disable traces"
complete -c praisonai -f -n "__fish_seen_subcommand_from traces" -a "status" -d "Show status"
complete -c praisonai -f -n "__fish_seen_subcommand_from traces" -a "list" -d "List traces"
# env subcommands
complete -c praisonai -f -n "__fish_seen_subcommand_from env" -a "view" -d "View env vars"
complete -c praisonai -f -n "__fish_seen_subcommand_from env" -a "check" -d "Check API keys"
complete -c praisonai -f -n "__fish_seen_subcommand_from env" -a "doctor" -d "Run diagnostics"
# session subcommands
complete -c praisonai -f -n "__fish_seen_subcommand_from session" -a "list" -d "List sessions"
complete -c praisonai -f -n "__fish_seen_subcommand_from session" -a "resume" -d "Resume session"
complete -c praisonai -f -n "__fish_seen_subcommand_from session" -a "delete" -d "Delete session"
complete -c praisonai -f -n "__fish_seen_subcommand_from session" -a "export" -d "Export session"
complete -c praisonai -f -n "__fish_seen_subcommand_from session" -a "show" -d "Show session"
# completion subcommands
complete -c praisonai -f -n "__fish_seen_subcommand_from completion" -a "bash" -d "Bash completion"
complete -c praisonai -f -n "__fish_seen_subcommand_from completion" -a "zsh" -d "Zsh completion"
complete -c praisonai -f -n "__fish_seen_subcommand_from completion" -a "fish" -d "Fish completion"
# debug subcommands
complete -c praisonai -f -n "__fish_seen_subcommand_from debug" -a "interactive" -d "Interactive debug"
complete -c praisonai -f -n "__fish_seen_subcommand_from debug" -a "lsp" -d "LSP debug"
complete -c praisonai -f -n "__fish_seen_subcommand_from debug" -a "acp" -d "ACP debug"
complete -c praisonai -f -n "__fish_seen_subcommand_from debug" -a "trace" -d "Trace debug"
# doctor subcommands
complete -c praisonai -f -n "__fish_seen_subcommand_from doctor" -a "env" -d "Check env"
complete -c praisonai -f -n "__fish_seen_subcommand_from doctor" -a "config" -d "Check config"
complete -c praisonai -f -n "__fish_seen_subcommand_from doctor" -a "tools" -d "Check tools"
complete -c praisonai -f -n "__fish_seen_subcommand_from doctor" -a "db" -d "Check DB"
complete -c praisonai -f -n "__fish_seen_subcommand_from doctor" -a "mcp" -d "Check MCP"
'''
@app.command("bash")
def completion_bash():
"""Generate bash completion script."""
print(BASH_COMPLETION.strip())
@app.command("zsh")
def completion_zsh():
"""Generate zsh completion script."""
print(ZSH_COMPLETION.strip())
@app.command("fish")
def completion_fish():
"""Generate fish completion script."""
print(FISH_COMPLETION.strip())
@app.callback(invoke_without_command=True)
def completion_callback(ctx: typer.Context):
"""Show completion installation instructions."""
if ctx.invoked_subcommand is None:
output = get_output_controller()
output.print_panel(
"Generate shell completion scripts.\n\n"
"Usage:\n"
" # Bash\n"
" praisonai completion bash >> ~/.bashrc\n\n"
" # Zsh\n"
" praisonai completion zsh > ~/.zsh/completions/_praisonai\n\n"
" # Fish\n"
" praisonai completion fish > ~/.config/fish/completions/praisonai.fish",
title="Shell Completions"
)
@@ -0,0 +1,166 @@
"""
Config command group for PraisonAI CLI.
Provides configuration management commands:
- config list: Show all configuration
- config get: Get a specific value
- config set: Set a configuration value
- config reset: Reset to defaults
"""
from typing import Optional
import typer
from ..configuration.loader import get_config_loader
from ..output.console import get_output_controller
app = typer.Typer(help="Configuration management")
@app.command("list")
def config_list(
scope: str = typer.Option(
"all",
"--scope",
"-s",
help="Scope to list: all, user, project",
),
):
"""List all configuration values."""
output = get_output_controller()
loader = get_config_loader()
config = loader.list_all()
if output.is_json_mode:
output.print_json(config)
return
output.print_panel("Configuration", title="PraisonAI Config")
def print_dict(d, prefix=""):
for key, value in d.items():
full_key = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict):
print_dict(value, full_key)
else:
output.print(f" {full_key} = {value}")
print_dict(config)
@app.command("get")
def config_get(
key: str = typer.Argument(..., help="Configuration key (dotted notation, e.g., output.format)"),
):
"""Get a configuration value."""
output = get_output_controller()
loader = get_config_loader()
value = loader.get(key)
if value is None:
output.print_error(f"Key not found: {key}")
raise typer.Exit(1)
if output.is_json_mode:
output.print_json({"key": key, "value": value})
else:
output.print(f"{key} = {value}")
@app.command("set")
def config_set(
key: str = typer.Argument(..., help="Configuration key (dotted notation)"),
value: str = typer.Argument(..., help="Value to set"),
scope: str = typer.Option(
"user",
"--scope",
"-s",
help="Scope: user or project",
),
):
"""Set a configuration value."""
output = get_output_controller()
loader = get_config_loader()
# Parse value
if value.lower() == "true":
parsed_value = True
elif value.lower() == "false":
parsed_value = False
else:
try:
parsed_value = int(value)
except ValueError:
try:
parsed_value = float(value)
except ValueError:
parsed_value = value
loader.set(key, parsed_value, scope=scope)
if output.is_json_mode:
output.print_json({"key": key, "value": parsed_value, "scope": scope})
else:
output.print_success(f"Set {key} = {parsed_value} ({scope})")
@app.command("reset")
def config_reset(
scope: str = typer.Option(
"user",
"--scope",
"-s",
help="Scope to reset: user or project",
),
confirm: bool = typer.Option(
False,
"--yes",
"-y",
help="Skip confirmation",
),
):
"""Reset configuration to defaults."""
output = get_output_controller()
if not confirm:
confirmed = typer.confirm(f"Reset {scope} configuration to defaults?")
if not confirmed:
output.print_info("Cancelled")
raise typer.Exit(0)
loader = get_config_loader()
loader.reset(scope=scope)
if output.is_json_mode:
output.print_json({"reset": True, "scope": scope})
else:
output.print_success(f"Reset {scope} configuration to defaults")
@app.command("path")
def config_path(
scope: str = typer.Option(
"user",
"--scope",
"-s",
help="Scope: user or project",
),
):
"""Show configuration file path."""
output = get_output_controller()
from ..configuration.paths import get_user_config_path, get_project_config_path
if scope == "project":
path = get_project_config_path()
else:
path = get_user_config_path()
if output.is_json_mode:
output.print_json({"path": str(path), "exists": path.exists()})
else:
exists = "✓" if path.exists() else "✗"
output.print(f"{path} [{exists}]")
@@ -0,0 +1,77 @@
"""
Context command group for PraisonAI CLI.
Provides context management commands.
"""
from typing import Optional
import typer
app = typer.Typer(help="Context management")
@app.command("show")
def context_show(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Show current context."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['context', 'show']
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("clear")
def context_clear():
"""Clear current context."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['context', 'clear']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("add")
def context_add(
content: str = typer.Argument(..., help="Content to add to context"),
):
"""Add content to context."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['context', 'add', content]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,142 @@
"""
Debug command group for PraisonAI CLI.
Wraps existing debug functionality from features/debug.py.
Provides debug commands for testing interactive flows.
"""
import sys
from typing import List, Optional
import typer
from ..output.console import get_output_controller
app = typer.Typer(help="Debug and test interactive flows")
@app.command("interactive")
def debug_interactive(
prompt: str = typer.Option(..., "--prompt", "-p", help="Prompt to execute"),
workspace: str = typer.Option(".", "--workspace", "-w", help="Workspace root directory"),
lsp: bool = typer.Option(True, "--lsp/--no-lsp", help="Enable/disable LSP"),
acp: bool = typer.Option(True, "--acp/--no-acp", help="Enable/disable ACP"),
approval: str = typer.Option("auto", "--approval", help="Approval mode: manual, auto, scoped"),
json_output: bool = typer.Option(False, "--json", help="Output JSON trace"),
trace_file: Optional[str] = typer.Option(None, "--trace-file", help="Save trace to file"),
timeout: float = typer.Option(60.0, "--timeout", help="Timeout in seconds"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
):
"""Run single interactive turn non-interactively."""
output = get_output_controller()
# Build args for existing handler
args = [
"interactive",
"--prompt", prompt,
"--workspace", workspace,
"--approval", approval,
"--timeout", str(timeout),
]
if not lsp:
args.append("--no-lsp")
if not acp:
args.append("--no-acp")
if json_output:
args.append("--json")
if trace_file:
args.extend(["--trace-file", trace_file])
if model:
args.extend(["--model", model])
# Delegate to existing handler
try:
from ..features.debug import run_debug_command
exit_code = run_debug_command(args)
raise typer.Exit(exit_code)
except ImportError as e:
output.print_error(f"Debug module not available: {e}")
raise typer.Exit(4)
@app.command("lsp")
def debug_lsp(
workspace: str = typer.Option(".", "--workspace", "-w", help="Workspace root directory"),
language: Optional[str] = typer.Option(None, "--language", "-l", help="Language to probe"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Direct LSP probes."""
args = ["lsp", "--workspace", workspace]
if language:
args.extend(["--language", language])
if json_output:
args.append("--json")
try:
from ..features.debug import run_debug_command
exit_code = run_debug_command(args)
raise typer.Exit(exit_code)
except ImportError as e:
output = get_output_controller()
output.print_error(f"Debug module not available: {e}")
raise typer.Exit(4)
@app.command("acp")
def debug_acp(
workspace: str = typer.Option(".", "--workspace", "-w", help="Workspace root directory"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Direct ACP probes."""
args = ["acp", "--workspace", workspace]
if json_output:
args.append("--json")
try:
from ..features.debug import run_debug_command
exit_code = run_debug_command(args)
raise typer.Exit(exit_code)
except ImportError as e:
output = get_output_controller()
output.print_error(f"Debug module not available: {e}")
raise typer.Exit(4)
@app.command("trace")
def debug_trace(
action: str = typer.Argument("list", help="Action: record, replay, diff, list"),
trace_id: Optional[str] = typer.Option(None, "--trace-id", "-t", help="Trace ID"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Trace record/replay/diff."""
args = ["trace", action]
if trace_id:
args.extend(["--trace-id", trace_id])
if json_output:
args.append("--json")
try:
from ..features.debug import run_debug_command
exit_code = run_debug_command(args)
raise typer.Exit(exit_code)
except ImportError as e:
output = get_output_controller()
output.print_error(f"Debug module not available: {e}")
raise typer.Exit(4)
@app.callback(invoke_without_command=True)
def debug_callback(ctx: typer.Context):
"""Show debug help."""
if ctx.invoked_subcommand is None:
output = get_output_controller()
output.print_panel(
"Debug commands for testing interactive coding assistant flows.\n\n"
"Commands:\n"
" interactive Run single interactive turn\n"
" lsp Direct LSP probes\n"
" acp Direct ACP probes\n"
" trace Trace record/replay/diff",
title="Debug Commands"
)
@@ -0,0 +1,109 @@
"""
Deploy command group for PraisonAI CLI.
Provides deployment commands.
"""
import typer
app = typer.Typer(help="Deployment management")
@app.command("docker")
def deploy_docker(
file: str = typer.Argument("agents.yaml", help="Agent file to deploy"),
tag: str = typer.Option(None, "--tag", "-t", help="Docker image tag"),
):
"""Deploy as Docker container."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['deploy', 'docker', file]
if tag:
argv.extend(['--tag', tag])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("aws")
def deploy_aws(
file: str = typer.Argument("agents.yaml", help="Agent file to deploy"),
region: str = typer.Option(None, "--region", "-r", help="AWS region"),
):
"""Deploy to AWS."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['deploy', 'aws', file]
if region:
argv.extend(['--region', region])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("gcp")
def deploy_gcp(
file: str = typer.Argument("agents.yaml", help="Agent file to deploy"),
project: str = typer.Option(None, "--project", "-p", help="GCP project"),
):
"""Deploy to Google Cloud."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['deploy', 'gcp', file]
if project:
argv.extend(['--project', project])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("azure")
def deploy_azure(
file: str = typer.Argument("agents.yaml", help="Agent file to deploy"),
resource_group: str = typer.Option(None, "--resource-group", "-g", help="Azure resource group"),
):
"""Deploy to Azure."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['deploy', 'azure', file]
if resource_group:
argv.extend(['--resource-group', resource_group])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,55 @@
"""
Diag command group for PraisonAI CLI.
Wraps existing diagnostics functionality from features/diag.py.
Provides diagnostic export for bug reports.
"""
from typing import Optional
import typer
from ..output.console import get_output_controller
app = typer.Typer(help="Diagnostics export")
@app.command("export")
def diag_export(
output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output path"),
workspace: str = typer.Option(".", "--workspace", "-w", help="Workspace root directory"),
include_logs: bool = typer.Option(True, "--include-logs/--no-logs", help="Include log files"),
include_config: bool = typer.Option(True, "--include-config/--no-config", help="Include configuration"),
include_trace: bool = typer.Option(True, "--include-trace/--no-trace", help="Include recent traces"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Export diagnostic bundle for bug reports."""
args = ["export", "--workspace", workspace]
if output_path:
args.extend(["--output", output_path])
if not include_logs:
args.append("--no-logs")
if not include_config:
args.append("--no-config")
if not include_trace:
args.append("--no-trace")
if json_output:
args.append("--json")
try:
from ..features.diag import run_diag_command
exit_code = run_diag_command(args)
raise typer.Exit(exit_code)
except ImportError as e:
output = get_output_controller()
output.print_error(f"Diag module not available: {e}")
raise typer.Exit(4)
@app.callback(invoke_without_command=True)
def diag_callback(ctx: typer.Context):
"""Show diag help or run export."""
if ctx.invoked_subcommand is None:
# Default to export
diag_export()
@@ -0,0 +1,54 @@
"""
Docs command group for PraisonAI CLI.
Provides documentation commands.
"""
import typer
app = typer.Typer(help="Documentation management")
@app.command("generate")
def docs_generate(
source: str = typer.Argument(".", help="Source directory"),
output: str = typer.Option("docs", "--output", "-o", help="Output directory"),
):
"""Generate documentation."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['docs', 'generate', source, '--output', output]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("serve")
def docs_serve(
port: int = typer.Option(8000, "--port", "-p", help="Port to serve on"),
):
"""Serve documentation locally."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['docs', 'serve', '--port', str(port)]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,166 @@
"""
Doctor command group for PraisonAI CLI.
Wraps existing doctor functionality from features/doctor/.
Provides health checks and diagnostics.
"""
from typing import Optional
import typer
from ..output.console import get_output_controller
app = typer.Typer(help="Health checks and diagnostics")
def _run_doctor(args: list) -> int:
"""Run doctor command with args."""
try:
from ..features.doctor.handler import DoctorHandler
handler = DoctorHandler()
# DoctorHandler uses execute(action, action_args) pattern
action = args[0] if args else None
action_args = args[1:] if len(args) > 1 else []
return handler.execute(action, action_args)
except ImportError as e:
output = get_output_controller()
output.print_error(f"Doctor module not available: {e}")
return 4
except Exception as e:
output = get_output_controller()
output.print_error(f"Doctor error: {e}")
return 1
@app.command("env")
def doctor_env(
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
timeout: float = typer.Option(10.0, "--timeout", help="Per-check timeout"),
):
"""Check environment variables and API keys."""
args = ["env"]
if deep:
args.append("--deep")
if json_output:
args.append("--json")
args.extend(["--timeout", str(timeout)])
raise typer.Exit(_run_doctor(args))
@app.command("config")
def doctor_config(
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Check configuration files."""
args = ["config"]
if deep:
args.append("--deep")
if json_output:
args.append("--json")
raise typer.Exit(_run_doctor(args))
@app.command("tools")
def doctor_tools(
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Check tools availability."""
args = ["tools"]
if deep:
args.append("--deep")
if json_output:
args.append("--json")
raise typer.Exit(_run_doctor(args))
@app.command("db")
def doctor_db(
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Check database connections."""
args = ["db"]
if deep:
args.append("--deep")
if json_output:
args.append("--json")
raise typer.Exit(_run_doctor(args))
@app.command("mcp")
def doctor_mcp(
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Check MCP servers."""
args = ["mcp"]
if deep:
args.append("--deep")
if json_output:
args.append("--json")
raise typer.Exit(_run_doctor(args))
@app.command("network")
def doctor_network(
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Check network connectivity."""
args = ["network"]
if deep:
args.append("--deep")
if json_output:
args.append("--json")
raise typer.Exit(_run_doctor(args))
@app.command("performance")
def doctor_performance(
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Check performance metrics."""
args = ["performance"]
if deep:
args.append("--deep")
if json_output:
args.append("--json")
raise typer.Exit(_run_doctor(args))
@app.command("selftest")
def doctor_selftest(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Run self-test."""
args = ["selftest"]
if json_output:
args.append("--json")
raise typer.Exit(_run_doctor(args))
@app.callback(invoke_without_command=True)
def doctor_callback(
ctx: typer.Context,
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
strict: bool = typer.Option(False, "--strict", help="Treat warnings as failures"),
quiet: bool = typer.Option(False, "--quiet", "-q", help="Minimal output"),
):
"""Run all fast health checks."""
if ctx.invoked_subcommand is None:
args = []
if deep:
args.append("--deep")
if json_output:
args.append("--json")
if strict:
args.append("--strict")
if quiet:
args.append("--quiet")
raise typer.Exit(_run_doctor(args))
@@ -0,0 +1,51 @@
"""
Endpoints command group for PraisonAI CLI.
Provides API endpoint management commands.
"""
import typer
app = typer.Typer(help="API endpoint management")
@app.command("list")
def endpoints_list():
"""List available endpoints."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['endpoints', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("test")
def endpoints_test(
endpoint: str = typer.Argument(..., help="Endpoint to test"),
):
"""Test an endpoint."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['endpoints', 'test', endpoint]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,179 @@
"""
Environment command group for PraisonAI CLI.
Provides environment management and diagnostics:
- env view: Show environment variables
- env check: Validate API keys
- env doctor: Run diagnostics (alias for doctor command)
"""
import os
from typing import Optional
import typer
from ..output.console import get_output_controller
app = typer.Typer(help="Environment and diagnostics")
# Known API key patterns
API_KEY_PATTERNS = {
"OPENAI_API_KEY": {"prefix": "sk-", "min_length": 20},
"ANTHROPIC_API_KEY": {"prefix": "sk-ant-", "min_length": 20},
"GOOGLE_API_KEY": {"prefix": "AI", "min_length": 20},
"GEMINI_API_KEY": {"prefix": "AI", "min_length": 20},
"TAVILY_API_KEY": {"prefix": "tvly-", "min_length": 20},
"GROQ_API_KEY": {"prefix": "gsk_", "min_length": 20},
}
def _redact_key(value: str, show_chars: int = 4) -> str:
"""Redact an API key for display."""
if len(value) <= show_chars * 2:
return "*" * len(value)
return value[:show_chars] + "*" * (len(value) - show_chars * 2) + value[-show_chars:]
def _validate_key(name: str, value: str) -> tuple[bool, str]:
"""Validate an API key format."""
if name not in API_KEY_PATTERNS:
return True, "Unknown key type"
pattern = API_KEY_PATTERNS[name]
if len(value) < pattern["min_length"]:
return False, f"Too short (expected >= {pattern['min_length']} chars)"
if pattern.get("prefix") and not value.startswith(pattern["prefix"]):
return False, f"Invalid prefix (expected {pattern['prefix']}...)"
return True, "Valid format"
@app.command("view")
def env_view(
show_values: bool = typer.Option(
False,
"--show-values",
help="Show actual values (redacted by default)",
),
filter_prefix: Optional[str] = typer.Option(
None,
"--filter",
"-f",
help="Filter by prefix (e.g., OPENAI, PRAISONAI)",
),
):
"""Show relevant environment variables."""
output = get_output_controller()
# Relevant prefixes
prefixes = ["OPENAI", "ANTHROPIC", "GOOGLE", "GEMINI", "TAVILY", "GROQ",
"PRAISONAI", "MODEL", "LLM", "OLLAMA", "AZURE"]
if filter_prefix:
prefixes = [filter_prefix.upper()]
env_vars = {}
for key, value in os.environ.items():
for prefix in prefixes:
if key.upper().startswith(prefix):
if show_values:
if "KEY" in key.upper() or "SECRET" in key.upper() or "TOKEN" in key.upper():
env_vars[key] = _redact_key(value)
else:
env_vars[key] = value
else:
env_vars[key] = "***" if value else "(empty)"
break
if output.is_json_mode:
output.print_json({"environment": env_vars})
return
if not env_vars:
output.print_info("No relevant environment variables found")
return
headers = ["Variable", "Value"]
rows = [[k, v] for k, v in sorted(env_vars.items())]
output.print_table(headers, rows, title="Environment Variables")
@app.command("check")
def env_check():
"""Validate API keys and configuration."""
output = get_output_controller()
results = []
all_valid = True
for key_name, pattern in API_KEY_PATTERNS.items():
value = os.environ.get(key_name)
if value:
valid, message = _validate_key(key_name, value)
results.append({
"key": key_name,
"present": True,
"valid": valid,
"message": message,
})
if not valid:
all_valid = False
else:
results.append({
"key": key_name,
"present": False,
"valid": False,
"message": "Not set",
})
if output.is_json_mode:
output.print_json({"checks": results, "all_valid": all_valid})
return
headers = ["Key", "Status", "Message"]
rows = []
for r in results:
if r["present"]:
status = "✅" if r["valid"] else "⚠️"
else:
status = "❌"
rows.append([r["key"], status, r["message"]])
output.print_table(headers, rows, title="API Key Validation")
if not all_valid:
output.print_warning("Some API keys are missing or invalid")
raise typer.Exit(3) # Exit code 3 = missing config/env
@app.command("doctor")
def env_doctor(
deep: bool = typer.Option(
False,
"--deep",
help="Run deep checks (network, DB connections)",
),
):
"""Run environment diagnostics (alias for 'praisonai doctor env')."""
output = get_output_controller()
# Delegate to doctor command
try:
from ..features.doctor.handler import DoctorHandler
handler = DoctorHandler()
# Run env checks
if output.is_json_mode:
# Run and get JSON output
result = handler.handle(["env", "--json"] if not deep else ["env", "--json", "--deep"])
else:
result = handler.handle(["env"] if not deep else ["env", "--deep"])
raise typer.Exit(result)
except ImportError:
# Fallback to basic env check
env_check()
@@ -0,0 +1,57 @@
"""
Eval command group for PraisonAI CLI.
Provides evaluation commands.
"""
import typer
app = typer.Typer(help="Evaluation and testing")
@app.command("accuracy")
def eval_accuracy(
agent: str = typer.Argument(..., help="Agent file or name"),
input_text: str = typer.Option(..., "--input", "-i", help="Input text"),
expected: str = typer.Option(..., "--expected", "-e", help="Expected output"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
):
"""Run accuracy evaluation."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['eval', 'accuracy', agent, '--input', input_text, '--expected', expected]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("performance")
def eval_performance(
agent: str = typer.Argument(..., help="Agent file or name"),
iterations: int = typer.Option(50, "--iterations", "-n", help="Number of iterations"),
):
"""Run performance evaluation."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['eval', 'performance', agent, '--iterations', str(iterations)]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,74 @@
"""
Hooks command group for PraisonAI CLI.
Provides hook management commands.
"""
import typer
app = typer.Typer(help="Hook management")
@app.command("list")
def hooks_list():
"""List available hooks."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['hooks', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("add")
def hooks_add(
name: str = typer.Argument(..., help="Hook name"),
event: str = typer.Option(..., "--event", "-e", help="Event to hook into"),
):
"""Add a hook."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['hooks', 'add', name, '--event', event]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("remove")
def hooks_remove(
name: str = typer.Argument(..., help="Hook name to remove"),
):
"""Remove a hook."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['hooks', 'remove', name]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,76 @@
"""
Knowledge command group for PraisonAI CLI.
Provides knowledge base management commands.
"""
import typer
app = typer.Typer(help="Knowledge base management")
@app.command("add")
def knowledge_add(
source: str = typer.Argument(..., help="Source file or URL"),
name: str = typer.Option(None, "--name", "-n", help="Knowledge base name"),
):
"""Add knowledge from a source."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['knowledge', 'add', source]
if name:
argv.extend(['--name', name])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("list")
def knowledge_list():
"""List knowledge bases."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['knowledge', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("search")
def knowledge_search(
query: str = typer.Argument(..., help="Search query"),
):
"""Search knowledge base."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['knowledge', 'search', query]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
+112
View File
@@ -0,0 +1,112 @@
"""
LSP command group for PraisonAI CLI.
Wraps existing LSP functionality from features/lsp_cli.py.
Provides LSP service lifecycle management.
"""
from typing import Optional
import typer
from ..output.console import get_output_controller
app = typer.Typer(help="LSP service lifecycle management")
@app.command("start")
def lsp_start(
workspace: str = typer.Option(".", "--workspace", "-w", help="Workspace root directory"),
language: Optional[str] = typer.Option(None, "--language", "-l", help="Language to start"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Start LSP server(s)."""
args = ["start", "--workspace", workspace]
if language:
args.extend(["--language", language])
if json_output:
args.append("--json")
try:
from ..features.lsp_cli import run_lsp_command
exit_code = run_lsp_command(args)
raise typer.Exit(exit_code)
except ImportError as e:
output = get_output_controller()
output.print_error(f"LSP module not available: {e}")
raise typer.Exit(4)
@app.command("stop")
def lsp_stop(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Stop LSP server(s)."""
args = ["stop"]
if json_output:
args.append("--json")
try:
from ..features.lsp_cli import run_lsp_command
exit_code = run_lsp_command(args)
raise typer.Exit(exit_code)
except ImportError as e:
output = get_output_controller()
output.print_error(f"LSP module not available: {e}")
raise typer.Exit(4)
@app.command("status")
def lsp_status(
workspace: str = typer.Option(".", "--workspace", "-w", help="Workspace root directory"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Show LSP status."""
args = ["status", "--workspace", workspace]
if json_output:
args.append("--json")
try:
from ..features.lsp_cli import run_lsp_command
exit_code = run_lsp_command(args)
raise typer.Exit(exit_code)
except ImportError as e:
output = get_output_controller()
output.print_error(f"LSP module not available: {e}")
raise typer.Exit(4)
@app.command("logs")
def lsp_logs(
tail: int = typer.Option(50, "--tail", "-n", help="Number of lines to show"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Show recent LSP logs."""
args = ["logs", "--tail", str(tail)]
if json_output:
args.append("--json")
try:
from ..features.lsp_cli import run_lsp_command
exit_code = run_lsp_command(args)
raise typer.Exit(exit_code)
except ImportError as e:
output = get_output_controller()
output.print_error(f"LSP module not available: {e}")
raise typer.Exit(4)
@app.callback(invoke_without_command=True)
def lsp_callback(ctx: typer.Context):
"""Show LSP help."""
if ctx.invoked_subcommand is None:
output = get_output_controller()
output.print_panel(
"LSP service lifecycle management.\n\n"
"Commands:\n"
" start Start LSP server(s)\n"
" stop Stop LSP server(s)\n"
" status Show LSP status\n"
" logs Show recent logs",
title="LSP Commands"
)
+210
View File
@@ -0,0 +1,210 @@
"""
MCP command group for PraisonAI CLI.
Provides MCP (Model Context Protocol) server management.
"""
from typing import Optional
import typer
from ..output.console import get_output_controller
from ..configuration.loader import get_config_loader
app = typer.Typer(help="MCP server management")
@app.command("list")
def mcp_list(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List configured MCP servers."""
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
servers = config.mcp.servers
if output.is_json_mode or json_output:
output.print_json({
"servers": {
name: {
"command": s.command,
"args": s.args,
"enabled": s.enabled,
}
for name, s in servers.items()
}
})
return
if not servers:
output.print_info("No MCP servers configured")
output.print("\nAdd a server with: praisonai mcp add <name> <command>")
return
headers = ["Name", "Command", "Enabled"]
rows = []
for name, server in servers.items():
cmd = f"{server.command} {' '.join(server.args)}"
if len(cmd) > 50:
cmd = cmd[:47] + "..."
rows.append([name, cmd, "✓" if server.enabled else "✗"])
output.print_table(headers, rows, title="MCP Servers")
@app.command("add")
def mcp_add(
name: str = typer.Argument(..., help="Server name"),
command: str = typer.Argument(..., help="Command to run"),
args: Optional[str] = typer.Option(None, "--args", "-a", help="Command arguments (space-separated)"),
env: Optional[str] = typer.Option(None, "--env", "-e", help="Environment variables (KEY=VALUE,...)"),
):
"""Add an MCP server."""
output = get_output_controller()
loader = get_config_loader()
# Parse args
arg_list = args.split() if args else []
# Parse env
env_dict = {}
if env:
for pair in env.split(","):
if "=" in pair:
key, value = pair.split("=", 1)
env_dict[key.strip()] = value.strip()
# Build server config
server_config = {
"command": command,
"args": arg_list,
"env": env_dict,
"enabled": True,
}
# Save to config
loader.set(f"mcp.servers.{name}", server_config)
if output.is_json_mode:
output.print_json({"added": name, "config": server_config})
else:
output.print_success(f"Added MCP server: {name}")
@app.command("remove")
def mcp_remove(
name: str = typer.Argument(..., help="Server name to remove"),
confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
):
"""Remove an MCP server."""
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
if name not in config.mcp.servers:
output.print_error(f"Server not found: {name}")
raise typer.Exit(1)
if not confirm:
confirmed = typer.confirm(f"Remove MCP server '{name}'?")
if not confirmed:
output.print_info("Cancelled")
raise typer.Exit(0)
# Remove from config by setting to None (will be cleaned up)
# For now, we need to reload and modify the raw config
from ..configuration.paths import get_user_config_path
from ..configuration.loader import _load_toml, _save_toml
config_path = get_user_config_path()
if config_path.exists():
raw_config = _load_toml(config_path)
if "mcp" in raw_config and "servers" in raw_config["mcp"]:
if name in raw_config["mcp"]["servers"]:
del raw_config["mcp"]["servers"][name]
_save_toml(config_path, raw_config)
if output.is_json_mode:
output.print_json({"removed": name})
else:
output.print_success(f"Removed MCP server: {name}")
@app.command("test")
def mcp_test(
name: str = typer.Argument(..., help="Server name to test"),
timeout: float = typer.Option(10.0, "--timeout", "-t", help="Timeout in seconds"),
):
"""Test an MCP server connection."""
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
if name not in config.mcp.servers:
output.print_error(f"Server not found: {name}")
raise typer.Exit(1)
server = config.mcp.servers[name]
output.print_info(f"Testing MCP server: {name}")
output.print(f" Command: {server.command} {' '.join(server.args)}")
# Try to start the server and check if it responds
import subprocess
import time
try:
cmd = [server.command] + server.args
env = dict(**server.env) if server.env else None
# Start process
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
# Wait briefly and check if it's running
time.sleep(0.5)
if proc.poll() is None:
# Process is running
proc.terminate()
proc.wait(timeout=2)
if output.is_json_mode:
output.print_json({"name": name, "status": "ok", "message": "Server started successfully"})
else:
output.print_success(f"Server '{name}' started successfully")
else:
# Process exited
stderr = proc.stderr.read().decode() if proc.stderr else ""
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": stderr or "Server exited immediately"})
else:
output.print_error(f"Server exited immediately: {stderr}")
raise typer.Exit(1)
except FileNotFoundError:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": f"Command not found: {server.command}"})
else:
output.print_error(f"Command not found: {server.command}")
raise typer.Exit(1)
except Exception as e:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": str(e)})
else:
output.print_error(f"Test failed: {e}")
raise typer.Exit(1)
@app.callback(invoke_without_command=True)
def mcp_callback(ctx: typer.Context):
"""Show MCP help or list servers."""
if ctx.invoked_subcommand is None:
mcp_list(json_output=False)
@@ -0,0 +1,109 @@
"""
Memory command group for PraisonAI CLI.
Provides memory management commands.
"""
import typer
app = typer.Typer(help="Memory management")
@app.command("show")
def memory_show(
user_id: str = typer.Option(None, "--user-id", help="User ID for memory isolation"),
limit: int = typer.Option(10, "--limit", "-l", help="Number of memories to show"),
):
"""Show stored memories."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['memory', 'show']
if user_id:
argv.extend(['--user-id', user_id])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("add")
def memory_add(
content: str = typer.Argument(..., help="Memory content to add"),
user_id: str = typer.Option(None, "--user-id", help="User ID for memory isolation"),
):
"""Add a memory."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['memory', 'add', content]
if user_id:
argv.extend(['--user-id', user_id])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("search")
def memory_search(
query: str = typer.Argument(..., help="Search query"),
user_id: str = typer.Option(None, "--user-id", help="User ID for memory isolation"),
):
"""Search memories."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['memory', 'search', query]
if user_id:
argv.extend(['--user-id', user_id])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("clear")
def memory_clear(
user_id: str = typer.Option(None, "--user-id", help="User ID for memory isolation"),
force: bool = typer.Option(False, "--force", "-f", help="Force clear without confirmation"),
):
"""Clear all memories."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['memory', 'clear']
if user_id:
argv.extend(['--user-id', user_id])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,76 @@
"""
Package command group for PraisonAI CLI.
Provides package management commands (pip-like).
"""
import typer
app = typer.Typer(help="Package management")
@app.command("install")
def package_install(
package: str = typer.Argument(..., help="Package to install"),
upgrade: bool = typer.Option(False, "--upgrade", "-U", help="Upgrade if already installed"),
):
"""Install a package."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['package', 'install', package]
if upgrade:
argv.append('--upgrade')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("uninstall")
def package_uninstall(
package: str = typer.Argument(..., help="Package to uninstall"),
):
"""Uninstall a package."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['package', 'uninstall', package]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("list")
def package_list():
"""List installed packages."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['package', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,466 @@
"""
Profile command group for PraisonAI CLI.
Provides detailed cProfile-based profiling for query execution.
"""
import sys
from typing import Optional
import typer
app = typer.Typer(help="Performance profiling and diagnostics")
@app.command("query")
def profile_query(
prompt: str = typer.Argument(..., help="Prompt to profile"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="Model to use"),
stream: bool = typer.Option(False, "--stream/--no-stream", help="Use streaming mode"),
deep: bool = typer.Option(False, "--deep", help="Enable deep call tracing (higher overhead)"),
limit: int = typer.Option(30, "--limit", "-n", help="Top N functions to show"),
sort: str = typer.Option("cumulative", "--sort", "-s", help="Sort by: cumulative or tottime"),
show_callers: bool = typer.Option(False, "--show-callers", help="Show caller functions"),
show_callees: bool = typer.Option(False, "--show-callees", help="Show callee functions"),
save: Optional[str] = typer.Option(None, "--save", help="Save artifacts to path (creates .prof, .txt, .json)"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Profile a query execution with detailed timing breakdown.
Uses the unified profiling architecture for consistent results across
CLI direct invocation (--profile) and this command.
Shows per-function timing, call graphs, and latency metrics.
Examples:
praisonai profile query "What is 2+2?"
praisonai profile query "Hello" --limit 20
praisonai profile query "Test" --deep --show-callers --show-callees
praisonai profile query "Test" --save ./profile_results
"""
# Use unified profiler for consistent results
try:
from ..execution import ExecutionRequest, Profiler, ProfilerConfig as UnifiedProfilerConfig
except ImportError as e:
typer.echo(f"Error: Unified profiler not available: {e}", err=True)
raise typer.Exit(1)
# Warn about deep tracing overhead
if deep:
typer.echo("⚠️ Deep call tracing enabled - this adds significant overhead", err=True)
# Create unified execution request
request = ExecutionRequest(
prompt=prompt,
agent_name="ProfiledAgent",
model=model,
stream=stream,
)
# Create unified profiler config
config = UnifiedProfilerConfig(
layer=2 if deep else 1,
limit=limit,
sort_by=sort,
show_callers=show_callers,
show_callees=show_callees,
output_format=output_format,
save_path=save,
)
# Run profiler
typer.echo("🔬 Starting profiled execution...", err=True)
try:
profiler = Profiler(config)
result, report = profiler.profile_sync(request, invocation_method="profile_command")
except ImportError as e:
typer.echo(f"Error: {e}", err=True)
typer.echo("Install praisonaiagents: pip install praisonaiagents", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error during profiling: {e}", err=True)
raise typer.Exit(1)
# Output results
if output_format == "json":
typer.echo(report.to_json())
else:
typer.echo(report.to_text())
# Save artifacts if requested
if save:
try:
import os
os.makedirs(save, exist_ok=True)
# Save JSON report
json_path = os.path.join(save, "profile_report.json")
with open(json_path, 'w') as f:
f.write(report.to_json())
# Save text report
txt_path = os.path.join(save, "profile_report.txt")
with open(txt_path, 'w') as f:
f.write(report.to_text())
typer.echo("\n✅ Artifacts saved:", err=True)
typer.echo(f" {json_path}", err=True)
typer.echo(f" {txt_path}", err=True)
except Exception as e:
typer.echo(f"Warning: Failed to save artifacts: {e}", err=True)
@app.command("imports")
def profile_imports():
"""
Profile module import times.
Shows which modules take longest to import, useful for optimizing startup time.
"""
import subprocess
import re
typer.echo("🔬 Profiling import times...", err=True)
try:
result = subprocess.run(
[sys.executable, "-X", "importtime", "-c", "import praisonaiagents"],
capture_output=True,
text=True,
timeout=60,
)
# Parse import times
import_times = []
for line in result.stderr.split('\n'):
if 'import time:' in line:
match = re.search(r'import time:\s+(\d+)\s+\|\s+(\d+)\s+\|\s+(.+)', line)
if match:
self_time = int(match.group(1))
cumulative = int(match.group(2))
module = match.group(3).strip()
import_times.append((module, self_time, cumulative))
# Sort by cumulative time
import_times.sort(key=lambda x: x[2], reverse=True)
# Display
typer.echo("\n" + "=" * 70)
typer.echo("Import Time Analysis")
typer.echo("=" * 70)
typer.echo(f"{'Module':<45} {'Self (μs)':>10} {'Cumul (μs)':>12}")
typer.echo("-" * 70)
for module, self_time, cumulative in import_times[:30]:
module_name = module
if len(module_name) > 43:
module_name = "..." + module_name[-40:]
typer.echo(f"{module_name:<45} {self_time:>10} {cumulative:>12}")
typer.echo("-" * 70)
if import_times:
total_time = max(t[2] for t in import_times)
typer.echo(f"Total import time: {total_time / 1000:.2f} ms")
except subprocess.TimeoutExpired:
typer.echo("Error: Import profiling timed out", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command("startup")
def profile_startup():
"""
Profile CLI startup time.
Measures time from CLI invocation to ready state.
"""
import time
import subprocess
typer.echo("🔬 Profiling startup time...", err=True)
# Measure cold start (new process)
start = time.perf_counter()
subprocess.run(
[sys.executable, "-c", "import praisonai; import praisonai.cli"],
capture_output=True,
text=True,
)
cold_start = (time.perf_counter() - start) * 1000
# Measure warm start (imports cached)
start = time.perf_counter()
subprocess.run(
[sys.executable, "-c", "import praisonai; import praisonai.cli"],
capture_output=True,
text=True,
)
warm_start = (time.perf_counter() - start) * 1000
typer.echo("\n" + "=" * 50)
typer.echo("Startup Time Analysis")
typer.echo("=" * 50)
typer.echo(f"Cold Start: {cold_start:>10.2f} ms")
typer.echo(f"Warm Start: {warm_start:>10.2f} ms")
typer.echo("=" * 50)
if cold_start > 1000:
typer.echo("\n⚠️ Cold start > 1s - consider lazy imports", err=True)
@app.command("suite")
def profile_suite(
output_dir: Optional[str] = typer.Option("/tmp/praisonai_profile_suite", "--output", "-o", help="Output directory for results"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Iterations per scenario"),
quick: bool = typer.Option(False, "--quick", help="Quick mode (fewer iterations)"),
):
"""
Run a comprehensive profiling suite.
Runs multiple scenarios (streaming/non-streaming, simple/complex prompts)
and produces aggregated statistics and reports.
Examples:
praisonai profile suite
praisonai profile suite --output ./my_profile_results
praisonai profile suite --quick
"""
try:
from ..features.profiler import run_profile_suite, ScenarioConfig
except ImportError as e:
typer.echo(f"Error: Profiler module not available: {e}", err=True)
raise typer.Exit(1)
# Define scenarios
iters = 1 if quick else iterations
scenarios = [
ScenarioConfig(name="simple_non_stream", prompt="hi", stream=False, iterations=iters, warmup=0 if quick else 1),
ScenarioConfig(name="simple_stream", prompt="hi", stream=True, iterations=iters, warmup=0 if quick else 1),
]
if not quick:
scenarios.extend([
ScenarioConfig(name="medium_non_stream", prompt="Explain Python in 2 sentences", stream=False, iterations=max(1, iters-1)),
ScenarioConfig(name="medium_stream", prompt="Explain Python in 2 sentences", stream=True, iterations=max(1, iters-1)),
])
typer.echo("🔬 Running Profile Suite...", err=True)
typer.echo(f" Output: {output_dir}", err=True)
typer.echo(f" Scenarios: {len(scenarios)}", err=True)
typer.echo(f" Iterations: {iterations}", err=True)
try:
result = run_profile_suite(
output_dir=output_dir,
scenarios=scenarios,
verbose=True,
)
# Print summary
typer.echo("\n" + "=" * 60)
typer.echo("Profile Suite Summary")
typer.echo("=" * 60)
typer.echo(f"Startup Cold: {result.startup_cold_ms:.2f}ms")
typer.echo(f"Startup Warm: {result.startup_warm_ms:.2f}ms")
if result.import_analysis:
typer.echo(f"\nTop Import: {result.import_analysis[0]['module']}")
typer.echo(f" Time: {result.import_analysis[0]['cumulative_ms']:.2f}ms")
typer.echo("\nScenario Results:")
for scenario in result.scenarios:
stats = scenario.get_stats(scenario.total_times)
typer.echo(f" {scenario.name}: {stats['mean']:.2f}ms (±{stats['stdev']:.2f}ms)")
typer.echo(f"\n✅ Full results saved to: {output_dir}")
except Exception as e:
typer.echo(f"Error running suite: {e}", err=True)
raise typer.Exit(1)
@app.command("snapshot")
def profile_snapshot(
name: str = typer.Argument("current", help="Name for the snapshot"),
baseline: bool = typer.Option(False, "--baseline", "-b", help="Save as baseline for comparison"),
compare: bool = typer.Option(False, "--compare", "-c", help="Compare against baseline"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Create or compare performance snapshots.
Snapshots record timing data for baseline comparison.
Use --baseline to save a reference point, then --compare to check for regressions.
Examples:
praisonai profile snapshot --baseline
praisonai profile snapshot current --compare
praisonai profile snapshot v2.0 --format json
"""
try:
from ..features.profiler import (
run_profile_suite,
ScenarioConfig,
PerfSnapshotManager,
create_snapshot_from_suite,
format_comparison_report,
)
except ImportError as e:
typer.echo(f"Error: Profiler module not available: {e}", err=True)
raise typer.Exit(1)
manager = PerfSnapshotManager()
if compare:
# Load baseline and compare
baseline_snap = manager.load_baseline()
if not baseline_snap:
typer.echo("❌ No baseline found. Create one with: praisonai profile snapshot --baseline", err=True)
raise typer.Exit(1)
typer.echo("🔬 Running current profile for comparison...", err=True)
# Run quick suite
scenarios = [
ScenarioConfig(name="simple", prompt="hi", stream=False, iterations=2, warmup=1),
]
result = run_profile_suite(
output_dir="/tmp/praisonai_snapshot_compare",
scenarios=scenarios,
verbose=False,
)
current_snap = create_snapshot_from_suite(result, name)
comparison = manager.compare(current_snap)
if output_format == "json":
import json
typer.echo(json.dumps(comparison.to_dict(), indent=2))
else:
typer.echo(format_comparison_report(comparison))
if comparison.is_regression():
raise typer.Exit(1) # Exit with error on regression
else:
# Create new snapshot
typer.echo(f"🔬 Creating performance snapshot '{name}'...", err=True)
scenarios = [
ScenarioConfig(name="simple", prompt="hi", stream=False, iterations=3, warmup=1),
ScenarioConfig(name="stream", prompt="hi", stream=True, iterations=2, warmup=1),
]
result = run_profile_suite(
output_dir="/tmp/praisonai_snapshot",
scenarios=scenarios,
verbose=True,
)
snapshot = create_snapshot_from_suite(result, name)
if baseline:
path = manager.save_baseline(snapshot)
typer.echo(f"\n✅ Baseline saved to: {path}")
else:
path = manager.save(snapshot)
typer.echo(f"\n✅ Snapshot saved to: {path}")
if output_format == "json":
import json
typer.echo(json.dumps(snapshot.to_dict(), indent=2))
else:
typer.echo("\nSnapshot Summary:")
typer.echo(f" Startup Cold: {snapshot.startup_cold_ms:.2f}ms")
typer.echo(f" Import Time: {snapshot.import_time_ms:.2f}ms")
typer.echo(f" Query Time: {snapshot.query_time_ms:.2f}ms")
@app.command("optimize")
def profile_optimize(
prewarm: bool = typer.Option(False, "--prewarm", help="Enable provider pre-warming"),
lite: bool = typer.Option(False, "--lite", help="Enable lite mode (skip type validation)"),
show_config: bool = typer.Option(False, "--show", help="Show current optimization config"),
):
"""
Configure performance optimizations.
All optimizations are opt-in and safe by default.
Examples:
praisonai profile optimize --show
praisonai profile optimize --prewarm
praisonai profile optimize --lite
"""
try:
from ..features.profiler import (
PrewarmManager,
get_lite_mode_config,
get_provider_cache,
)
except ImportError as e:
typer.echo(f"Error: Profiler module not available: {e}", err=True)
raise typer.Exit(1)
if show_config:
typer.echo("=" * 50)
typer.echo("Performance Optimization Status")
typer.echo("=" * 50)
lite_config = get_lite_mode_config()
typer.echo(f"\nLite Mode: {'Enabled' if lite_config.enabled else 'Disabled'}")
if lite_config.enabled:
typer.echo(f" Skip Type Validation: {lite_config.skip_type_validation}")
typer.echo(f" Skip Model Validation: {lite_config.skip_model_validation}")
typer.echo(f" Minimal Imports: {lite_config.minimal_imports}")
typer.echo(f"\nPre-warming: {'Enabled' if PrewarmManager.is_enabled() else 'Disabled'}")
cache = get_provider_cache()
stats = cache.get_stats()
typer.echo("\nProvider Cache:")
typer.echo(f" Hits: {stats['hits']}")
typer.echo(f" Misses: {stats['misses']}")
typer.echo(f" Size: {stats['size']}")
typer.echo("\n" + "=" * 50)
typer.echo("\nTo enable optimizations, set environment variables:")
typer.echo(" PRAISONAI_LITE_MODE=1")
typer.echo(" PRAISONAI_SKIP_TYPE_VALIDATION=1")
typer.echo(" PRAISONAI_MINIMAL_IMPORTS=1")
return
if prewarm:
typer.echo("🔥 Enabling provider pre-warming...")
PrewarmManager.enable()
import os
if os.environ.get('OPENAI_API_KEY'):
typer.echo(" Pre-warming OpenAI...")
PrewarmManager.prewarm_provider('openai')
if os.environ.get('ANTHROPIC_API_KEY'):
typer.echo(" Pre-warming Anthropic...")
PrewarmManager.prewarm_provider('anthropic')
typer.echo("✅ Pre-warming initiated (runs in background)")
if lite:
typer.echo("⚡ Lite mode configuration:")
typer.echo(" Set these environment variables to enable:")
typer.echo(" export PRAISONAI_LITE_MODE=1")
typer.echo(" export PRAISONAI_SKIP_TYPE_VALIDATION=1")
typer.echo(" export PRAISONAI_MINIMAL_IMPORTS=1")
if not prewarm and not lite and not show_config:
typer.echo("Use --show to see current config, or --prewarm/--lite to enable optimizations")
@app.callback(invoke_without_command=True)
def profile_callback(ctx: typer.Context):
"""Show profile help if no subcommand."""
if ctx.invoked_subcommand is None:
typer.echo(ctx.get_help())
@@ -0,0 +1,45 @@
"""
Realtime command group for PraisonAI CLI.
Provides realtime interaction commands.
"""
from typing import Optional
import typer
app = typer.Typer(help="Realtime interaction mode")
@app.callback(invoke_without_command=True)
def realtime_main(
ctx: typer.Context,
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""
Start realtime interaction mode.
Examples:
praisonai realtime
praisonai realtime --model gpt-4o
"""
from praisonai.cli.main import PraisonAI
import sys
argv = ['realtime']
if model:
argv.extend(['--model', model])
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,76 @@
"""
Recipe command group for PraisonAI CLI.
Provides recipe management commands.
"""
import typer
app = typer.Typer(help="Recipe management")
@app.command("list")
def recipe_list():
"""List available recipes."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['recipe', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("run")
def recipe_run(
name: str = typer.Argument(..., help="Recipe name"),
model: str = typer.Option(None, "--model", "-m", help="LLM model to use"),
):
"""Run a recipe."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['recipe', 'run', name]
if model:
argv.extend(['--model', model])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("install")
def recipe_install(
source: str = typer.Argument(..., help="Recipe source (path or URL)"),
):
"""Install a recipe."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['recipe', 'install', source]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,51 @@
"""
Registry command group for PraisonAI CLI.
Provides registry management commands.
"""
import typer
app = typer.Typer(help="Registry management")
@app.command("list")
def registry_list():
"""List registry entries."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['registry', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("serve")
def registry_serve(
port: int = typer.Option(8080, "--port", "-p", help="Port to serve on"),
):
"""Start registry server."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['registry', 'serve', '--port', str(port)]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,49 @@
"""
Research command group for PraisonAI CLI.
Provides research and analysis commands.
"""
import typer
app = typer.Typer(help="Research and analysis")
@app.callback(invoke_without_command=True)
def research_main(
ctx: typer.Context,
query: str = typer.Argument(None, help="Research query"),
model: str = typer.Option(None, "--model", "-m", help="LLM model to use"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
output: str = typer.Option(None, "--output", "-o", help="Output file path"),
):
"""
Run research and analysis tasks.
Examples:
praisonai research "What are the latest AI trends?"
praisonai research --model gpt-4o "Analyze market data"
"""
from praisonai.cli.main import PraisonAI
import sys
argv = ['research']
if query:
argv.append(query)
if model:
argv.extend(['--model', model])
if verbose:
argv.append('--verbose')
if output:
argv.extend(['--output', output])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,71 @@
"""
Rules command group for PraisonAI CLI.
Provides rules management commands.
"""
import typer
app = typer.Typer(help="Rules management")
@app.command("list")
def rules_list():
"""List active rules."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['rules', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("add")
def rules_add(
rule: str = typer.Argument(..., help="Rule to add"),
):
"""Add a rule."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['rules', 'add', rule]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("clear")
def rules_clear():
"""Clear all rules."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['rules', 'clear']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
+228
View File
@@ -0,0 +1,228 @@
"""
Run command group for PraisonAI CLI.
Provides agent execution commands.
"""
from typing import Optional
import typer
from ..output.console import get_output_controller
from ..state.identifiers import get_current_context
app = typer.Typer(help="Run agents")
@app.callback(invoke_without_command=True)
def run_main(
ctx: typer.Context,
target: Optional[str] = typer.Argument(None, help="Agent file or prompt"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
framework: Optional[str] = typer.Option(None, "--framework", "-f", help="Framework: praisonai, crewai, autogen"),
interactive: bool = typer.Option(False, "--interactive", "-i", help="Interactive mode"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
stream: bool = typer.Option(True, "--stream/--no-stream", help="Stream output"),
trace: bool = typer.Option(False, "--trace", help="Enable tracing"),
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Tools file path"),
max_tokens: int = typer.Option(16000, "--max-tokens", help="Maximum output tokens"),
):
"""
Run agents from a file or prompt.
Examples:
praisonai run agents.yaml
praisonai run "What is the weather?"
praisonai run agents.yaml --interactive
"""
output = get_output_controller()
context = get_current_context()
if not target:
output.print_panel(
"Run agents from a file or prompt.\n\n"
"Usage:\n"
" praisonai run agents.yaml\n"
" praisonai run \"What is the weather?\"\n"
" praisonai run agents.yaml --interactive\n\n"
"Options:\n"
" --model, -m LLM model to use\n"
" --framework, -f Framework (praisonai, crewai, autogen)\n"
" --interactive, -i Interactive mode\n"
" --verbose, -v Verbose output\n"
" --trace Enable tracing\n"
" --memory Enable memory",
title="Run Command"
)
return
# Emit start event
output.emit_start(
message=f"Starting run: {target[:50]}..." if len(target) > 50 else f"Starting run: {target}",
data={
"target": target,
"model": model,
"framework": framework,
}
)
# Check if target is a file or prompt
import os
is_file = os.path.exists(target) and (target.endswith('.yaml') or target.endswith('.yml'))
if is_file:
# Run from file
_run_from_file(
target,
model=model,
framework=framework,
interactive=interactive,
verbose=verbose,
stream=stream,
trace=trace,
memory=memory,
tools=tools,
max_tokens=max_tokens,
)
else:
# Run as prompt
_run_prompt(
target,
model=model,
verbose=verbose,
stream=stream,
trace=trace,
memory=memory,
tools=tools,
max_tokens=max_tokens,
)
def _run_from_file(
file_path: str,
model: Optional[str] = None,
framework: Optional[str] = None,
interactive: bool = False,
verbose: bool = False,
stream: bool = True,
trace: bool = False,
memory: bool = False,
tools: Optional[str] = None,
max_tokens: int = 16000,
):
"""Run agents from a YAML file."""
output = get_output_controller()
try:
# Use existing PraisonAI class
from praisonai.cli.main import PraisonAI
praison = PraisonAI(
agent_file=file_path,
framework=framework or "praisonai",
)
# Set model if provided
if model:
praison.config_list[0]['model'] = model
# Run
result = praison.run()
output.emit_result(
message="Run completed",
data={"result": str(result) if result else None}
)
if result:
if not output.is_json_mode:
output.print_success("Run completed")
except Exception as e:
output.emit_error(message=str(e))
output.print_error(str(e))
raise typer.Exit(1)
def _run_prompt(
prompt: str,
model: Optional[str] = None,
verbose: bool = False,
stream: bool = True,
trace: bool = False,
memory: bool = False,
tools: Optional[str] = None,
max_tokens: int = 16000,
):
"""Run a direct prompt."""
output = get_output_controller()
try:
# Use existing handle_direct_prompt
from praisonai.cli.main import PraisonAI
praison = PraisonAI()
if model:
praison.config_list[0]['model'] = model
# Create args-like object for handle_direct_prompt
class Args:
pass
args = Args()
args.llm = model
args.verbose = verbose
args.memory = memory
args.tools = tools
args.max_tokens = max_tokens
args.web_search = False
args.web_fetch = False
args.prompt_caching = False
args.planning = False
args.planning_tools = None
args.planning_reasoning = False
args.auto_approve_plan = False
args.final_agent = None
args.user_id = None
args.auto_save = None
args.history = None
args.include_rules = None
args.workflow = None
args.workflow_var = None
args.claude_memory = False
args.guardrail = None
args.metrics = False
args.image = None
args.image_generate = False
args.telemetry = False
args.mcp = None
args.mcp_env = None
args.fast_context = None
args.handoff = None
args.auto_memory = False
args.todo = False
args.router = False
args.router_provider = None
args.query_rewrite = False
args.rewrite_tools = None
args.expand_prompt = False
args.expand_tools = None
args.no_tools = False
praison.args = args
result = praison.handle_direct_prompt(prompt)
output.emit_result(
message="Prompt completed",
data={"result": str(result) if result else None}
)
if result and not output.is_json_mode:
print(result)
except Exception as e:
output.emit_error(message=str(e))
output.print_error(str(e))
raise typer.Exit(1)
@@ -0,0 +1,150 @@
"""
Schedule command group for PraisonAI CLI.
Provides scheduler management.
"""
from typing import Optional
import typer
from ..output.console import get_output_controller
app = typer.Typer(help="Scheduler management")
def _run_schedule(args: list) -> int:
"""Run schedule command with args."""
try:
from ..features.agent_scheduler import AgentSchedulerHandler
# Parse subcommand
if args and args[0] in ['start', 'list', 'stop', 'logs', 'restart', 'delete', 'describe', 'save', 'stop-all', 'stats']:
subcommand = args[0]
remaining = args[1:] if len(args) > 1 else []
# Create minimal args namespace
class Args:
pass
fake_args = Args()
return AgentSchedulerHandler.handle_daemon_command(subcommand, fake_args, remaining)
return 0
except ImportError as e:
output = get_output_controller()
output.print_error(f"Scheduler module not available: {e}")
return 4
@app.command("start")
def schedule_start(
agents_file: str = typer.Argument("agents.yaml", help="Agents YAML file"),
interval: Optional[str] = typer.Option(None, "--interval", "-i", help="Schedule interval (e.g., 'hourly', '*/30m')"),
daemon: bool = typer.Option(True, "--daemon/--no-daemon", help="Run as daemon"),
name: Optional[str] = typer.Option(None, "--name", "-n", help="Job name"),
):
"""Start scheduled agent execution."""
args = ["start", agents_file]
if interval:
args.extend(["--interval", interval])
if not daemon:
args.append("--no-daemon")
if name:
args.extend(["--name", name])
raise typer.Exit(_run_schedule(args))
@app.command("stop")
def schedule_stop(
job_id: Optional[str] = typer.Argument(None, help="Job ID to stop (or 'all')"),
):
"""Stop scheduled job(s)."""
if job_id == "all":
raise typer.Exit(_run_schedule(["stop-all"]))
elif job_id:
raise typer.Exit(_run_schedule(["stop", job_id]))
else:
raise typer.Exit(_run_schedule(["stop"]))
@app.command("list")
def schedule_list(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List scheduled jobs."""
args = ["list"]
if json_output:
args.append("--json")
raise typer.Exit(_run_schedule(args))
@app.command("logs")
def schedule_logs(
job_id: Optional[str] = typer.Argument(None, help="Job ID"),
tail: int = typer.Option(50, "--tail", "-n", help="Number of lines"),
follow: bool = typer.Option(False, "--follow", "-f", help="Follow log output"),
):
"""View scheduler logs."""
args = ["logs"]
if job_id:
args.append(job_id)
args.extend(["--tail", str(tail)])
if follow:
args.append("--follow")
raise typer.Exit(_run_schedule(args))
@app.command("restart")
def schedule_restart(
job_id: str = typer.Argument(..., help="Job ID to restart"),
):
"""Restart a scheduled job."""
raise typer.Exit(_run_schedule(["restart", job_id]))
@app.command("delete")
def schedule_delete(
job_id: str = typer.Argument(..., help="Job ID to delete"),
confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
):
"""Delete a scheduled job."""
if not confirm:
confirmed = typer.confirm(f"Delete job {job_id}?")
if not confirmed:
output = get_output_controller()
output.print_info("Cancelled")
raise typer.Exit(0)
raise typer.Exit(_run_schedule(["delete", job_id]))
@app.command("describe")
def schedule_describe(
job_id: str = typer.Argument(..., help="Job ID"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Show job details."""
args = ["describe", job_id]
if json_output:
args.append("--json")
raise typer.Exit(_run_schedule(args))
@app.command("stats")
def schedule_stats(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Show scheduler statistics."""
args = ["stats"]
if json_output:
args.append("--json")
raise typer.Exit(_run_schedule(args))
@app.callback(invoke_without_command=True)
def schedule_callback(ctx: typer.Context):
"""Show schedule help or list jobs."""
if ctx.invoked_subcommand is None:
schedule_list(json_output=False)
@@ -0,0 +1,97 @@
"""
Serve command group for PraisonAI CLI.
Provides API server management.
"""
from typing import Optional
import typer
from ..output.console import get_output_controller
app = typer.Typer(help="API server management")
@app.command("start")
def serve_start(
host: str = typer.Option("0.0.0.0", "--host", "-h", help="Host to bind to"),
port: int = typer.Option(8000, "--port", "-p", help="Port to bind to"),
agents_file: Optional[str] = typer.Option(None, "--agents", "-a", help="Agents YAML file"),
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"),
workers: int = typer.Option(1, "--workers", "-w", help="Number of workers"),
):
"""Start API server."""
output = get_output_controller()
# Build args for existing handler
args = [
"start",
"--host", host,
"--port", str(port),
"--workers", str(workers),
]
if agents_file:
args.extend(["--agents", agents_file])
if reload:
args.append("--reload")
try:
from ..features.serve import handle_serve_command
exit_code = handle_serve_command(args)
raise typer.Exit(exit_code)
except ImportError as e:
output.print_error(f"Serve module not available: {e}")
raise typer.Exit(4)
@app.command("stop")
def serve_stop():
"""Stop API server."""
output = get_output_controller()
try:
from ..features.serve import handle_serve_command
exit_code = handle_serve_command(["stop"])
raise typer.Exit(exit_code)
except ImportError as e:
output.print_error(f"Serve module not available: {e}")
raise typer.Exit(4)
@app.command("status")
def serve_status(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Show server status."""
output = get_output_controller()
args = ["status"]
if json_output:
args.append("--json")
try:
from ..features.serve import handle_serve_command
exit_code = handle_serve_command(args)
raise typer.Exit(exit_code)
except ImportError as e:
output.print_error(f"Serve module not available: {e}")
raise typer.Exit(4)
@app.callback(invoke_without_command=True)
def serve_callback(ctx: typer.Context):
"""Show serve help or start server."""
if ctx.invoked_subcommand is None:
output = get_output_controller()
output.print_panel(
"API server management.\n\n"
"Commands:\n"
" start Start API server\n"
" stop Stop API server\n"
" status Show server status\n\n"
"Quick start:\n"
" praisonai serve start --agents agents.yaml",
title="Serve Commands"
)
@@ -0,0 +1,212 @@
"""
Session command group for PraisonAI CLI.
Provides session management:
- session list: List all sessions
- session resume: Resume a session
- session delete: Delete a session
- session export: Export a session
"""
from typing import Optional
import typer
from ..output.console import get_output_controller
from ..state.sessions import get_session_manager
app = typer.Typer(help="Session management")
@app.command("list")
def session_list(
limit: int = typer.Option(
20,
"--limit",
"-n",
help="Maximum number of sessions to show",
),
):
"""List all sessions."""
output = get_output_controller()
manager = get_session_manager()
sessions = manager.list(limit=limit)
if output.is_json_mode:
output.print_json({
"sessions": [s.to_dict() for s in sessions]
})
return
if not sessions:
output.print_info("No sessions found")
return
headers = ["ID", "Name", "Status", "Events", "Updated"]
rows = []
for s in sessions:
rows.append([
s.session_id[:20] + "..." if len(s.session_id) > 20 else s.session_id,
s.name or "-",
s.status,
str(s.event_count),
s.updated_at.strftime("%Y-%m-%d %H:%M"),
])
output.print_table(headers, rows, title="Sessions")
@app.command("resume")
def session_resume(
session_id: str = typer.Argument(..., help="Session ID to resume"),
):
"""Resume a session."""
output = get_output_controller()
manager = get_session_manager()
session = manager.get(session_id)
if not session:
output.print_error(
f"Session not found: {session_id}",
remediation="Use 'praisonai session list' to see available sessions"
)
raise typer.Exit(1)
# Load session events
events = manager.get_events(session_id)
if output.is_json_mode:
output.print_json({
"session": session.to_dict(),
"events": events,
"message": "Session loaded. True resume not supported - showing transcript.",
})
return
output.print_panel(
f"Session: {session.name or session.session_id}\n"
f"Run ID: {session.run_id}\n"
f"Trace ID: {session.trace_id}\n"
f"Events: {session.event_count}\n"
f"Status: {session.status}",
title="Session Loaded"
)
output.print_warning(
"True session resume is not yet supported. "
"Showing session transcript instead."
)
# Show recent events
if events:
output.print("\n--- Recent Events ---\n")
for event in events[-10:]:
event_type = event.get("event", "unknown")
message = event.get("message", "")
output.print(f"[{event_type}] {message}")
@app.command("delete")
def session_delete(
session_id: str = typer.Argument(..., help="Session ID to delete"),
confirm: bool = typer.Option(
False,
"--yes",
"-y",
help="Skip confirmation",
),
):
"""Delete a session."""
output = get_output_controller()
manager = get_session_manager()
session = manager.get(session_id)
if not session:
output.print_error(f"Session not found: {session_id}")
raise typer.Exit(1)
if not confirm:
confirmed = typer.confirm(f"Delete session {session_id}?")
if not confirmed:
output.print_info("Cancelled")
raise typer.Exit(0)
deleted = manager.delete(session_id)
if output.is_json_mode:
output.print_json({"deleted": deleted, "session_id": session_id})
else:
if deleted:
output.print_success(f"Deleted session: {session_id}")
else:
output.print_error(f"Failed to delete session: {session_id}")
raise typer.Exit(1)
@app.command("export")
def session_export(
session_id: str = typer.Argument(..., help="Session ID to export"),
format: str = typer.Option(
"md",
"--format",
"-f",
help="Export format: md or json",
),
output_file: Optional[str] = typer.Option(
None,
"--output",
"-o",
help="Output file path",
),
):
"""Export a session."""
output = get_output_controller()
manager = get_session_manager()
content = manager.export(session_id, format=format)
if content is None:
output.print_error(f"Session not found: {session_id}")
raise typer.Exit(1)
if output_file:
with open(output_file, "w", encoding="utf-8") as f:
f.write(content)
output.print_success(f"Exported to: {output_file}")
else:
print(content)
@app.command("show")
def session_show(
session_id: str = typer.Argument(..., help="Session ID to show"),
):
"""Show session details."""
output = get_output_controller()
manager = get_session_manager()
session = manager.get(session_id)
if not session:
output.print_error(f"Session not found: {session_id}")
raise typer.Exit(1)
if output.is_json_mode:
output.print_json(session.to_dict())
return
output.print_panel(
f"Session ID: {session.session_id}\n"
f"Name: {session.name or '-'}\n"
f"Run ID: {session.run_id}\n"
f"Trace ID: {session.trace_id}\n"
f"Created: {session.created_at.isoformat()}\n"
f"Updated: {session.updated_at.isoformat()}\n"
f"Status: {session.status}\n"
f"Events: {session.event_count}\n"
f"Workspace: {session.workspace or '-'}",
title="Session Details"
)
@@ -0,0 +1,99 @@
"""
Skills command group for PraisonAI CLI.
Provides skill management commands.
"""
import typer
app = typer.Typer(help="Skill management")
@app.command("list")
def skills_list(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed info"),
):
"""List available skills."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['skills', 'list']
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("validate")
def skills_validate(
path: str = typer.Argument(..., help="Skill directory path"),
):
"""Validate a skill."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['skills', 'validate', path]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("create")
def skills_create(
name: str = typer.Argument(..., help="Skill name"),
):
"""Create a new skill."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['skills', 'create', name]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("install")
def skills_install(
source: str = typer.Argument(..., help="Skill source (path or URL)"),
):
"""Install a skill."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['skills', 'install', source]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,54 @@
"""
Templates command group for PraisonAI CLI.
Provides template management commands.
"""
import typer
app = typer.Typer(help="Template management")
@app.command("list")
def templates_list():
"""List available templates."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['templates', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("create")
def templates_create(
name: str = typer.Argument(..., help="Template name"),
source: str = typer.Option(None, "--source", "-s", help="Source file to create template from"),
):
"""Create a new template."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['templates', 'create', name]
if source:
argv.extend(['--source', source])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,74 @@
"""
Todo command group for PraisonAI CLI.
Provides todo/task management commands.
"""
import typer
app = typer.Typer(help="Todo/task management")
@app.command("list")
def todo_list():
"""List todos."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['todo', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("add")
def todo_add(
task: str = typer.Argument(..., help="Task description"),
priority: str = typer.Option("medium", "--priority", "-p", help="Priority (low, medium, high)"),
):
"""Add a todo."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['todo', 'add', task]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("done")
def todo_done(
task_id: str = typer.Argument(..., help="Task ID to mark as done"),
):
"""Mark a todo as done."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['todo', 'done', task_id]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,77 @@
"""
Tools command group for PraisonAI CLI.
Provides tool management commands.
"""
import typer
app = typer.Typer(help="Tool management")
@app.command("list")
def tools_list(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed info"),
):
"""List available tools."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['tools', 'list']
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("info")
def tools_info(
name: str = typer.Argument(..., help="Tool name"),
):
"""Show tool information."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['tools', 'info', name]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("test")
def tools_test(
name: str = typer.Argument(..., help="Tool name to test"),
):
"""Test a tool."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['tools', 'test', name]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,145 @@
"""
Traces command group for PraisonAI CLI.
Provides trace collection management:
- traces enable: Enable trace collection
- traces disable: Disable trace collection
- traces status: Show current status
- traces list: List recent traces
"""
from typing import Optional
import typer
from ..configuration.loader import get_config_loader
from ..output.console import get_output_controller
app = typer.Typer(help="Trace collection management")
@app.command("enable")
def traces_enable(
endpoint: Optional[str] = typer.Option(
None,
"--endpoint",
"-e",
help="Trace endpoint URL",
),
sample_rate: float = typer.Option(
1.0,
"--sample-rate",
"-r",
help="Sample rate (0.0 to 1.0)",
),
):
"""Enable trace collection."""
output = get_output_controller()
loader = get_config_loader()
loader.set("traces.enabled", True)
if endpoint:
loader.set("traces.endpoint", endpoint)
if sample_rate != 1.0:
loader.set("traces.sample_rate", sample_rate)
if output.is_json_mode:
output.print_json({
"traces_enabled": True,
"endpoint": endpoint,
"sample_rate": sample_rate,
})
else:
output.print_success("Trace collection enabled")
if endpoint:
output.print(f" Endpoint: {endpoint}")
output.print(f" Sample rate: {sample_rate}")
@app.command("disable")
def traces_disable():
"""Disable trace collection."""
output = get_output_controller()
loader = get_config_loader()
loader.set("traces.enabled", False)
if output.is_json_mode:
output.print_json({"traces_enabled": False})
else:
output.print_success("Trace collection disabled")
@app.command("status")
def traces_status():
"""Show trace collection status."""
output = get_output_controller()
loader = get_config_loader()
enabled = loader.get("traces.enabled", False)
endpoint = loader.get("traces.endpoint")
sample_rate = loader.get("traces.sample_rate", 1.0)
if output.is_json_mode:
output.print_json({
"enabled": enabled,
"endpoint": endpoint,
"sample_rate": sample_rate,
})
else:
status = "✅ Enabled" if enabled else "❌ Disabled"
output.print_panel(
f"Status: {status}\n"
f"Endpoint: {endpoint or 'Not configured'}\n"
f"Sample Rate: {sample_rate}",
title="Trace Collection Status"
)
@app.command("list")
def traces_list(
limit: int = typer.Option(
20,
"--limit",
"-n",
help="Maximum number of traces to show",
),
):
"""List recent traces."""
output = get_output_controller()
from ..configuration.paths import get_traces_dir
traces_dir = get_traces_dir()
if not traces_dir.exists():
if output.is_json_mode:
output.print_json({"traces": []})
else:
output.print_info("No traces found")
return
traces = []
for trace_file in sorted(traces_dir.iterdir(), reverse=True)[:limit]:
if trace_file.is_file() and trace_file.suffix == ".json":
traces.append({
"name": trace_file.stem,
"size": trace_file.stat().st_size,
"modified": trace_file.stat().st_mtime,
})
if output.is_json_mode:
output.print_json({"traces": traces})
else:
if not traces:
output.print_info("No traces found")
return
headers = ["Name", "Size", "Modified"]
rows = []
for t in traces:
from datetime import datetime
modified = datetime.fromtimestamp(t["modified"]).strftime("%Y-%m-%d %H:%M")
rows.append([t["name"], f"{t['size']} bytes", modified])
output.print_table(headers, rows, title="Recent Traces")
@@ -0,0 +1,48 @@
"""
Train command group for PraisonAI CLI.
Provides model training commands.
"""
from typing import Optional
import typer
app = typer.Typer(help="Model training and fine-tuning")
@app.callback(invoke_without_command=True)
def train_main(
ctx: typer.Context,
dataset: Optional[str] = typer.Argument(None, help="Training dataset path"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="Base model to fine-tune"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""
Train or fine-tune models.
Examples:
praisonai train dataset.json
praisonai train --model gpt-4o dataset.json
"""
from praisonai.cli.main import PraisonAI
import sys
argv = ['train']
if dataset:
argv.append(dataset)
if model:
argv.extend(['--model', model])
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,47 @@
"""
UI command group for PraisonAI CLI.
Provides web UI commands.
"""
from typing import Optional
import typer
app = typer.Typer(help="Web UI management")
@app.callback(invoke_without_command=True)
def ui_main(
ctx: typer.Context,
port: int = typer.Option(8080, "--port", "-p", help="Port to run UI on"),
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to"),
public: bool = typer.Option(False, "--public", help="Make UI publicly accessible"),
):
"""
Start the web UI.
Examples:
praisonai ui
praisonai ui --port 3000
praisonai ui --public
"""
from praisonai.cli.main import PraisonAI
import sys
argv = ['ui']
argv.extend(['--port', str(port)])
argv.extend(['--host', host])
if public:
argv.append('--public')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,101 @@
"""
Version command group for PraisonAI CLI.
Provides version information:
- version show: Show version
- version check: Check for updates
"""
import typer
from ..output.console import get_output_controller
app = typer.Typer(help="Version information")
@app.command("show")
def version_show():
"""Show version information."""
output = get_output_controller()
from praisonai.version import __version__
# Try to get additional version info
versions = {
"praisonai": __version__,
}
# Check praisonaiagents version
try:
import praisonaiagents
versions["praisonaiagents"] = getattr(praisonaiagents, "__version__", "unknown")
except ImportError:
versions["praisonaiagents"] = "not installed"
# Check Python version
import sys
versions["python"] = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
if output.is_json_mode:
output.print_json(versions)
return
output.print_panel(
f"PraisonAI: {versions['praisonai']}\n"
f"PraisonAI Agents: {versions['praisonaiagents']}\n"
f"Python: {versions['python']}",
title="Version Information"
)
@app.command("check")
def version_check():
"""Check for updates."""
output = get_output_controller()
from praisonai.version import __version__
output.print_info("Checking for updates...")
try:
import urllib.request
import json
url = "https://pypi.org/pypi/praisonai/json"
with urllib.request.urlopen(url, timeout=5) as response:
data = json.loads(response.read().decode())
latest = data["info"]["version"]
if output.is_json_mode:
output.print_json({
"current": __version__,
"latest": latest,
"update_available": latest != __version__,
})
return
if latest != __version__:
output.print_warning(
f"Update available: {__version__} → {latest}\n"
f"Run: pip install --upgrade praisonai"
)
else:
output.print_success(f"You are using the latest version ({__version__})")
except Exception as e:
if output.is_json_mode:
output.print_json({
"current": __version__,
"latest": None,
"error": str(e),
})
else:
output.print_error(f"Failed to check for updates: {e}")
output.print(f"Current version: {__version__}")
@app.callback(invoke_without_command=True)
def version_callback(ctx: typer.Context):
"""Show version (default behavior)."""
if ctx.invoked_subcommand is None:
version_show()
@@ -0,0 +1,82 @@
"""
Workflow command group for PraisonAI CLI.
Provides workflow management commands.
"""
import typer
app = typer.Typer(help="Workflow management")
@app.command("run")
def workflow_run(
file: str = typer.Argument(..., help="Workflow file path"),
model: str = typer.Option(None, "--model", "-m", help="LLM model to use"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Run a workflow."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['workflow', 'run', file]
if model:
argv.extend(['--model', model])
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("list")
def workflow_list():
"""List available workflows."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['workflow', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("create")
def workflow_create(
name: str = typer.Argument(..., help="Workflow name"),
template: str = typer.Option(None, "--template", "-t", help="Template to use"),
):
"""Create a new workflow."""
from praisonai.cli.main import PraisonAI
import sys
argv = ['workflow', 'create', name]
if template:
argv.extend(['--template', template])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@@ -0,0 +1,18 @@
"""
PraisonAI CLI Configuration Module.
Provides configuration management with TOML support and precedence handling.
"""
from .loader import ConfigLoader, get_config
from .schema import ConfigSchema
from .paths import get_config_paths, get_user_config_path, get_project_config_path
__all__ = [
'ConfigLoader',
'ConfigSchema',
'get_config',
'get_config_paths',
'get_user_config_path',
'get_project_config_path',
]
@@ -0,0 +1,353 @@
"""
Configuration loader for PraisonAI CLI.
Handles loading, merging, and saving configuration with precedence.
"""
import os
import threading
from pathlib import Path
from typing import Any, Dict, Optional
from .paths import (
get_config_paths,
get_user_config_path,
get_project_config_path,
get_env_prefix,
ensure_config_dirs,
)
from .schema import ConfigSchema, DEFAULT_CONFIG
# Thread-safe config cache
_config_lock = threading.Lock()
_config_cache: Optional[ConfigSchema] = None
def _load_toml(path: Path) -> Dict[str, Any]:
"""Load a TOML file."""
try:
# Try tomllib (Python 3.11+) first
import tomllib
with open(path, "rb") as f:
return tomllib.load(f)
except ImportError:
pass
try:
# Fall back to tomli
import tomli
with open(path, "rb") as f:
return tomli.load(f)
except ImportError:
pass
# Last resort: basic TOML parsing
return _basic_toml_parse(path)
def _basic_toml_parse(path: Path) -> Dict[str, Any]:
"""Basic TOML parser for simple configs."""
result: Dict[str, Any] = {}
current_section: Optional[str] = None
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
current_section = line[1:-1]
if current_section not in result:
result[current_section] = {}
elif "=" in line:
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
# Parse value
if value.lower() == "true":
parsed_value = True
elif value.lower() == "false":
parsed_value = False
elif value.startswith('"') and value.endswith('"'):
parsed_value = value[1:-1]
elif value.startswith("'") and value.endswith("'"):
parsed_value = value[1:-1]
else:
try:
parsed_value = int(value)
except ValueError:
try:
parsed_value = float(value)
except ValueError:
parsed_value = value
if current_section:
result[current_section][key] = parsed_value
else:
result[key] = parsed_value
return result
def _save_toml(path: Path, data: Dict[str, Any]) -> None:
"""Save data to a TOML file."""
try:
import tomli_w
with open(path, "wb") as f:
tomli_w.dump(data, f)
return
except ImportError:
pass
# Fall back to basic TOML writing
_basic_toml_write(path, data)
def _basic_toml_write(path: Path, data: Dict[str, Any]) -> None:
"""Basic TOML writer."""
lines = []
# Write top-level keys first
for key, value in data.items():
if not isinstance(value, dict):
lines.append(f"{key} = {_format_toml_value(value)}")
# Write sections
for key, value in data.items():
if isinstance(value, dict):
lines.append(f"\n[{key}]")
for sub_key, sub_value in value.items():
if not isinstance(sub_value, dict):
lines.append(f"{sub_key} = {_format_toml_value(sub_value)}")
else:
# Nested section
lines.append(f"\n[{key}.{sub_key}]")
for nested_key, nested_value in sub_value.items():
lines.append(f"{nested_key} = {_format_toml_value(nested_value)}")
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
def _format_toml_value(value: Any) -> str:
"""Format a value for TOML."""
if isinstance(value, bool):
return "true" if value else "false"
elif isinstance(value, str):
return f'"{value}"'
elif isinstance(value, (int, float)):
return str(value)
elif isinstance(value, list):
items = ", ".join(_format_toml_value(v) for v in value)
return f"[{items}]"
else:
return f'"{value}"'
def _merge_dicts(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
"""Deep merge two dictionaries."""
result = base.copy()
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _merge_dicts(result[key], value)
else:
result[key] = value
return result
def _get_env_overrides() -> Dict[str, Any]:
"""Get configuration overrides from environment variables."""
prefix = get_env_prefix()
overrides: Dict[str, Any] = {}
for key, value in os.environ.items():
if not key.startswith(prefix):
continue
# Convert PRAISONAI_OUTPUT_FORMAT to output.format
config_key = key[len(prefix):].lower()
parts = config_key.split("_")
# Parse value
if value.lower() == "true":
parsed_value = True
elif value.lower() == "false":
parsed_value = False
else:
try:
parsed_value = int(value)
except ValueError:
try:
parsed_value = float(value)
except ValueError:
parsed_value = value
# Build nested dict
if len(parts) >= 2:
section = parts[0]
sub_key = "_".join(parts[1:])
if section not in overrides:
overrides[section] = {}
overrides[section][sub_key] = parsed_value
else:
overrides[config_key] = parsed_value
return overrides
def _get_dotted_value(data: Dict[str, Any], key: str) -> Any:
"""Get a value using dotted key notation."""
parts = key.split(".")
current = data
for part in parts:
if isinstance(current, dict) and part in current:
current = current[part]
else:
return None
return current
def _set_dotted_value(data: Dict[str, Any], key: str, value: Any) -> None:
"""Set a value using dotted key notation."""
parts = key.split(".")
current = data
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
class ConfigLoader:
"""
Configuration loader with precedence handling.
Precedence (highest to lowest):
1. CLI flags (not handled here)
2. Environment variables (PRAISONAI_*)
3. Project config (.praison/config.toml)
4. User config (~/.praison/config.toml)
5. Defaults
"""
def __init__(self, project_root: Optional[Path] = None):
self.project_root = project_root or Path.cwd()
self._config: Optional[ConfigSchema] = None
self._raw_config: Dict[str, Any] = {}
def load(self, force_reload: bool = False) -> ConfigSchema:
"""Load configuration with precedence."""
if self._config is not None and not force_reload:
return self._config
# Start with defaults
merged = DEFAULT_CONFIG.to_dict()
# Load config files in reverse precedence order
config_paths = get_config_paths(self.project_root)
for path in reversed(config_paths):
try:
file_config = _load_toml(path)
merged = _merge_dicts(merged, file_config)
except Exception:
pass # Skip invalid files
# Apply environment overrides
env_overrides = _get_env_overrides()
merged = _merge_dicts(merged, env_overrides)
self._raw_config = merged
self._config = ConfigSchema.from_dict(merged)
return self._config
def get(self, key: str, default: Any = None) -> Any:
"""Get a configuration value using dotted key notation."""
self.load()
value = _get_dotted_value(self._raw_config, key)
return value if value is not None else default
def set(self, key: str, value: Any, scope: str = "user") -> None:
"""
Set a configuration value.
Args:
key: Dotted key notation (e.g., "output.format")
value: Value to set
scope: "user" or "project"
"""
if scope == "project":
config_path = get_project_config_path(self.project_root)
else:
config_path = get_user_config_path()
# Load existing config or create empty
if config_path.exists():
try:
existing = _load_toml(config_path)
except Exception:
existing = {}
else:
existing = {}
# Set the value
_set_dotted_value(existing, key, value)
# Ensure directory exists
ensure_config_dirs()
if scope == "project":
config_path.parent.mkdir(parents=True, exist_ok=True)
# Save
_save_toml(config_path, existing)
# Reload
self.load(force_reload=True)
def reset(self, scope: str = "user") -> None:
"""Reset configuration to defaults."""
if scope == "project":
config_path = get_project_config_path(self.project_root)
else:
config_path = get_user_config_path()
if config_path.exists():
config_path.unlink()
self.load(force_reload=True)
def list_all(self) -> Dict[str, Any]:
"""List all configuration values."""
self.load()
return self._raw_config.copy()
# Global config loader
_global_loader: Optional[ConfigLoader] = None
def get_config(project_root: Optional[Path] = None) -> ConfigSchema:
"""Get the global configuration."""
global _global_loader, _config_cache
with _config_lock:
if _global_loader is None or project_root is not None:
_global_loader = ConfigLoader(project_root)
if _config_cache is None:
_config_cache = _global_loader.load()
return _config_cache
def get_config_loader(project_root: Optional[Path] = None) -> ConfigLoader:
"""Get the global config loader."""
global _global_loader
with _config_lock:
if _global_loader is None or project_root is not None:
_global_loader = ConfigLoader(project_root)
return _global_loader
@@ -0,0 +1,114 @@
"""
Configuration paths for PraisonAI CLI.
Defines standard locations for configuration files.
"""
import os
from pathlib import Path
from typing import List, Optional
def get_user_config_dir() -> Path:
"""Get user configuration directory (~/.praison/)."""
return Path.home() / ".praison"
def get_user_config_path() -> Path:
"""Get user configuration file path (~/.praison/config.toml)."""
return get_user_config_dir() / "config.toml"
def get_project_config_dir(project_root: Optional[Path] = None) -> Path:
"""Get project configuration directory (.praison/)."""
root = project_root or Path.cwd()
return root / ".praison"
def get_project_config_path(project_root: Optional[Path] = None) -> Path:
"""Get project configuration file path (.praison/config.toml)."""
return get_project_config_dir(project_root) / "config.toml"
def get_sessions_dir() -> Path:
"""Get sessions directory (~/.praison/sessions/)."""
return get_user_config_dir() / "sessions"
def get_traces_dir() -> Path:
"""Get traces directory (~/.praison/traces/)."""
return get_user_config_dir() / "traces"
def get_logs_dir() -> Path:
"""Get logs directory (~/.praison/logs/)."""
return get_user_config_dir() / "logs"
def get_cache_dir() -> Path:
"""Get cache directory (~/.praison/cache/)."""
return get_user_config_dir() / "cache"
def get_config_paths(project_root: Optional[Path] = None) -> List[Path]:
"""
Get all configuration file paths in precedence order (highest first).
Precedence:
1. Project config: .praison/config.toml
2. User config: ~/.praison/config.toml
Returns:
List of paths in precedence order
"""
paths = []
# Project config (highest precedence)
project_config = get_project_config_path(project_root)
if project_config.exists():
paths.append(project_config)
# User config
user_config = get_user_config_path()
if user_config.exists():
paths.append(user_config)
return paths
def ensure_config_dirs() -> None:
"""Ensure all configuration directories exist."""
dirs = [
get_user_config_dir(),
get_sessions_dir(),
get_traces_dir(),
get_logs_dir(),
get_cache_dir(),
]
for d in dirs:
d.mkdir(parents=True, exist_ok=True)
def get_env_prefix() -> str:
"""Get environment variable prefix."""
return "PRAISONAI_"
def env_to_config_key(env_var: str) -> Optional[str]:
"""
Convert environment variable name to config key.
Example: PRAISONAI_OUTPUT_FORMAT -> output.format
"""
prefix = get_env_prefix()
if not env_var.startswith(prefix):
return None
key = env_var[len(prefix):].lower()
# Convert underscores to dots for nested keys
# Single underscore = dot, double underscore = single underscore
parts = key.split("__")
result_parts = []
for part in parts:
result_parts.append(part.replace("_", "."))
return "_".join(result_parts)
@@ -0,0 +1,164 @@
"""
Configuration schema for PraisonAI CLI.
Defines the structure and defaults for configuration.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@dataclass
class OutputConfig:
"""Output configuration."""
format: str = "text" # text, json, stream-json
color: bool = True
verbose: bool = False
quiet: bool = False
screen_reader: bool = False
@dataclass
class TracesConfig:
"""Traces configuration."""
enabled: bool = False
endpoint: Optional[str] = None
sample_rate: float = 1.0
@dataclass
class MCPServerConfig:
"""MCP server configuration."""
command: str = ""
args: List[str] = field(default_factory=list)
env: Dict[str, str] = field(default_factory=dict)
enabled: bool = True
@dataclass
class MCPConfig:
"""MCP configuration."""
servers: Dict[str, MCPServerConfig] = field(default_factory=dict)
@dataclass
class ModelConfig:
"""Model configuration."""
default: str = "gpt-4o-mini"
temperature: float = 0.7
max_tokens: int = 16000
@dataclass
class SessionConfig:
"""Session configuration."""
auto_save: bool = False
history_limit: int = 10
@dataclass
class ConfigSchema:
"""
Complete configuration schema.
Represents all configurable options for PraisonAI CLI.
"""
output: OutputConfig = field(default_factory=OutputConfig)
traces: TracesConfig = field(default_factory=TracesConfig)
mcp: MCPConfig = field(default_factory=MCPConfig)
model: ModelConfig = field(default_factory=ModelConfig)
session: SessionConfig = field(default_factory=SessionConfig)
# Additional settings stored as dict for flexibility
extra: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"output": {
"format": self.output.format,
"color": self.output.color,
"verbose": self.output.verbose,
"quiet": self.output.quiet,
"screen_reader": self.output.screen_reader,
},
"traces": {
"enabled": self.traces.enabled,
"endpoint": self.traces.endpoint,
"sample_rate": self.traces.sample_rate,
},
"mcp": {
"servers": {
name: {
"command": server.command,
"args": server.args,
"env": server.env,
"enabled": server.enabled,
}
for name, server in self.mcp.servers.items()
}
},
"model": {
"default": self.model.default,
"temperature": self.model.temperature,
"max_tokens": self.model.max_tokens,
},
"session": {
"auto_save": self.session.auto_save,
"history_limit": self.session.history_limit,
},
**self.extra,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ConfigSchema":
"""Create from dictionary."""
output_data = data.get("output", {})
traces_data = data.get("traces", {})
mcp_data = data.get("mcp", {})
model_data = data.get("model", {})
session_data = data.get("session", {})
# Extract known keys
known_keys = {"output", "traces", "mcp", "model", "session"}
extra = {k: v for k, v in data.items() if k not in known_keys}
# Parse MCP servers
mcp_servers = {}
for name, server_data in mcp_data.get("servers", {}).items():
mcp_servers[name] = MCPServerConfig(
command=server_data.get("command", ""),
args=server_data.get("args", []),
env=server_data.get("env", {}),
enabled=server_data.get("enabled", True),
)
return cls(
output=OutputConfig(
format=output_data.get("format", "text"),
color=output_data.get("color", True),
verbose=output_data.get("verbose", False),
quiet=output_data.get("quiet", False),
screen_reader=output_data.get("screen_reader", False),
),
traces=TracesConfig(
enabled=traces_data.get("enabled", False),
endpoint=traces_data.get("endpoint"),
sample_rate=traces_data.get("sample_rate", 1.0),
),
mcp=MCPConfig(servers=mcp_servers),
model=ModelConfig(
default=model_data.get("default", "gpt-4o-mini"),
temperature=model_data.get("temperature", 0.7),
max_tokens=model_data.get("max_tokens", 16000),
),
session=SessionConfig(
auto_save=session_data.get("auto_save", False),
history_limit=session_data.get("history_limit", 10),
),
extra=extra,
)
# Default configuration
DEFAULT_CONFIG = ConfigSchema()
@@ -0,0 +1,99 @@
"""
PraisonAI Unified Execution Module.
Provides a single, unified execution primitive for all CLI modes:
- CLI direct prompt
- Profile command
- TUI workers
Design Principles:
1. Profiling observes, never alters execution
2. Zero overhead when profiling disabled
3. Sync and async are first-class citizens
4. Fail-safe, not fail-fast
5. Bounded resource usage
6. Schema stability with versioning
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .request import ExecutionRequest
from .result import ExecutionResult
from .core import execute_sync, execute_async
from .profiler import Profiler, ProfilerConfig, ProfileReport
# Lazy loading to maintain zero-overhead when not used
_lazy_cache = {}
def __getattr__(name: str):
"""Lazy load execution components."""
global _lazy_cache
if name in _lazy_cache:
return _lazy_cache[name]
if name == "ExecutionRequest":
from .request import ExecutionRequest
_lazy_cache[name] = ExecutionRequest
return ExecutionRequest
elif name == "ExecutionResult":
from .result import ExecutionResult
_lazy_cache[name] = ExecutionResult
return ExecutionResult
elif name == "execute_sync":
from .core import execute_sync
_lazy_cache[name] = execute_sync
return execute_sync
elif name == "execute_async":
from .core import execute_async
_lazy_cache[name] = execute_async
return execute_async
elif name == "Profiler":
from .profiler import Profiler
_lazy_cache[name] = Profiler
return Profiler
elif name == "ProfilerConfig":
from .profiler import ProfilerConfig
_lazy_cache[name] = ProfilerConfig
return ProfilerConfig
elif name == "ProfileReport":
from .profiler import ProfileReport
_lazy_cache[name] = ProfileReport
return ProfileReport
elif name == "TimingBreakdown":
from .profiler import TimingBreakdown
_lazy_cache[name] = TimingBreakdown
return TimingBreakdown
elif name == "InvocationInfo":
from .profiler import InvocationInfo
_lazy_cache[name] = InvocationInfo
return InvocationInfo
elif name == "DecisionTrace":
from .profiler import DecisionTrace
_lazy_cache[name] = DecisionTrace
return DecisionTrace
elif name == "ModuleBreakdown":
from .profiler import ModuleBreakdown
_lazy_cache[name] = ModuleBreakdown
return ModuleBreakdown
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
# Core execution
"ExecutionRequest",
"ExecutionResult",
"execute_sync",
"execute_async",
# Profiling
"Profiler",
"ProfilerConfig",
"ProfileReport",
"TimingBreakdown",
"InvocationInfo",
"DecisionTrace",
"ModuleBreakdown",
]
@@ -0,0 +1,210 @@
"""
Core Execution Module.
Provides the unified execution primitives:
- _execute_core(): Single implementation shared by all paths
- execute_sync(): Synchronous wrapper for CLI
- execute_async(): Asynchronous wrapper for TUI workers
"""
import asyncio
import time
import uuid
from typing import Optional, AsyncGenerator
from .request import ExecutionRequest
from .result import ExecutionResult
def _generate_run_id() -> str:
"""Generate a unique run ID."""
return str(uuid.uuid4())[:8]
def _execute_core(
request: ExecutionRequest,
timing: Optional[dict] = None,
) -> ExecutionResult:
"""
THE SINGLE EXECUTION IMPLEMENTATION.
All execution modes call this function.
Profiler wraps this function, never injects into it.
Args:
request: The execution request
timing: Optional dict to record timing phases (for profiling)
Returns:
ExecutionResult with output and metadata
"""
run_id = _generate_run_id()
start_time = time.perf_counter()
if timing is not None:
timing["core_start"] = start_time
try:
# Phase 1: Import (measured inline)
import_start = time.perf_counter()
from praisonaiagents import Agent
import_end = time.perf_counter()
if timing is not None:
timing["imports_ms"] = (import_end - import_start) * 1000
# Phase 2: Agent Construction
agent_start = time.perf_counter()
agent_config = {
"name": request.agent_name,
"verbose": False,
}
if request.agent_instructions:
agent_config["instructions"] = request.agent_instructions
if request.model:
agent_config["llm"] = request.model
if request.tools:
agent_config["tools"] = list(request.tools)
agent = Agent(**agent_config)
agent_end = time.perf_counter()
if timing is not None:
timing["agent_init_ms"] = (agent_end - agent_start) * 1000
# Phase 3: Execution
exec_start = time.perf_counter()
if request.stream:
# For streaming, collect all chunks
chunks = []
if hasattr(agent, '_start_stream'):
for chunk in agent._start_stream(request.prompt):
chunks.append(chunk)
else:
# Fallback to non-streaming
result = agent.start(request.prompt)
chunks = [result] if result else []
output = "".join(chunks)
else:
output = agent.start(request.prompt)
exec_end = time.perf_counter()
if timing is not None:
timing["execution_ms"] = (exec_end - exec_start) * 1000
end_time = time.perf_counter()
if timing is not None:
timing["total_ms"] = (end_time - start_time) * 1000
return ExecutionResult(
output=output or "",
run_id=run_id,
success=True,
start_time=start_time,
end_time=end_time,
metadata={
"model": request.model,
"stream": request.stream,
},
)
except Exception as e:
end_time = time.perf_counter()
if timing is not None:
timing["total_ms"] = (end_time - start_time) * 1000
timing["error"] = str(e)
return ExecutionResult.from_error(
error=str(e),
run_id=run_id,
)
def execute_sync(request: ExecutionRequest) -> ExecutionResult:
"""
Synchronous execution wrapper.
Used by:
- CLI direct prompt
- Profile command
Args:
request: The execution request
Returns:
ExecutionResult with output and metadata
"""
return _execute_core(request)
async def execute_async(request: ExecutionRequest) -> ExecutionResult:
"""
Asynchronous execution wrapper.
Used by:
- TUI workers
- Queue system
Args:
request: The execution request
Returns:
ExecutionResult with output and metadata
"""
# Run the synchronous core in a thread pool to avoid blocking
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _execute_core, request, None)
async def execute_async_stream(
request: ExecutionRequest,
) -> AsyncGenerator[str, None]:
"""
Asynchronous streaming execution.
Yields chunks as they arrive.
Args:
request: The execution request (stream=True recommended)
Yields:
String chunks of the response
"""
try:
from praisonaiagents import Agent
agent_config = {
"name": request.agent_name,
"verbose": False,
}
if request.agent_instructions:
agent_config["instructions"] = request.agent_instructions
if request.model:
agent_config["llm"] = request.model
if request.tools:
agent_config["tools"] = list(request.tools)
agent = Agent(**agent_config)
if hasattr(agent, '_start_stream'):
for chunk in agent._start_stream(request.prompt):
yield chunk
await asyncio.sleep(0) # Yield control
else:
result = agent.start(request.prompt)
if result:
yield result
except Exception as e:
yield f"Error: {e}"
@@ -0,0 +1,898 @@
"""
Profiler Module.
Provides external profiling that wraps execution.
Execution code has ZERO awareness of profiling.
Design Principles:
1. Profiler wraps execution, does not inject into it
2. Layered profiling with explicit overhead
3. Bounded resource usage
4. Versioned, stable output schema
"""
import cProfile
import pstats
import time
import platform
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from .request import ExecutionRequest
from .result import ExecutionResult
from .core import _execute_core
# Schema version - MUST be incremented for breaking changes
SCHEMA_VERSION = "1.0"
# Bounds for profile data
MAX_FUNCTION_STATS = 1000
MAX_CALL_GRAPH_EDGES = 5000
MAX_NETWORK_REQUESTS = 100
MAX_IMPORT_MODULES = 200
@dataclass
class ProfilerConfig:
"""
Profiler configuration with explicit layer control.
Layers:
- 0: Wall-clock phases only (<1ms overhead)
- 1: + cProfile function stats (~5% overhead)
- 2: + Call graph callers/callees (~15% overhead)
Network timing is orthogonal (opt-in via track_network).
"""
# Layer control (0, 1, or 2)
layer: int = 1
# Layer 1+ options
limit: int = 30 # Max functions to show
sort_by: str = "cumulative" # cumulative, time, calls
# Layer 2 options
show_callers: bool = False
show_callees: bool = False
# Network timing (orthogonal to layers)
track_network: bool = False
# Output options
output_format: str = "text" # text, json
save_path: Optional[str] = None
@classmethod
def from_flags(
cls,
profile: bool = False,
deep: bool = False,
network: bool = False,
) -> 'ProfilerConfig':
"""Create config from CLI flags."""
if deep:
return cls(
layer=2,
show_callers=True,
show_callees=True,
track_network=network,
)
elif profile:
return cls(layer=1, track_network=network)
else:
return cls(layer=0, track_network=network)
@dataclass
class TimingBreakdown:
"""
Phase timing breakdown with full timeline visibility.
All fields in milliseconds.
Timeline order: cli_entry -> cli_parse -> routing -> imports -> agent_init ->
network_start -> first_token -> first_output -> execution_end
"""
# MANDATORY fields (always present)
total_ms: float = 0.0
imports_ms: float = 0.0
agent_init_ms: float = 0.0
execution_ms: float = 0.0
# TIMELINE PHASES (new - for full visibility)
cli_entry_ms: float = 0.0 # Time from ENTER to CLI entry point
cli_parse_ms: float = 0.0 # Argparse/Typer routing time
routing_ms: float = 0.0 # Command routing time
network_start_ms: float = 0.0 # When network request started (relative to start)
first_token_ms: float = 0.0 # Time to first token (streaming)
first_output_ms: float = 0.0 # Time to First Response (TTFR) - first visible output
# OPTIONAL fields (may be 0)
tool_time_ms: float = 0.0 # If tools were called
@property
def time_to_first_response_ms(self) -> float:
"""Time to First Response (TTFR) - the key user-perceived latency metric."""
if self.first_output_ms > 0:
return self.first_output_ms
# Fallback: use execution end if first_output not captured
return self.total_ms
def to_dict(self) -> Dict[str, float]:
"""Convert to dictionary."""
return {
"total_ms": self.total_ms,
"imports_ms": self.imports_ms,
"agent_init_ms": self.agent_init_ms,
"execution_ms": self.execution_ms,
"cli_entry_ms": self.cli_entry_ms,
"cli_parse_ms": self.cli_parse_ms,
"routing_ms": self.routing_ms,
"network_start_ms": self.network_start_ms,
"first_token_ms": self.first_token_ms,
"first_output_ms": self.first_output_ms,
"time_to_first_response_ms": self.time_to_first_response_ms,
"tool_time_ms": self.tool_time_ms,
}
def to_timeline(self) -> List[Tuple[str, float, float]]:
"""
Get ordered timeline of phases.
Returns list of (phase_name, start_ms, duration_ms) tuples.
"""
timeline = []
cursor = 0.0
# CLI Entry (if captured)
if self.cli_entry_ms > 0:
timeline.append(("CLI Entry", 0.0, self.cli_entry_ms))
cursor = self.cli_entry_ms
# CLI Parse
if self.cli_parse_ms > 0:
timeline.append(("CLI Parse", cursor, self.cli_parse_ms))
cursor += self.cli_parse_ms
# Routing
if self.routing_ms > 0:
timeline.append(("Routing", cursor, self.routing_ms))
cursor += self.routing_ms
# Imports
if self.imports_ms > 0:
timeline.append(("Imports", cursor, self.imports_ms))
cursor += self.imports_ms
# Agent Init
if self.agent_init_ms > 0:
timeline.append(("Agent Init", cursor, self.agent_init_ms))
cursor += self.agent_init_ms
# Network to First Token (if streaming)
if self.network_start_ms > 0 and self.first_token_ms > 0:
network_duration = self.first_token_ms - self.network_start_ms
if network_duration > 0:
timeline.append(("Network → First Token", self.network_start_ms, network_duration))
# Execution
if self.execution_ms > 0:
timeline.append(("Execution", cursor, self.execution_ms))
return timeline
@dataclass
class FunctionStat:
"""Statistics for a single function."""
name: str
file: str
line: int
calls: int
total_time_ms: float
cumulative_time_ms: float
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"name": self.name,
"file": self.file,
"line": self.line,
"calls": self.calls,
"total_time_ms": self.total_time_ms,
"cumulative_time_ms": self.cumulative_time_ms,
}
@dataclass
class CallGraph:
"""Call graph data (callers and callees)."""
callers: Dict[str, List[str]] = field(default_factory=dict)
callees: Dict[str, List[str]] = field(default_factory=dict)
@property
def edges(self) -> List[Tuple[str, str]]:
"""Get all edges as (caller, callee) tuples."""
edges = []
for callee, callers in self.callers.items():
for caller in callers:
edges.append((caller, callee))
return edges
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"callers": self.callers,
"callees": self.callees,
"edge_count": len(self.edges),
}
@dataclass
class RequestTiming:
"""Single HTTP request timing."""
url: str
method: str
start_time: float
first_byte_time: float
end_time: float
status_code: int
@property
def ttfb_ms(self) -> float:
"""Time to first byte in milliseconds."""
return (self.first_byte_time - self.start_time) * 1000
@property
def total_ms(self) -> float:
"""Total request time in milliseconds."""
return (self.end_time - self.start_time) * 1000
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"url": self.url,
"method": self.method,
"ttfb_ms": self.ttfb_ms,
"total_ms": self.total_ms,
"status_code": self.status_code,
}
@dataclass
class InvocationInfo:
"""How the profile was invoked."""
method: str # "cli_direct", "profile_command", "tui"
flags: Dict[str, Any] = field(default_factory=dict)
praisonai_version: str = ""
python_version: str = ""
def __post_init__(self):
"""Set version info if not provided."""
if not self.python_version:
self.python_version = platform.python_version()
if not self.praisonai_version:
try:
from praisonai.version import __version__
self.praisonai_version = __version__
except ImportError:
self.praisonai_version = "unknown"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"method": self.method,
"flags": self.flags,
"praisonai_version": self.praisonai_version,
"python_version": self.python_version,
}
@dataclass
class DecisionTrace:
"""
Decision trace for deep profile visibility.
Explains what decisions were made during execution.
"""
agent_config: str = "" # Agent configuration chosen
model_selected: str = "" # Model used
streaming_mode: bool = False # Streaming vs non-streaming
profile_layer: int = 0 # Profile layer (0/1/2)
tools_enabled: List[str] = field(default_factory=list)
tools_disabled: List[str] = field(default_factory=list)
fallbacks_used: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"agent_config": self.agent_config,
"model_selected": self.model_selected,
"streaming_mode": self.streaming_mode,
"profile_layer": self.profile_layer,
"tools_enabled": self.tools_enabled,
"tools_disabled": self.tools_disabled,
"fallbacks_used": self.fallbacks_used,
}
def to_text(self) -> str:
"""Format as human-readable text."""
lines = []
lines.append(f" Agent Config: {self.agent_config or 'default'}")
lines.append(f" Model: {self.model_selected or 'default'}")
lines.append(f" Streaming: {'enabled' if self.streaming_mode else 'disabled'}")
lines.append(f" Profile Layer: {self.profile_layer}")
if self.tools_enabled:
lines.append(f" Tools Enabled: {', '.join(self.tools_enabled)}")
if self.tools_disabled:
lines.append(f" Tools Disabled: {', '.join(self.tools_disabled)}")
if self.fallbacks_used:
lines.append(f" Fallbacks: {', '.join(self.fallbacks_used)}")
return "\n".join(lines)
@dataclass
class ModuleBreakdown:
"""
Module/file breakdown for deep profile visibility.
Groups functions by module category.
"""
cli_modules: List[str] = field(default_factory=list)
execution_modules: List[str] = field(default_factory=list)
agent_modules: List[str] = field(default_factory=list)
tool_modules: List[str] = field(default_factory=list)
network_modules: List[str] = field(default_factory=list)
third_party_modules: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, List[str]]:
"""Convert to dictionary."""
return {
"cli": self.cli_modules,
"execution": self.execution_modules,
"agent": self.agent_modules,
"tools": self.tool_modules,
"network": self.network_modules,
"third_party": self.third_party_modules,
}
@dataclass
class ProfileReport:
"""
Canonical profile report.
VERSION 1.0 - Schema is frozen after release.
New fields MUST be optional with defaults.
"""
# Schema version (MANDATORY, NEVER CHANGES MEANING)
schema_version: str = SCHEMA_VERSION
# Identity (MANDATORY)
run_id: str = ""
timestamp: str = "" # ISO 8601
# Invocation (MANDATORY)
invocation: InvocationInfo = field(default_factory=lambda: InvocationInfo(method="unknown"))
# Timing (MANDATORY)
timing: TimingBreakdown = field(default_factory=TimingBreakdown)
# Function stats (OPTIONAL, present if layer >= 1)
functions: Optional[List[FunctionStat]] = None
# Call graph (OPTIONAL, present if layer >= 2)
call_graph: Optional[CallGraph] = None
# Network (OPTIONAL, present if track_network)
network: Optional[List[RequestTiming]] = None
# Response (MANDATORY but truncated)
response_preview: str = "" # Max 500 chars
# Decision trace (OPTIONAL, present if layer >= 2)
decision_trace: Optional[DecisionTrace] = None
# Module breakdown (OPTIONAL, present if layer >= 2)
module_breakdown: Optional[ModuleBreakdown] = None
def __post_init__(self):
"""Set timestamp if not provided."""
if not self.timestamp:
self.timestamp = datetime.utcnow().isoformat() + "Z"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result = {
"schema_version": self.schema_version,
"run_id": self.run_id,
"timestamp": self.timestamp,
"invocation": self.invocation.to_dict(),
"timing": self.timing.to_dict(),
"timeline": self.timing.to_timeline(),
"timeline_diagram": self.to_timeline_diagram(),
"response_preview": self.response_preview,
}
if self.functions is not None:
result["functions"] = [f.to_dict() for f in self.functions]
if self.call_graph is not None:
result["call_graph"] = self.call_graph.to_dict()
if self.network is not None:
result["network"] = [r.to_dict() for r in self.network]
if self.decision_trace is not None:
result["decision_trace"] = self.decision_trace.to_dict()
if self.module_breakdown is not None:
result["module_breakdown"] = self.module_breakdown.to_dict()
return result
def to_json(self) -> str:
"""Convert to JSON string."""
import json
return json.dumps(self.to_dict(), indent=2, default=str)
def to_timeline_diagram(self) -> str:
"""
Generate ASCII timeline diagram showing execution phases.
Format:
ENTER ─────────────────────────────────────────────────────────► RESPONSE
│ imports │ init │ network │
│ XXXms │ XXms │ XXXms │
└─────────┴──────┴───────────────────────┘
TOTAL: XXXXms
"""
lines = []
total = self.timing.total_ms
if total <= 0:
return "No timing data available"
# Calculate phase widths (proportional to time, min 8 chars)
phases = []
if self.timing.imports_ms > 0:
phases.append(("imports", self.timing.imports_ms))
if self.timing.agent_init_ms > 0:
phases.append(("init", self.timing.agent_init_ms))
if self.timing.execution_ms > 0:
phases.append(("network", self.timing.execution_ms))
if not phases:
phases = [("total", total)]
# Scale to fit in ~60 chars
scale = 50.0 / total if total > 0 else 1
# Build the diagram
lines.append("")
lines.append("## Timeline Diagram")
lines.append("")
# Top line with arrow
top_line = "ENTER "
for name, ms in phases:
width = max(8, int(ms * scale))
top_line += "─" * width
top_line += "► RESPONSE"
lines.append(top_line)
# Phase names line
name_line = " "
for name, ms in phases:
width = max(8, int(ms * scale))
name_line += "│" + name.center(width - 1)
name_line += "│"
lines.append(name_line)
# Phase times line
time_line = " "
for name, ms in phases:
width = max(8, int(ms * scale))
time_str = f"{ms:.0f}ms"
time_line += "│" + time_str.center(width - 1)
time_line += "│"
lines.append(time_line)
# Bottom line
bottom_line = " "
for i, (name, ms) in enumerate(phases):
width = max(8, int(ms * scale))
if i == 0:
bottom_line += "└" + "─" * (width - 1)
else:
bottom_line += "┴" + "─" * (width - 1)
bottom_line += "┘"
lines.append(bottom_line)
# Total line
total_line = " " * (len(bottom_line) - 15) + f"TOTAL: {total:.0f}ms"
lines.append(total_line)
lines.append("")
return "\n".join(lines)
def to_text(self) -> str:
"""Format as human-readable text."""
lines = []
lines.append("=" * 70)
lines.append("PraisonAI Profile Report")
lines.append("=" * 70)
lines.append("")
# Identity
lines.append(f"Run ID: {self.run_id}")
lines.append(f"Timestamp: {self.timestamp}")
lines.append(f"Method: {self.invocation.method}")
lines.append(f"Version: {self.invocation.praisonai_version}")
lines.append("")
# TIMELINE DIAGRAM (visual representation)
lines.append(self.to_timeline_diagram())
# TIMELINE SECTION (shows ENTER → First Response)
lines.append("## Execution Timeline")
lines.append("-" * 45)
timeline = self.timing.to_timeline()
if timeline:
for phase_name, start_ms, duration_ms in timeline:
lines.append(f" {phase_name:<25} : {duration_ms:>8.2f} ms")
else:
# Fallback to basic phases
if self.timing.imports_ms > 0:
lines.append(f" {'Imports':<25} : {self.timing.imports_ms:>8.2f} ms")
if self.timing.agent_init_ms > 0:
lines.append(f" {'Agent Init':<25} : {self.timing.agent_init_ms:>8.2f} ms")
if self.timing.execution_ms > 0:
lines.append(f" {'Execution':<25} : {self.timing.execution_ms:>8.2f} ms")
lines.append(" " + "─" * 43)
# TIME TO FIRST RESPONSE (TTFR) - the key metric
ttfr = self.timing.time_to_first_response_ms
lines.append(f" {'⏱ Time to First Response':<25} : {ttfr:>8.2f} ms")
lines.append(f" {'TOTAL':<25} : {self.timing.total_ms:>8.2f} ms")
lines.append("")
# Timing breakdown (legacy format for compatibility)
lines.append("## Timing Breakdown")
lines.append("-" * 40)
if self.timing.cli_entry_ms > 0:
lines.append(f" CLI Entry: {self.timing.cli_entry_ms:>10.2f} ms")
if self.timing.cli_parse_ms > 0:
lines.append(f" CLI Parse: {self.timing.cli_parse_ms:>10.2f} ms")
if self.timing.routing_ms > 0:
lines.append(f" Routing: {self.timing.routing_ms:>10.2f} ms")
lines.append(f" Imports: {self.timing.imports_ms:>10.2f} ms")
lines.append(f" Agent Init: {self.timing.agent_init_ms:>10.2f} ms")
lines.append(f" Execution: {self.timing.execution_ms:>10.2f} ms")
if self.timing.network_start_ms > 0:
lines.append(f" Network Start: {self.timing.network_start_ms:>10.2f} ms")
if self.timing.first_token_ms > 0:
lines.append(f" First Token: {self.timing.first_token_ms:>10.2f} ms")
if self.timing.first_output_ms > 0:
lines.append(f" First Output: {self.timing.first_output_ms:>10.2f} ms")
lines.append(" ─────────────────────────────────────")
lines.append(f" TOTAL: {self.timing.total_ms:>10.2f} ms")
lines.append("")
# Decision trace (deep profile only)
if self.decision_trace:
lines.append("## Decision Trace")
lines.append("-" * 40)
lines.append(self.decision_trace.to_text())
lines.append("")
# Function stats
if self.functions:
lines.append("## Top Functions by Cumulative Time")
lines.append("-" * 70)
lines.append(f"{'Function':<40} {'Calls':>8} {'Cumul (ms)':>12}")
lines.append("-" * 70)
for func in self.functions[:20]:
name = func.name[:38] if len(func.name) > 38 else func.name
lines.append(f"{name:<40} {func.calls:>8} {func.cumulative_time_ms:>12.2f}")
lines.append("")
# Module breakdown (deep profile only)
if self.module_breakdown:
lines.append("## Module Breakdown")
lines.append("-" * 40)
mb = self.module_breakdown
if mb.cli_modules:
lines.append(f" CLI: {len(mb.cli_modules)} modules")
if mb.execution_modules:
lines.append(f" Execution: {len(mb.execution_modules)} modules")
if mb.agent_modules:
lines.append(f" Agent: {len(mb.agent_modules)} modules")
if mb.tool_modules:
lines.append(f" Tools: {len(mb.tool_modules)} modules")
if mb.network_modules:
lines.append(f" Network: {len(mb.network_modules)} modules")
if mb.third_party_modules:
lines.append(f" Third-party:{len(mb.third_party_modules)} modules")
lines.append("")
# Call graph summary
if self.call_graph:
lines.append(f"## Call Graph: {len(self.call_graph.edges)} edges")
lines.append("")
# Network timing
if self.network:
lines.append("## Network Requests")
lines.append("-" * 70)
for req in self.network[:10]:
lines.append(f" {req.method} {req.url[:50]} - {req.total_ms:.2f}ms (TTFB: {req.ttfb_ms:.2f}ms)")
lines.append("")
# Response preview
if self.response_preview:
lines.append("## Response Preview")
lines.append("-" * 40)
preview = self.response_preview[:200]
if len(self.response_preview) > 200:
preview += "..."
lines.append(preview)
lines.append("")
lines.append("=" * 70)
return "\n".join(lines)
class Profiler:
"""
External profiler that wraps execution.
Execution code has ZERO awareness of profiling.
Profiler uses cProfile.runctx() or similar.
"""
def __init__(self, config: ProfilerConfig):
self.config = config
self._cprofile: Optional[cProfile.Profile] = None
self._timing: Dict[str, float] = {}
self._network_requests: List[RequestTiming] = []
def profile_sync(
self,
request: ExecutionRequest,
invocation_method: str = "profile_command",
) -> Tuple[ExecutionResult, ProfileReport]:
"""
Profile synchronous execution.
Args:
request: The execution request
invocation_method: How this was invoked (for report)
Returns:
Tuple of (ExecutionResult, ProfileReport)
"""
timing_dict: Dict[str, float] = {}
# Layer 0: Wall-clock timing (always)
start = time.perf_counter()
# Layer 1+: cProfile
if self.config.layer >= 1:
self._cprofile = cProfile.Profile()
self._cprofile.enable()
try:
result = _execute_core(request, timing_dict)
finally:
if self._cprofile:
self._cprofile.disable()
total_ms = (time.perf_counter() - start) * 1000
# Build report
report = self._build_report(
request=request,
result=result,
timing_dict=timing_dict,
total_ms=total_ms,
invocation_method=invocation_method,
)
return result, report
def _build_report(
self,
request: ExecutionRequest,
result: ExecutionResult,
timing_dict: Dict[str, float],
total_ms: float,
invocation_method: str,
) -> ProfileReport:
"""Build the profile report from collected data."""
# Calculate first_output_ms (TTFR) - time from start to when output is available
# This is imports + agent_init + execution (when output becomes available)
imports_ms = timing_dict.get("imports_ms", 0.0)
agent_init_ms = timing_dict.get("agent_init_ms", 0.0)
execution_ms = timing_dict.get("execution_ms", 0.0)
first_output_ms = imports_ms + agent_init_ms + execution_ms
# Timing breakdown with full timeline
timing = TimingBreakdown(
total_ms=total_ms,
imports_ms=imports_ms,
agent_init_ms=agent_init_ms,
execution_ms=execution_ms,
cli_entry_ms=timing_dict.get("cli_entry_ms", 0.0),
cli_parse_ms=timing_dict.get("cli_parse_ms", 0.0),
routing_ms=timing_dict.get("routing_ms", 0.0),
network_start_ms=timing_dict.get("network_start_ms", 0.0),
first_token_ms=timing_dict.get("first_token_ms", 0.0),
first_output_ms=first_output_ms,
)
# Invocation info
invocation = InvocationInfo(
method=invocation_method,
flags={
"layer": self.config.layer,
"stream": request.stream,
"model": request.model,
},
)
# Function stats (layer 1+)
functions = None
if self.config.layer >= 1 and self._cprofile:
functions = self._extract_function_stats()
# Call graph (layer 2)
call_graph = None
if self.config.layer >= 2 and self._cprofile:
call_graph = self._extract_call_graph()
# Decision trace (layer 2 - deep profile)
decision_trace = None
if self.config.layer >= 2:
decision_trace = DecisionTrace(
agent_config=request.agent_name or "default",
model_selected=request.model or "default",
streaming_mode=request.stream,
profile_layer=self.config.layer,
tools_enabled=list(request.tools) if request.tools else [],
tools_disabled=[],
fallbacks_used=[],
)
# Module breakdown (layer 2 - deep profile)
module_breakdown = None
if self.config.layer >= 2 and functions:
module_breakdown = self._extract_module_breakdown(functions)
# Response preview (truncated)
response_preview = result.output[:500] if result.output else ""
return ProfileReport(
run_id=result.run_id,
invocation=invocation,
timing=timing,
functions=functions,
call_graph=call_graph,
network=self._network_requests if self._network_requests else None,
response_preview=response_preview,
decision_trace=decision_trace,
module_breakdown=module_breakdown,
)
def _extract_module_breakdown(self, functions: List[FunctionStat]) -> ModuleBreakdown:
"""Extract module breakdown from function stats."""
cli_modules = set()
execution_modules = set()
agent_modules = set()
tool_modules = set()
network_modules = set()
third_party_modules = set()
for func in functions:
file_path = func.file.lower()
if "praisonai/cli" in file_path:
cli_modules.add(func.file)
elif "praisonai" in file_path and "execution" in file_path:
execution_modules.add(func.file)
elif "praisonaiagents" in file_path and "agent" in file_path:
agent_modules.add(func.file)
elif "praisonaiagents" in file_path and "tool" in file_path:
tool_modules.add(func.file)
elif any(x in file_path for x in ["httpx", "httpcore", "urllib", "requests", "aiohttp"]):
network_modules.add(func.file)
elif "site-packages" in file_path or not file_path.startswith("/"):
third_party_modules.add(func.file)
return ModuleBreakdown(
cli_modules=list(cli_modules)[:20],
execution_modules=list(execution_modules)[:20],
agent_modules=list(agent_modules)[:20],
tool_modules=list(tool_modules)[:20],
network_modules=list(network_modules)[:20],
third_party_modules=list(third_party_modules)[:20],
)
def _extract_function_stats(self) -> List[FunctionStat]:
"""Extract function statistics from cProfile."""
if not self._cprofile:
return []
stats = pstats.Stats(self._cprofile)
# Sort by cumulative time
stats.sort_stats(self.config.sort_by)
# Extract stats
function_stats = []
for (filename, line, name), (cc, nc, tt, ct, callers) in stats.stats.items():
if len(function_stats) >= MAX_FUNCTION_STATS:
break
function_stats.append(FunctionStat(
name=name,
file=filename,
line=line,
calls=nc,
total_time_ms=tt * 1000,
cumulative_time_ms=ct * 1000,
))
# Sort and limit
function_stats.sort(key=lambda x: x.cumulative_time_ms, reverse=True)
return function_stats[:self.config.limit]
def _extract_call_graph(self) -> CallGraph:
"""Extract call graph from cProfile."""
if not self._cprofile:
return CallGraph()
stats = pstats.Stats(self._cprofile)
callers: Dict[str, List[str]] = {}
callees: Dict[str, List[str]] = {}
edge_count = 0
for (filename, line, name), (cc, nc, tt, ct, caller_dict) in stats.stats.items():
if edge_count >= MAX_CALL_GRAPH_EDGES:
break
callee_key = f"{name}:{filename}:{line}"
for (caller_file, caller_line, caller_name), _ in caller_dict.items():
if edge_count >= MAX_CALL_GRAPH_EDGES:
break
caller_key = f"{caller_name}:{caller_file}:{caller_line}"
# Add to callers
if callee_key not in callers:
callers[callee_key] = []
if caller_key not in callers[callee_key]:
callers[callee_key].append(caller_key)
# Add to callees
if caller_key not in callees:
callees[caller_key] = []
if callee_key not in callees[caller_key]:
callees[caller_key].append(callee_key)
edge_count += 1
return CallGraph(callers=callers, callees=callees)
@@ -0,0 +1,85 @@
"""
ExecutionRequest - Immutable request for execution.
This is the minimal, immutable request that all execution paths use.
CLI/TUI translate their flags into this structure.
"""
from dataclasses import dataclass
from typing import Callable, Optional, Tuple, Any
@dataclass(frozen=True)
class ExecutionRequest:
"""
Minimal, immutable request for execution.
NOT a kitchen-sink of all CLI flags.
CLI/TUI translate their flags into this.
Attributes:
prompt: The user prompt to execute
agent_name: Name of the agent (default: "Assistant")
agent_instructions: Custom instructions for the agent
model: LLM model to use (None = use default)
tools: Tuple of tool callables (immutable)
stream: Whether to stream the response
Note: This is frozen (immutable) to:
- Prevent accidental mutation during profiling
- Enable safe sharing across threads/coroutines
"""
# Required
prompt: str
# Agent identity (optional, has defaults)
agent_name: str = "Assistant"
agent_instructions: Optional[str] = None
# Model (optional, has defaults)
model: Optional[str] = None # None = use default
# Tools (optional) - using tuple for immutability
tools: Tuple[Callable[..., Any], ...] = ()
# Execution mode
stream: bool = False
def __post_init__(self):
"""Validate request after initialization."""
if not self.prompt:
raise ValueError("prompt cannot be empty")
def with_model(self, model: str) -> 'ExecutionRequest':
"""Create a new request with a different model."""
return ExecutionRequest(
prompt=self.prompt,
agent_name=self.agent_name,
agent_instructions=self.agent_instructions,
model=model,
tools=self.tools,
stream=self.stream,
)
def with_tools(self, tools: Tuple[Callable[..., Any], ...]) -> 'ExecutionRequest':
"""Create a new request with different tools."""
return ExecutionRequest(
prompt=self.prompt,
agent_name=self.agent_name,
agent_instructions=self.agent_instructions,
model=self.model,
tools=tools,
stream=self.stream,
)
def to_dict(self) -> dict:
"""Convert to dictionary for serialization."""
return {
"prompt": self.prompt,
"agent_name": self.agent_name,
"agent_instructions": self.agent_instructions,
"model": self.model,
"tools_count": len(self.tools),
"stream": self.stream,
}
@@ -0,0 +1,74 @@
"""
ExecutionResult - Result from execution.
Contains the output and metadata from an execution.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@dataclass
class ExecutionResult:
"""
Result from executing a request.
Attributes:
output: The text output from the agent
run_id: Unique identifier for this execution
success: Whether execution completed successfully
error: Error message if execution failed
metadata: Additional metadata about the execution
tool_calls: List of tools that were called
start_time: When execution started (perf_counter)
end_time: When execution ended (perf_counter)
"""
# Primary output
output: str
# Identity
run_id: str = ""
# Status
success: bool = True
error: Optional[str] = None
# Metadata
metadata: Dict[str, Any] = field(default_factory=dict)
# Tool tracking
tool_calls: List[str] = field(default_factory=list)
# Timing (raw perf_counter values)
start_time: float = 0.0
end_time: float = 0.0
@property
def duration_ms(self) -> float:
"""Duration in milliseconds."""
if self.start_time and self.end_time:
return (self.end_time - self.start_time) * 1000
return 0.0
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"output": self.output,
"run_id": self.run_id,
"success": self.success,
"error": self.error,
"metadata": self.metadata,
"tool_calls": self.tool_calls,
"duration_ms": self.duration_ms,
}
@classmethod
def from_error(cls, error: str, run_id: str = "") -> 'ExecutionResult':
"""Create a result from an error."""
return cls(
output="",
run_id=run_id,
success=False,
error=error,
)
@@ -94,8 +94,21 @@ __all__ = [
'CapabilitiesHandler',
# Performance benchmarking
'PerformanceHandler',
# Comprehensive benchmarking
'BenchmarkHandler',
# Lite agent (BYO-LLM)
'LiteHandler',
# Agent-centric tools (LSP/ACP powered)
'create_agent_centric_tools',
'InteractiveRuntime',
'RuntimeConfig',
'CodeIntelligenceRouter',
'ActionOrchestrator',
# Interactive tools provider (canonical source)
'get_interactive_tools',
'resolve_tool_groups',
'ToolConfig',
'TOOL_GROUPS',
]
def __getattr__(name):
@@ -222,7 +235,39 @@ def __getattr__(name):
elif name == 'PerformanceHandler':
from .performance import PerformanceHandler
return PerformanceHandler
elif name == 'BenchmarkHandler':
from .benchmark import BenchmarkHandler
return BenchmarkHandler
elif name == 'LiteHandler':
from .lite import LiteHandler
return LiteHandler
# Agent-centric tools (LSP/ACP powered)
elif name == 'create_agent_centric_tools':
from .agent_tools import create_agent_centric_tools
return create_agent_centric_tools
elif name == 'InteractiveRuntime':
from .interactive_runtime import InteractiveRuntime
return InteractiveRuntime
elif name == 'RuntimeConfig':
from .interactive_runtime import RuntimeConfig
return RuntimeConfig
elif name == 'CodeIntelligenceRouter':
from .code_intelligence import CodeIntelligenceRouter
return CodeIntelligenceRouter
elif name == 'ActionOrchestrator':
from .action_orchestrator import ActionOrchestrator
return ActionOrchestrator
# Interactive tools provider (canonical source)
elif name == 'get_interactive_tools':
from .interactive_tools import get_interactive_tools
return get_interactive_tools
elif name == 'resolve_tool_groups':
from .interactive_tools import resolve_tool_groups
return resolve_tool_groups
elif name == 'ToolConfig':
from .interactive_tools import ToolConfig
return ToolConfig
elif name == 'TOOL_GROUPS':
from .interactive_tools import TOOL_GROUPS
return TOOL_GROUPS
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,474 @@
"""
Agent-Centric Tools for PraisonAI Interactive Mode.
These tools route file operations and code intelligence through LSP/ACP,
making the Agent the central orchestrator for all actions.
"""
import asyncio
import json
import logging
from typing import Callable, Dict, List, TYPE_CHECKING
if TYPE_CHECKING:
from .interactive_runtime import InteractiveRuntime
from .code_intelligence import CodeIntelligenceRouter
from .action_orchestrator import ActionOrchestrator
logger = logging.getLogger(__name__)
def create_agent_centric_tools(
runtime: "InteractiveRuntime",
router: "CodeIntelligenceRouter" = None,
orchestrator: "ActionOrchestrator" = None
) -> List[Callable]:
"""
Create tools that route through LSP/ACP for agent-centric architecture.
Args:
runtime: The InteractiveRuntime instance
router: Optional CodeIntelligenceRouter (created if not provided)
orchestrator: Optional ActionOrchestrator (created if not provided)
Returns:
List of tool functions for the Agent
"""
from .code_intelligence import CodeIntelligenceRouter
from .action_orchestrator import ActionOrchestrator
if router is None:
router = CodeIntelligenceRouter(runtime)
if orchestrator is None:
orchestrator = ActionOrchestrator(runtime)
# Helper to run async functions synchronously
def run_async(coro):
"""Run async coroutine synchronously."""
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# Create a new loop in a thread
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, coro)
return future.result(timeout=60)
else:
return loop.run_until_complete(coro)
except RuntimeError:
return asyncio.run(coro)
# =========================================================================
# ACP-Powered File Tools
# =========================================================================
def acp_create_file(filepath: str, content: str) -> str:
"""
Create a file through ACP with plan/approve/apply/verify flow.
This tool routes file creation through the ActionOrchestrator,
ensuring proper tracking, approval, and verification.
Args:
filepath: Path to the file to create (relative to workspace)
content: Content to write to the file
Returns:
JSON string with result including plan status and verification
"""
async def _create():
# Build a detailed prompt for the orchestrator
prompt = f"create file {filepath}"
# Create plan
result = await orchestrator.create_plan(prompt)
if not result.success:
return json.dumps({
"success": False,
"error": result.error,
"read_only": result.read_only_blocked
})
plan = result.plan
# Update the plan step with actual content
if plan.steps:
plan.steps[0].params["content"] = content
# Approve based on runtime config
auto_approve = runtime.config.approval_mode == "auto"
approved = await orchestrator.approve_plan(plan, auto=auto_approve)
if not approved and runtime.config.approval_mode == "manual":
return json.dumps({
"success": False,
"error": "Manual approval required",
"plan": plan.to_dict(),
"requires_approval": True
})
# Apply the plan
result = await orchestrator.apply_plan(plan, force=auto_approve)
if not result.success:
return json.dumps({
"success": False,
"error": result.error,
"plan": plan.to_dict()
})
# Verify
result = await orchestrator.verify_plan(plan)
return json.dumps({
"success": result.success,
"file_created": filepath,
"plan_id": plan.id,
"status": plan.status.value,
"verified": result.success
})
return run_async(_create())
def acp_edit_file(filepath: str, new_content: str) -> str:
"""
Edit a file through ACP with plan/approve/apply/verify flow.
Args:
filepath: Path to the file to edit (relative to workspace)
new_content: New content for the file
Returns:
JSON string with result including plan status
"""
async def _edit():
prompt = f"edit file {filepath}"
result = await orchestrator.create_plan(prompt)
if not result.success:
return json.dumps({
"success": False,
"error": result.error,
"read_only": result.read_only_blocked
})
plan = result.plan
# Update step with new content
if plan.steps:
plan.steps[0].params["new_content"] = new_content
auto_approve = runtime.config.approval_mode == "auto"
approved = await orchestrator.approve_plan(plan, auto=auto_approve)
if not approved and runtime.config.approval_mode == "manual":
return json.dumps({
"success": False,
"error": "Manual approval required",
"requires_approval": True
})
result = await orchestrator.apply_plan(plan, force=auto_approve)
return json.dumps({
"success": result.success,
"file_edited": filepath,
"plan_id": plan.id,
"error": result.error
})
return run_async(_edit())
def acp_delete_file(filepath: str) -> str:
"""
Delete a file through ACP with plan/approve/apply/verify flow.
Args:
filepath: Path to the file to delete
Returns:
JSON string with result
"""
async def _delete():
prompt = f"delete file {filepath}"
result = await orchestrator.create_plan(prompt)
if not result.success:
return json.dumps({
"success": False,
"error": result.error
})
plan = result.plan
# Delete requires explicit approval even in auto mode
auto_approve = runtime.config.approval_mode == "auto"
approved = await orchestrator.approve_plan(plan, auto=auto_approve)
if not approved:
return json.dumps({
"success": False,
"error": "Delete requires approval",
"requires_approval": True
})
result = await orchestrator.apply_plan(plan)
result = await orchestrator.verify_plan(plan)
return json.dumps({
"success": result.success,
"file_deleted": filepath,
"verified": result.success
})
return run_async(_delete())
def acp_execute_command(command: str, cwd: str = None) -> str:
"""
Execute a shell command through ACP with tracking.
Args:
command: The command to execute
cwd: Working directory (optional)
Returns:
JSON string with command output
"""
async def _execute():
prompt = f"run command: {command}"
result = await orchestrator.create_plan(prompt)
if not result.success:
return json.dumps({
"success": False,
"error": result.error
})
plan = result.plan
# Commands require approval
auto_approve = runtime.config.approval_mode == "auto"
approved = await orchestrator.approve_plan(plan, auto=auto_approve)
if not approved:
return json.dumps({
"success": False,
"error": "Command requires approval",
"requires_approval": True
})
result = await orchestrator.apply_plan(plan)
# Extract command result
if plan.steps and plan.steps[0].result:
cmd_result = plan.steps[0].result
return json.dumps({
"success": result.success,
"command": command,
"stdout": cmd_result.get("stdout", ""),
"stderr": cmd_result.get("stderr", ""),
"returncode": cmd_result.get("returncode", -1)
})
return json.dumps({
"success": result.success,
"error": result.error
})
return run_async(_execute())
# =========================================================================
# LSP-Powered Code Intelligence Tools
# =========================================================================
def lsp_list_symbols(file_path: str) -> str:
"""
List all symbols (functions, classes, methods) in a file using LSP.
Falls back to regex-based extraction if LSP is unavailable.
Args:
file_path: Path to the file to analyze
Returns:
JSON string with list of symbols and their locations
"""
async def _list():
result = await router.handle_query(
f"list all functions and classes in {file_path}",
file_path=file_path
)
return json.dumps(result.to_dict())
return run_async(_list())
def lsp_find_definition(symbol: str, file_path: str = None) -> str:
"""
Find where a symbol is defined using LSP.
Args:
symbol: The symbol name to find
file_path: Optional file path for context
Returns:
JSON string with definition location(s)
"""
async def _find():
query = f"where is {symbol} defined"
if file_path:
query += f" in {file_path}"
result = await router.handle_query(query, file_path=file_path)
return json.dumps(result.to_dict())
return run_async(_find())
def lsp_find_references(symbol: str, file_path: str = None) -> str:
"""
Find all references to a symbol using LSP.
Args:
symbol: The symbol name to find references for
file_path: Optional file path for context
Returns:
JSON string with reference locations
"""
async def _find():
query = f"find all references to {symbol}"
if file_path:
query += f" in {file_path}"
result = await router.handle_query(query, file_path=file_path)
return json.dumps(result.to_dict())
return run_async(_find())
def lsp_get_diagnostics(file_path: str = None) -> str:
"""
Get diagnostics (errors, warnings) for a file using LSP.
Args:
file_path: Path to the file (optional, gets all if not specified)
Returns:
JSON string with diagnostic information
"""
async def _get():
query = "show all errors and warnings"
if file_path:
query += f" in {file_path}"
result = await router.handle_query(query, file_path=file_path)
return json.dumps(result.to_dict())
return run_async(_get())
# =========================================================================
# Basic File Tools (for read-only operations)
# =========================================================================
def read_file(filepath: str) -> str:
"""
Read content from a file.
This is a read-only operation that doesn't require ACP.
Args:
filepath: Path to the file to read
Returns:
File content or error message
"""
from pathlib import Path
try:
path = Path(filepath)
if not path.is_absolute():
path = Path(runtime.config.workspace) / filepath
if not path.exists():
return json.dumps({"error": f"File not found: {filepath}"})
content = path.read_text()
# Track in trace if enabled
if runtime._trace:
runtime._trace.add_entry(
category="file",
action="read",
params={"file": str(path)},
result={"size": len(content)}
)
return content
except Exception as e:
return json.dumps({"error": str(e)})
def list_files(directory: str = ".", pattern: str = "*") -> str:
"""
List files in a directory.
Args:
directory: Directory to list (relative to workspace)
pattern: Glob pattern to filter files
Returns:
JSON string with list of files
"""
from pathlib import Path
try:
path = Path(directory)
if not path.is_absolute():
path = Path(runtime.config.workspace) / directory
if not path.exists():
return json.dumps({"error": f"Directory not found: {directory}"})
files = []
for f in path.glob(pattern):
files.append({
"name": f.name,
"path": str(f.relative_to(runtime.config.workspace)),
"is_dir": f.is_dir(),
"size": f.stat().st_size if f.is_file() else None
})
return json.dumps({"files": files, "count": len(files)})
except Exception as e:
return json.dumps({"error": str(e)})
# Return all tools
return [
# ACP-powered file tools
acp_create_file,
acp_edit_file,
acp_delete_file,
acp_execute_command,
# LSP-powered code intelligence
lsp_list_symbols,
lsp_find_definition,
lsp_find_references,
lsp_get_diagnostics,
# Basic read-only tools
read_file,
list_files,
]
def get_tool_descriptions() -> Dict[str, str]:
"""Get descriptions of all agent-centric tools."""
return {
"acp_create_file": "Create a file through ACP with plan/approve/apply/verify",
"acp_edit_file": "Edit a file through ACP with tracking",
"acp_delete_file": "Delete a file through ACP (requires approval)",
"acp_execute_command": "Execute a shell command through ACP",
"lsp_list_symbols": "List symbols in a file using LSP",
"lsp_find_definition": "Find where a symbol is defined",
"lsp_find_references": "Find all references to a symbol",
"lsp_get_diagnostics": "Get errors and warnings for a file",
"read_file": "Read content from a file (read-only)",
"list_files": "List files in a directory",
}
File diff suppressed because it is too large. Load diff
@@ -4,6 +4,7 @@ ACP health checks for PraisonAI Doctor.
import asyncio
import logging
import os
from ..models import CheckCategory, CheckResult, CheckStatus
from ..registry import register_check
@@ -17,7 +18,7 @@ logger = logging.getLogger(__name__)
category=CheckCategory.TOOLS,
description="Check if ACP module is installed"
)
def check_acp_module() -> CheckResult:
def check_acp_module(config=None) -> CheckResult:
"""Check if ACP module is available."""
try:
import importlib.util
@@ -25,21 +26,30 @@ def check_acp_module() -> CheckResult:
if spec is not None:
return CheckResult(
id="acp_module",
title="ACP Module Available",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="ACP module is available",
details={"module": "praisonai.acp"}
metadata={"module": "praisonai.acp"}
)
else:
return CheckResult(
id="acp_module",
title="ACP Module Available",
category=CheckCategory.TOOLS,
status=CheckStatus.WARN,
message="ACP module not found",
details={"hint": "ACP module should be part of praisonai package"}
remediation="ACP module should be part of praisonai package"
)
except Exception as e:
return CheckResult(
id="acp_module",
title="ACP Module Available",
category=CheckCategory.TOOLS,
status=CheckStatus.FAIL,
message=f"Error checking ACP module: {e}",
details={"error": str(e)}
metadata={"error": str(e)}
)
@@ -49,7 +59,7 @@ def check_acp_module() -> CheckResult:
category=CheckCategory.TOOLS,
description="Check if agent-client-protocol SDK is installed"
)
def check_acp_sdk() -> CheckResult:
def check_acp_sdk(config=None) -> CheckResult:
"""Check if ACP SDK is available."""
try:
import importlib.util
@@ -57,21 +67,30 @@ def check_acp_sdk() -> CheckResult:
if spec is not None:
return CheckResult(
id="acp_sdk",
title="ACP SDK Available",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="ACP SDK (agent-client-protocol) is installed",
details={"package": "agent-client-protocol"}
metadata={"package": "agent-client-protocol"}
)
else:
return CheckResult(
status=CheckStatus.INFO,
id="acp_sdk",
title="ACP SDK Available",
category=CheckCategory.TOOLS,
status=CheckStatus.SKIP,
message="ACP SDK not installed (optional)",
details={"hint": "Install with: pip install agent-client-protocol"}
remediation="Install with: pip install agent-client-protocol"
)
except Exception as e:
return CheckResult(
id="acp_sdk",
title="ACP SDK Available",
category=CheckCategory.TOOLS,
status=CheckStatus.FAIL,
message=f"Error checking ACP SDK: {e}",
details={"error": str(e)}
metadata={"error": str(e)}
)
@@ -81,27 +100,36 @@ def check_acp_sdk() -> CheckResult:
category=CheckCategory.TOOLS,
description="Check if ACP server can be imported"
)
def check_acp_server() -> CheckResult:
def check_acp_server(config=None) -> CheckResult:
"""Check if ACP server can be imported."""
try:
from praisonai.acp import ACPServer # noqa: F401
return CheckResult(
id="acp_server",
title="ACP Server Import",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="ACP server can be imported",
details={"class": "ACPServer"}
metadata={"class": "ACPServer"}
)
except ImportError as e:
return CheckResult(
id="acp_server",
title="ACP Server Import",
category=CheckCategory.TOOLS,
status=CheckStatus.WARN,
message=f"ACP server import failed: {e}",
details={"error": str(e)}
metadata={"error": str(e)}
)
except Exception as e:
return CheckResult(
id="acp_server",
title="ACP Server Import",
category=CheckCategory.TOOLS,
status=CheckStatus.FAIL,
message=f"Error importing ACP server: {e}",
details={"error": str(e)}
metadata={"error": str(e)}
)
@@ -111,20 +139,21 @@ def check_acp_server() -> CheckResult:
category=CheckCategory.CONFIG,
description="Check ACP configuration"
)
def check_acp_config() -> CheckResult:
def check_acp_config(config=None) -> CheckResult:
"""Check ACP configuration."""
import os
config = {
acp_conf = {
"approval_mode": os.environ.get("PRAISONAI_APPROVAL_MODE", "manual"),
"allow_write": os.environ.get("PRAISONAI_ALLOW_WRITE", "false"),
"allow_shell": os.environ.get("PRAISONAI_ALLOW_SHELL", "false"),
}
return CheckResult(
id="acp_config",
title="ACP Configuration",
category=CheckCategory.CONFIG,
status=CheckStatus.PASS,
message=f"ACP configured with approval_mode={config['approval_mode']}",
details=config
message=f"ACP configured with approval_mode={acp_conf['approval_mode']}",
metadata=acp_conf
)
@@ -135,7 +164,7 @@ def check_acp_config() -> CheckResult:
description="Check if ACP runtime can start",
requires_deep=True
)
def check_acp_runtime() -> CheckResult:
def check_acp_runtime(config=None) -> CheckResult:
"""Check if ACP runtime can start."""
try:
from praisonai.cli.features.interactive_runtime import create_runtime
@@ -150,88 +179,39 @@ def check_acp_runtime() -> CheckResult:
await runtime.start()
ready = runtime.acp_ready
error = runtime._acp_state.error
read_only = runtime.read_only
return ready, error, read_only
return ready, error
finally:
await runtime.stop()
ready, error, read_only = asyncio.run(_check())
ready, error = asyncio.run(_check())
if ready:
return CheckResult(
id="acp_runtime",
title="ACP Runtime Check",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="ACP runtime started successfully",
details={"ready": True, "read_only": read_only}
metadata={"ready": True}
)
else:
return CheckResult(
id="acp_runtime",
title="ACP Runtime Check",
category=CheckCategory.TOOLS,
status=CheckStatus.WARN,
message=f"ACP runtime failed to start: {error}",
details={"ready": False, "error": error, "read_only": read_only}
metadata={"ready": False, "error": str(error)}
)
except Exception as e:
return CheckResult(
id="acp_runtime",
title="ACP Runtime Check",
category=CheckCategory.TOOLS,
status=CheckStatus.FAIL,
message=f"ACP runtime check failed: {e}",
details={"error": str(e)}
)
@register_check(
id="interactive_runtime",
title="Interactive Runtime Check",
category=CheckCategory.TOOLS,
description="Check if full interactive runtime can start",
requires_deep=True
)
def check_interactive_runtime() -> CheckResult:
"""Check if full interactive runtime can start."""
try:
from praisonai.cli.features.interactive_runtime import create_runtime
async def _check():
runtime = create_runtime(
workspace=".",
lsp=True,
acp=True
)
try:
await runtime.start()
status = runtime.get_status()
return status
finally:
await runtime.stop()
status = asyncio.run(_check())
lsp_ready = status.get("lsp", {}).get("ready", False)
acp_ready = status.get("acp", {}).get("ready", False)
if lsp_ready and acp_ready:
return CheckResult(
status=CheckStatus.PASS,
message="Interactive runtime fully operational",
details=status
)
elif lsp_ready or acp_ready:
return CheckResult(
status=CheckStatus.WARN,
message="Interactive runtime partially operational",
details=status
)
else:
return CheckResult(
status=CheckStatus.FAIL,
message="Interactive runtime failed to start",
details=status
)
except Exception as e:
return CheckResult(
status=CheckStatus.FAIL,
message=f"Interactive runtime check failed: {e}",
details={"error": str(e)}
metadata={"error": str(e)}
)
@@ -4,6 +4,7 @@ LSP health checks for PraisonAI Doctor.
import asyncio
import logging
import shutil
from ..models import CheckCategory, CheckResult, CheckStatus
from ..registry import register_check
@@ -17,7 +18,7 @@ logger = logging.getLogger(__name__)
category=CheckCategory.TOOLS,
description="Check if LSP module is installed"
)
def check_lsp_module() -> CheckResult:
def check_lsp_module(config=None) -> CheckResult:
"""Check if LSP module is available."""
try:
import importlib.util
@@ -25,21 +26,30 @@ def check_lsp_module() -> CheckResult:
if spec is not None:
return CheckResult(
id="lsp_module",
title="LSP Module Available",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="LSP module is available",
details={"module": "praisonaiagents.lsp"}
metadata={"module": "praisonaiagents.lsp"}
)
else:
return CheckResult(
id="lsp_module",
title="LSP Module Available",
category=CheckCategory.TOOLS,
status=CheckStatus.WARN,
message="LSP module not found",
details={"hint": "Install praisonaiagents with LSP support"}
remediation="Install praisonaiagents with LSP support"
)
except Exception as e:
return CheckResult(
id="lsp_module",
title="LSP Module Available",
category=CheckCategory.TOOLS,
status=CheckStatus.FAIL,
message=f"Error checking LSP module: {e}",
details={"error": str(e)}
metadata={"error": str(e)}
)
@@ -49,27 +59,36 @@ def check_lsp_module() -> CheckResult:
category=CheckCategory.TOOLS,
description="Check if LSP client can be imported"
)
def check_lsp_client() -> CheckResult:
def check_lsp_client(config=None) -> CheckResult:
"""Check if LSP client can be imported."""
try:
from praisonaiagents.lsp import LSPClient # noqa: F401
return CheckResult(
id="lsp_client",
title="LSP Client Import",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="LSP client can be imported",
details={"class": "LSPClient"}
metadata={"class": "LSPClient"}
)
except ImportError as e:
return CheckResult(
id="lsp_client",
title="LSP Client Import",
category=CheckCategory.TOOLS,
status=CheckStatus.WARN,
message=f"LSP client import failed: {e}",
details={"error": str(e)}
metadata={"error": str(e)}
)
except Exception as e:
return CheckResult(
id="lsp_client",
title="LSP Client Import",
category=CheckCategory.TOOLS,
status=CheckStatus.FAIL,
message=f"Error importing LSP client: {e}",
details={"error": str(e)}
metadata={"error": str(e)}
)
@@ -79,11 +98,8 @@ def check_lsp_client() -> CheckResult:
category=CheckCategory.TOOLS,
description="Check if Python language server is available"
)
def check_lsp_server_python() -> CheckResult:
def check_lsp_server_python(config=None) -> CheckResult:
"""Check if Python language server is available."""
import shutil
# Check for common Python language servers
servers = [
("pyright-langserver", "pyright"),
("pylsp", "python-lsp-server"),
@@ -97,18 +113,21 @@ def check_lsp_server_python() -> CheckResult:
if found:
return CheckResult(
id="lsp_server_python",
title="Python LSP Server",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message=f"Python LSP server available: {found[0]['command']}",
details={"servers": found}
metadata={"servers": found}
)
else:
return CheckResult(
id="lsp_server_python",
title="Python LSP Server",
category=CheckCategory.TOOLS,
status=CheckStatus.WARN,
message="No Python LSP server found",
details={
"hint": "Install with: npm install -g pyright OR pip install python-lsp-server",
"checked": [s[0] for s in servers]
}
remediation="Install with: npm install -g pyright OR pip install python-lsp-server"
)
@@ -118,30 +137,34 @@ def check_lsp_server_python() -> CheckResult:
category=CheckCategory.TOOLS,
description="Check if TypeScript language server is available"
)
def check_lsp_server_typescript() -> CheckResult:
def check_lsp_server_typescript(config=None) -> CheckResult:
"""Check if TypeScript language server is available."""
import shutil
# Check for TypeScript language server
if shutil.which("typescript-language-server"):
return CheckResult(
id="lsp_server_typescript",
title="TypeScript LSP Server",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="TypeScript LSP server available",
details={"command": "typescript-language-server"}
metadata={"command": "typescript-language-server"}
)
elif shutil.which("tsserver"):
return CheckResult(
id="lsp_server_typescript",
title="TypeScript LSP Server",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="TypeScript server available (tsserver)",
details={"command": "tsserver"}
metadata={"command": "tsserver"}
)
else:
return CheckResult(
status=CheckStatus.INFO,
id="lsp_server_typescript",
title="TypeScript LSP Server",
category=CheckCategory.TOOLS,
status=CheckStatus.SKIP,
message="TypeScript LSP server not found (optional)",
details={
"hint": "Install with: npm install -g typescript-language-server typescript"
}
remediation="Install with: npm install -g typescript-language-server typescript"
)
@@ -152,7 +175,7 @@ def check_lsp_server_typescript() -> CheckResult:
description="Check if LSP runtime can start",
requires_deep=True
)
def check_lsp_runtime() -> CheckResult:
def check_lsp_runtime(config=None) -> CheckResult:
"""Check if LSP runtime can start."""
try:
from praisonai.cli.features.interactive_runtime import create_runtime
@@ -175,22 +198,31 @@ def check_lsp_runtime() -> CheckResult:
if ready:
return CheckResult(
id="lsp_runtime",
title="LSP Runtime Check",
category=CheckCategory.TOOLS,
status=CheckStatus.PASS,
message="LSP runtime started successfully",
details={"ready": True}
metadata={"ready": True}
)
else:
return CheckResult(
id="lsp_runtime",
title="LSP Runtime Check",
category=CheckCategory.TOOLS,
status=CheckStatus.WARN,
message=f"LSP runtime failed to start: {error}",
details={"ready": False, "error": error}
metadata={"ready": False, "error": str(error)}
)
except Exception as e:
return CheckResult(
id="lsp_runtime",
title="LSP Runtime Check",
category=CheckCategory.TOOLS,
status=CheckStatus.FAIL,
message=f"LSP runtime check failed: {e}",
details={"error": str(e)}
metadata={"error": str(e)}
)
@@ -0,0 +1,380 @@
"""
Interactive Default Tools Provider for PraisonAI.
This module provides the canonical source of truth for default tools
in interactive modes (TUI and `praison "prompt"`).
Tool Groups:
- `acp`: ACP-powered file operations (create, edit, delete, execute)
- `lsp`: LSP-powered code intelligence (symbols, definitions, references)
- `basic`: Basic file tools (read, list, search)
- `interactive`: Union of all groups (default)
Usage:
from praisonai.cli.features.interactive_tools import (
get_interactive_tools,
resolve_tool_groups,
TOOL_GROUPS,
)
# Get all interactive tools (default)
tools = get_interactive_tools()
# Get specific groups
tools = get_interactive_tools(groups=["acp", "basic"])
# Disable specific groups
tools = get_interactive_tools(disable=["lsp"])
"""
import logging
import os
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional, Set
logger = logging.getLogger(__name__)
# Tool group definitions
TOOL_GROUPS = {
"acp": [
"acp_create_file",
"acp_edit_file",
"acp_delete_file",
"acp_execute_command",
],
"lsp": [
"lsp_list_symbols",
"lsp_find_definition",
"lsp_find_references",
"lsp_get_diagnostics",
],
"basic": [
"read_file",
"write_file",
"list_files",
"execute_command",
"internet_search",
],
}
# Default interactive group includes all
TOOL_GROUPS["interactive"] = (
TOOL_GROUPS["acp"] +
TOOL_GROUPS["lsp"] +
TOOL_GROUPS["basic"]
)
@dataclass
class ToolConfig:
"""Configuration for tool loading."""
workspace: str = field(default_factory=os.getcwd)
enable_acp: bool = True
enable_lsp: bool = True
enable_basic: bool = True
approval_mode: str = "auto" # auto, manual, scoped
lsp_enabled: bool = True
acp_enabled: bool = True
@classmethod
def from_env(cls) -> "ToolConfig":
"""Create config from environment variables."""
config = cls()
# Check env vars for disabling groups
disable_str = os.environ.get("PRAISON_TOOLS_DISABLE", "")
if disable_str:
disabled = [g.strip().lower() for g in disable_str.split(",")]
if "acp" in disabled:
config.enable_acp = False
if "lsp" in disabled:
config.enable_lsp = False
if "basic" in disabled:
config.enable_basic = False
# Check for workspace
workspace = os.environ.get("PRAISON_WORKSPACE", "")
if workspace:
config.workspace = workspace
# Check approval mode
approval = os.environ.get("PRAISON_APPROVAL_MODE", "")
if approval in ("auto", "manual", "scoped"):
config.approval_mode = approval
return config
def resolve_tool_groups(
groups: Optional[List[str]] = None,
disable: Optional[List[str]] = None,
config: Optional[ToolConfig] = None,
) -> Set[str]:
"""
Resolve which tool names should be included based on groups and config.
Args:
groups: Specific groups to include (None = all enabled)
disable: Groups to explicitly disable
config: Tool configuration
Returns:
Set of tool names to include
"""
if config is None:
config = ToolConfig.from_env()
disable = disable or []
# Start with requested groups or all enabled
if groups:
tool_names = set()
for group in groups:
if group in TOOL_GROUPS:
tool_names.update(TOOL_GROUPS[group])
else:
tool_names = set()
if config.enable_acp:
tool_names.update(TOOL_GROUPS["acp"])
if config.enable_lsp:
tool_names.update(TOOL_GROUPS["lsp"])
if config.enable_basic:
tool_names.update(TOOL_GROUPS["basic"])
# Remove disabled groups
for group in disable:
if group in TOOL_GROUPS:
tool_names -= set(TOOL_GROUPS[group])
return tool_names
def _load_basic_tools() -> Dict[str, Callable]:
"""Lazy load basic tools from praisonaiagents."""
tools = {}
try:
from praisonaiagents.tools import read_file
tools["read_file"] = read_file
except ImportError:
logger.debug("read_file not available")
try:
from praisonaiagents.tools import write_file
tools["write_file"] = write_file
except ImportError:
logger.debug("write_file not available")
try:
from praisonaiagents.tools import list_files
tools["list_files"] = list_files
except ImportError:
logger.debug("list_files not available")
try:
from praisonaiagents.tools import execute_command
tools["execute_command"] = execute_command
except ImportError:
logger.debug("execute_command not available")
try:
from praisonaiagents.tools import internet_search
tools["internet_search"] = internet_search
except ImportError:
logger.debug("internet_search not available")
return tools
def _load_acp_tools(config: ToolConfig) -> Dict[str, Callable]:
"""Lazy load ACP tools with runtime initialization."""
tools = {}
try:
from .interactive_runtime import InteractiveRuntime, RuntimeConfig
from .agent_tools import create_agent_centric_tools
# Create runtime config
runtime_config = RuntimeConfig(
workspace=config.workspace,
lsp_enabled=config.lsp_enabled,
acp_enabled=config.acp_enabled,
approval_mode=config.approval_mode,
)
# Create runtime (lazy - doesn't start until needed)
runtime = InteractiveRuntime(runtime_config)
# Get all agent-centric tools
all_tools = create_agent_centric_tools(runtime)
# Map by name
for tool in all_tools:
name = tool.__name__
if name.startswith("acp_"):
tools[name] = tool
logger.debug(f"Loaded {len(tools)} ACP tools")
except ImportError as e:
logger.debug(f"ACP tools not available: {e}")
except Exception as e:
logger.warning(f"Error loading ACP tools: {e}")
return tools
def _load_lsp_tools(config: ToolConfig) -> Dict[str, Callable]:
"""Lazy load LSP tools with runtime initialization."""
tools = {}
try:
from .interactive_runtime import InteractiveRuntime, RuntimeConfig
from .agent_tools import create_agent_centric_tools
# Create runtime config
runtime_config = RuntimeConfig(
workspace=config.workspace,
lsp_enabled=config.lsp_enabled,
acp_enabled=config.acp_enabled,
approval_mode=config.approval_mode,
)
# Create runtime
runtime = InteractiveRuntime(runtime_config)
# Get all agent-centric tools
all_tools = create_agent_centric_tools(runtime)
# Map by name
for tool in all_tools:
name = tool.__name__
if name.startswith("lsp_"):
tools[name] = tool
logger.debug(f"Loaded {len(tools)} LSP tools")
except ImportError as e:
logger.debug(f"LSP tools not available: {e}")
except Exception as e:
logger.warning(f"Error loading LSP tools: {e}")
return tools
def get_interactive_tools(
groups: Optional[List[str]] = None,
disable: Optional[List[str]] = None,
config: Optional[ToolConfig] = None,
workspace: Optional[str] = None,
) -> List[Callable]:
"""
Get the default interactive tools for TUI and prompt modes.
This is the canonical source of truth for interactive tool defaults.
Both `praisonai tui launch` and `praison "prompt"` should use this.
Args:
groups: Specific tool groups to include (default: all enabled)
disable: Tool groups to disable (e.g., ["acp", "lsp"])
config: Tool configuration (created from env if not provided)
workspace: Override workspace path
Returns:
List of tool callables
Example:
# Default (all tools)
tools = get_interactive_tools()
# Disable ACP tools
tools = get_interactive_tools(disable=["acp"])
# Only basic tools
tools = get_interactive_tools(groups=["basic"])
"""
if config is None:
config = ToolConfig.from_env()
if workspace:
config.workspace = workspace
# Resolve which tools to include
tool_names = resolve_tool_groups(groups, disable, config)
# Load tools by group
all_tools: Dict[str, Callable] = {}
# Load basic tools (always available, no runtime needed)
if config.enable_basic and not (disable and "basic" in disable):
basic_tools = _load_basic_tools()
all_tools.update(basic_tools)
# Load ACP tools (requires runtime)
if config.enable_acp and not (disable and "acp" in disable):
acp_tools = _load_acp_tools(config)
all_tools.update(acp_tools)
# Load LSP tools (requires runtime)
if config.enable_lsp and not (disable and "lsp" in disable):
lsp_tools = _load_lsp_tools(config)
all_tools.update(lsp_tools)
# Filter to requested tools
result = []
for name in sorted(tool_names): # Deterministic ordering
if name in all_tools:
result.append(all_tools[name])
logger.debug(f"Loaded {len(result)} interactive tools: {[t.__name__ for t in result]}")
return result
def merge_tool_overrides(
default_tools: List[Callable],
add_tools: Optional[List[Callable]] = None,
remove_names: Optional[List[str]] = None,
) -> List[Callable]:
"""
Merge tool overrides with default tools.
Args:
default_tools: Base list of tools
add_tools: Tools to add
remove_names: Tool names to remove
Returns:
Merged tool list
"""
# Build name -> tool map
tool_map = {t.__name__: t for t in default_tools}
# Remove specified tools
if remove_names:
for name in remove_names:
tool_map.pop(name, None)
# Add new tools
if add_tools:
for tool in add_tools:
tool_map[tool.__name__] = tool
return list(tool_map.values())
def get_tool_group_names() -> Dict[str, List[str]]:
"""Get available tool groups and their tool names."""
return TOOL_GROUPS.copy()
def print_tool_summary(tools: List[Callable]) -> None:
"""Print a summary of loaded tools (for debugging)."""
acp_count = sum(1 for t in tools if t.__name__.startswith("acp_"))
lsp_count = sum(1 for t in tools if t.__name__.startswith("lsp_"))
basic_count = len(tools) - acp_count - lsp_count
print(f"Interactive tools loaded: {len(tools)} total")
print(f" - ACP tools: {acp_count}")
print(f" - LSP tools: {lsp_count}")
print(f" - Basic tools: {basic_count}")
@@ -0,0 +1,81 @@
"""
PraisonAI CLI Profiler Module.
Provides detailed cProfile-based profiling for CLI commands and agent execution.
Supports per-file/per-function timing, call graphs, and import time analysis.
Includes Tier 0/1/2 optimizations:
- Tier 0: Lazy imports, provider caching, CLI startup optimization
- Tier 1: Connection pooling, prewarm hooks (opt-in)
- Tier 2: Lite mode, async init, perf snapshot
"""
from .core import (
ProfilerConfig,
ProfilerResult,
QueryProfiler,
run_profiled_query,
format_profile_report,
)
from .suite import (
ScenarioConfig,
ScenarioResult,
SuiteResult,
ProfileSuiteRunner,
run_profile_suite,
)
from .optimizations import (
# Tier 0
ProviderCache,
get_provider_cache,
resolve_model_provider,
LazyImporter,
# Tier 1
ClientPool,
get_client_pool,
PrewarmManager,
# Tier 2
LiteModeConfig,
get_lite_mode_config,
is_lite_mode,
PerfSnapshot,
PerfComparison,
PerfSnapshotManager,
create_snapshot_from_suite,
format_comparison_report,
)
__all__ = [
# Core profiler
'ProfilerConfig',
'ProfilerResult',
'QueryProfiler',
'run_profiled_query',
'format_profile_report',
# Suite runner
'ScenarioConfig',
'ScenarioResult',
'SuiteResult',
'ProfileSuiteRunner',
'run_profile_suite',
# Tier 0 optimizations
'ProviderCache',
'get_provider_cache',
'resolve_model_provider',
'LazyImporter',
# Tier 1 optimizations
'ClientPool',
'get_client_pool',
'PrewarmManager',
# Tier 2 optimizations
'LiteModeConfig',
'get_lite_mode_config',
'is_lite_mode',
'PerfSnapshot',
'PerfComparison',
'PerfSnapshotManager',
'create_snapshot_from_suite',
'format_comparison_report',
]
@@ -0,0 +1,558 @@
"""
Core profiler implementation for PraisonAI CLI.
Provides cProfile-based profiling with detailed per-file/per-function timing,
call graphs, and import time analysis.
"""
import cProfile
import io
import json
import os
import pstats
import re
import subprocess
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# Patterns for secrets to redact
SECRET_PATTERNS = [
r'sk-[a-zA-Z0-9]{20,}', # OpenAI keys
r'sk-ant-[a-zA-Z0-9-]{20,}', # Anthropic keys
r'AIza[a-zA-Z0-9_-]{35}', # Google API keys
r'tvly-[a-zA-Z0-9-]{20,}', # Tavily keys
r'[a-zA-Z0-9_-]{32,}', # Generic long tokens (be careful)
]
def redact_secrets(text: str) -> str:
"""Redact potential secrets from text."""
result = text
for pattern in SECRET_PATTERNS[:4]: # Skip generic pattern for safety
result = re.sub(pattern, '[REDACTED]', result)
return result
@dataclass
class ProfilerConfig:
"""Configuration for profiler."""
deep: bool = False # Enable deep call tracing
limit: int = 30 # Top N functions to show
sort_by: str = "cumulative" # cumulative or tottime
show_files: bool = False # Group by file
show_callers: bool = False # Show callers
show_callees: bool = False # Show callees
importtime: bool = False # Show import timing
first_token: bool = False # Track time to first token
save_path: Optional[str] = None # Path to save artifacts
output_format: str = "text" # text or json
stream: bool = False # Streaming mode
@dataclass
class TimingBreakdown:
"""Timing breakdown for profiling."""
cli_parse_ms: float = 0.0
imports_ms: float = 0.0
agent_construction_ms: float = 0.0
model_init_ms: float = 0.0
first_token_ms: float = 0.0
total_run_ms: float = 0.0
@dataclass
class FunctionStats:
"""Statistics for a single function."""
name: str
filename: str
lineno: int
calls: int
tottime: float # Time in function excluding subcalls
cumtime: float # Time in function including subcalls
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"filename": self.filename,
"lineno": self.lineno,
"calls": self.calls,
"tottime_ms": self.tottime * 1000,
"cumtime_ms": self.cumtime * 1000,
}
@dataclass
class FileStats:
"""Statistics aggregated by file."""
filename: str
total_time: float
functions: List[FunctionStats] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"filename": self.filename,
"total_time_ms": self.total_time * 1000,
"function_count": len(self.functions),
}
@dataclass
class ProfilerResult:
"""Result from a profiling run."""
prompt: str
response: str
timing: TimingBreakdown
function_stats: List[FunctionStats]
file_stats: List[FileStats]
callers: Dict[str, List[str]] = field(default_factory=dict)
callees: Dict[str, List[str]] = field(default_factory=dict)
import_times: List[Tuple[str, float]] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
def to_dict(self) -> Dict[str, Any]:
return {
"timestamp": self.timestamp,
"metadata": self.metadata,
"prompt": self.prompt[:200] + "..." if len(self.prompt) > 200 else self.prompt,
"response_preview": self.response[:200] + "..." if len(self.response) > 200 else self.response,
"timing": {
"cli_parse_ms": self.timing.cli_parse_ms,
"imports_ms": self.timing.imports_ms,
"agent_construction_ms": self.timing.agent_construction_ms,
"model_init_ms": self.timing.model_init_ms,
"first_token_ms": self.timing.first_token_ms,
"total_run_ms": self.timing.total_run_ms,
},
"top_functions": [f.to_dict() for f in self.function_stats[:20]],
"top_files": [f.to_dict() for f in self.file_stats[:10]],
}
class QueryProfiler:
"""
Profiler for query execution.
Uses cProfile for function-level profiling and optional sys.setprofile
for deep call tracing.
"""
def __init__(self, config: Optional[ProfilerConfig] = None):
self.config = config or ProfilerConfig()
self.profiler = cProfile.Profile()
self._first_token_time: Optional[float] = None
self._start_time: float = 0.0
self._deep_trace_data: Dict[str, Dict] = {}
def _on_first_token(self):
"""Called when first token is received (for streaming)."""
if self._first_token_time is None:
self._first_token_time = time.perf_counter()
def _deep_trace_callback(self, frame, event, arg):
"""Callback for sys.setprofile deep tracing."""
if event not in ('call', 'return'):
return
filename = frame.f_code.co_filename
funcname = frame.f_code.co_name
lineno = frame.f_lineno
key = f"{filename}:{funcname}:{lineno}"
if key not in self._deep_trace_data:
self._deep_trace_data[key] = {
'filename': filename,
'funcname': funcname,
'lineno': lineno,
'calls': 0,
'total_time': 0.0,
'start_times': [],
}
if event == 'call':
self._deep_trace_data[key]['calls'] += 1
self._deep_trace_data[key]['start_times'].append(time.perf_counter())
elif event == 'return' and self._deep_trace_data[key]['start_times']:
start = self._deep_trace_data[key]['start_times'].pop()
self._deep_trace_data[key]['total_time'] += time.perf_counter() - start
def profile_query(
self,
prompt: str,
model: Optional[str] = None,
stream: bool = False,
) -> ProfilerResult:
"""
Profile a query execution.
Args:
prompt: The prompt to execute
model: Optional model to use
stream: Whether to use streaming mode
Returns:
ProfilerResult with detailed timing and statistics
"""
timing = TimingBreakdown()
response = ""
# Metadata
metadata = self._collect_metadata(model)
# Time CLI parsing (simulated - actual parsing happens before this)
cli_start = time.perf_counter()
timing.cli_parse_ms = (time.perf_counter() - cli_start) * 1000
# Time imports
import_start = time.perf_counter()
try:
from praisonaiagents import Agent
except ImportError:
raise ImportError("praisonaiagents not installed")
timing.imports_ms = (time.perf_counter() - import_start) * 1000
# Time agent construction
construct_start = time.perf_counter()
agent_config = {
"name": "ProfilerAgent",
"role": "Assistant",
"goal": "Complete the task",
"verbose": False,
}
if model:
agent_config["llm"] = model
agent = Agent(**agent_config)
timing.agent_construction_ms = (time.perf_counter() - construct_start) * 1000
# Time model initialization (first call)
model_init_start = time.perf_counter()
timing.model_init_ms = (time.perf_counter() - model_init_start) * 1000
# Profile the actual execution
self._start_time = time.perf_counter()
self._first_token_time = None
if self.config.deep:
sys.setprofile(self._deep_trace_callback)
self.profiler.enable()
try:
if stream and hasattr(agent, '_start_stream'):
# Streaming mode with first token tracking
chunks = []
for chunk in agent._start_stream(prompt):
if not chunks:
self._on_first_token()
chunks.append(chunk)
response = ''.join(chunks)
else:
# Non-streaming mode
response = agent.start(prompt)
if response is None:
response = ""
finally:
self.profiler.disable()
if self.config.deep:
sys.setprofile(None)
end_time = time.perf_counter()
timing.total_run_ms = (end_time - self._start_time) * 1000
if self._first_token_time:
timing.first_token_ms = (self._first_token_time - self._start_time) * 1000
# Extract statistics
function_stats = self._extract_function_stats()
file_stats = self._extract_file_stats(function_stats)
callers, callees = self._extract_call_graph()
# Get import times if requested
import_times = []
if self.config.importtime:
import_times = self._get_import_times()
return ProfilerResult(
prompt=prompt,
response=str(response),
timing=timing,
function_stats=function_stats,
file_stats=file_stats,
callers=callers if self.config.show_callers else {},
callees=callees if self.config.show_callees else {},
import_times=import_times,
metadata=metadata,
)
def _collect_metadata(self, model: Optional[str]) -> Dict[str, Any]:
"""Collect system metadata."""
import platform
try:
from praisonai.version import __version__ as praisonai_version
except ImportError:
praisonai_version = "unknown"
return {
"python_version": platform.python_version(),
"platform": platform.platform(),
"praisonai_version": praisonai_version,
"model": model or "default",
"timestamp": datetime.utcnow().isoformat() + "Z",
}
def _extract_function_stats(self) -> List[FunctionStats]:
"""Extract function statistics from profiler."""
stats_stream = io.StringIO()
stats = pstats.Stats(self.profiler, stream=stats_stream)
# Sort by configured key
sort_key = pstats.SortKey.CUMULATIVE if self.config.sort_by == "cumulative" else pstats.SortKey.TIME
stats.sort_stats(sort_key)
function_stats = []
for (filename, lineno, funcname), (cc, nc, tt, ct, callers) in stats.stats.items():
function_stats.append(FunctionStats(
name=funcname,
filename=filename,
lineno=lineno,
calls=nc,
tottime=tt,
cumtime=ct,
))
# Sort and limit
if self.config.sort_by == "cumulative":
function_stats.sort(key=lambda x: x.cumtime, reverse=True)
else:
function_stats.sort(key=lambda x: x.tottime, reverse=True)
return function_stats[:self.config.limit]
def _extract_file_stats(self, function_stats: List[FunctionStats]) -> List[FileStats]:
"""Aggregate statistics by file."""
file_map: Dict[str, FileStats] = {}
for func in function_stats:
if func.filename not in file_map:
file_map[func.filename] = FileStats(
filename=func.filename,
total_time=0.0,
functions=[],
)
file_map[func.filename].total_time += func.cumtime
file_map[func.filename].functions.append(func)
file_stats = list(file_map.values())
file_stats.sort(key=lambda x: x.total_time, reverse=True)
return file_stats[:self.config.limit]
def _extract_call_graph(self) -> Tuple[Dict[str, List[str]], Dict[str, List[str]]]:
"""Extract caller/callee relationships."""
callers: Dict[str, List[str]] = {}
callees: Dict[str, List[str]] = {}
stats = pstats.Stats(self.profiler)
for (filename, lineno, funcname), (cc, nc, tt, ct, caller_dict) in stats.stats.items():
func_key = f"{funcname} ({os.path.basename(filename)}:{lineno})"
if caller_dict:
callers[func_key] = []
for (caller_file, caller_line, caller_name), _ in caller_dict.items():
caller_key = f"{caller_name} ({os.path.basename(caller_file)}:{caller_line})"
callers[func_key].append(caller_key)
if caller_key not in callees:
callees[caller_key] = []
callees[caller_key].append(func_key)
return callers, callees
def _get_import_times(self) -> List[Tuple[str, float]]:
"""Get import timing by running subprocess with -X importtime."""
try:
result = subprocess.run(
[sys.executable, "-X", "importtime", "-c", "import praisonaiagents"],
capture_output=True,
text=True,
timeout=30,
)
import_times = []
for line in result.stderr.split('\n'):
if 'import time:' in line:
# Parse: "import time: 123 | 456 | module_name"
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, cumulative / 1000000)) # Convert to seconds
# Sort by time descending
import_times.sort(key=lambda x: x[1], reverse=True)
return import_times[:20] # Top 20
except Exception:
return []
def save_artifacts(self, result: ProfilerResult, base_path: str):
"""Save profiling artifacts to files."""
base = Path(base_path)
base.parent.mkdir(parents=True, exist_ok=True)
# Save .prof binary
prof_path = str(base) + ".prof"
self.profiler.dump_stats(prof_path)
# Save .txt report
txt_path = str(base) + ".txt"
with open(txt_path, 'w') as f:
f.write(format_profile_report(result, self.config))
# Save .json if requested
if self.config.output_format == "json":
json_path = str(base) + ".json"
with open(json_path, 'w') as f:
json.dump(result.to_dict(), f, indent=2, default=str)
return prof_path, txt_path
def run_profiled_query(
prompt: str,
config: Optional[ProfilerConfig] = None,
model: Optional[str] = None,
) -> ProfilerResult:
"""
Run a profiled query.
Args:
prompt: The prompt to execute
config: Profiler configuration
model: Optional model to use
Returns:
ProfilerResult with detailed timing and statistics
"""
profiler = QueryProfiler(config)
return profiler.profile_query(prompt, model=model, stream=config.stream if config else False)
def format_profile_report(result: ProfilerResult, config: Optional[ProfilerConfig] = None) -> str:
"""
Format a profile result as a text report.
Args:
result: ProfilerResult to format
config: Optional profiler config
Returns:
Formatted text report
"""
config = config or ProfilerConfig()
lines = []
# Header
lines.append("=" * 70)
lines.append("PraisonAI Profile Report")
lines.append("=" * 70)
lines.append("")
# Metadata
lines.append("## System Information")
lines.append(f" Timestamp: {result.timestamp}")
lines.append(f" Python Version: {result.metadata.get('python_version', 'N/A')}")
lines.append(f" Platform: {result.metadata.get('platform', 'N/A')}")
lines.append(f" PraisonAI: {result.metadata.get('praisonai_version', 'N/A')}")
lines.append(f" Model: {result.metadata.get('model', 'N/A')}")
lines.append("")
# Timing breakdown
lines.append("## Timing Breakdown")
lines.append(f" CLI Parse: {result.timing.cli_parse_ms:>10.2f} ms")
lines.append(f" Imports: {result.timing.imports_ms:>10.2f} ms")
lines.append(f" Agent Construct: {result.timing.agent_construction_ms:>10.2f} ms")
lines.append(f" Model Init: {result.timing.model_init_ms:>10.2f} ms")
if result.timing.first_token_ms > 0:
lines.append(f" First Token: {result.timing.first_token_ms:>10.2f} ms")
lines.append(f" Total Run: {result.timing.total_run_ms:>10.2f} ms")
lines.append("")
# Per-file timing (if requested)
if config.show_files and result.file_stats:
lines.append("## Per-File Timing (Top Files)")
lines.append("-" * 70)
lines.append(f"{'File':<50} {'Time (ms)':>15}")
lines.append("-" * 70)
for fs in result.file_stats[:15]:
filename = os.path.basename(fs.filename)
if len(filename) > 48:
filename = "..." + filename[-45:]
lines.append(f"{filename:<50} {fs.total_time * 1000:>15.2f}")
lines.append("")
# Per-function timing
lines.append("## Per-Function Timing (Top Functions)")
lines.append("-" * 70)
sort_label = "Cumulative" if config.sort_by == "cumulative" else "Total"
lines.append(f"{'Function':<35} {'Calls':>8} {sort_label + ' (ms)':>12} {'Self (ms)':>12}")
lines.append("-" * 70)
for fs in result.function_stats[:config.limit]:
funcname = fs.name
if len(funcname) > 33:
funcname = funcname[:30] + "..."
lines.append(f"{funcname:<35} {fs.calls:>8} {fs.cumtime * 1000:>12.2f} {fs.tottime * 1000:>12.2f}")
lines.append("")
# Callers (if requested)
if config.show_callers and result.callers:
lines.append("## Callers (Who called each function)")
lines.append("-" * 70)
for func, caller_list in list(result.callers.items())[:10]:
lines.append(f" {func}:")
for caller in caller_list[:5]:
lines.append(f" <- {caller}")
lines.append("")
# Callees (if requested)
if config.show_callees and result.callees:
lines.append("## Callees (What each function called)")
lines.append("-" * 70)
for func, callee_list in list(result.callees.items())[:10]:
lines.append(f" {func}:")
for callee in callee_list[:5]:
lines.append(f" -> {callee}")
lines.append("")
# Import times (if requested)
if config.importtime and result.import_times:
lines.append("## Import Times (Top Modules)")
lines.append("-" * 70)
lines.append(f"{'Module':<50} {'Time (ms)':>15}")
lines.append("-" * 70)
for module, time_sec in result.import_times[:15]:
module_name = module
if len(module_name) > 48:
module_name = "..." + module_name[-45:]
lines.append(f"{module_name:<50} {time_sec * 1000:>15.2f}")
lines.append("")
# Response preview
lines.append("## Response Preview")
lines.append("-" * 70)
preview = result.response[:500] + "..." if len(result.response) > 500 else result.response
lines.append(preview)
lines.append("")
lines.append("=" * 70)
return "\n".join(lines)
@@ -0,0 +1,652 @@
"""
Performance Optimizations for PraisonAI CLI.
Implements Tier 0/1/2 optimizations:
- Tier 0: Lazy imports, provider caching, CLI startup optimization
- Tier 1: Connection pooling, prewarm hooks (opt-in)
- Tier 2: Lite mode, async init, perf snapshot
All optimizations are safe-by-default and opt-in where behavior changes.
Multi-agent safe: no global mutable state that affects concurrent runs.
"""
import os
import json
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from functools import lru_cache
# =============================================================================
# TIER 0: Safe Fast Wins (No behavior change)
# =============================================================================
class ProviderCache:
"""
Thread-safe cache for provider/model resolution.
Caches resolved provider configurations to avoid repeated lookups.
Per-process cache with clear invalidation rules.
Multi-agent safe: uses thread-local storage for mutable state.
"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._cache = {}
cls._instance._cache_lock = threading.Lock()
cls._instance._hits = 0
cls._instance._misses = 0
return cls._instance
def get(self, key: str) -> Optional[Any]:
"""Get cached value (thread-safe)."""
with self._cache_lock:
if key in self._cache:
self._hits += 1
return self._cache[key]
self._misses += 1
return None
def set(self, key: str, value: Any, ttl_seconds: int = 300) -> None:
"""Set cached value with TTL (thread-safe)."""
with self._cache_lock:
self._cache[key] = {
'value': value,
'expires': time.time() + ttl_seconds,
}
def invalidate(self, key: Optional[str] = None) -> None:
"""Invalidate cache entry or entire cache."""
with self._cache_lock:
if key:
self._cache.pop(key, None)
else:
self._cache.clear()
def get_stats(self) -> Dict[str, int]:
"""Get cache statistics."""
with self._cache_lock:
return {
'hits': self._hits,
'misses': self._misses,
'size': len(self._cache),
}
def cleanup_expired(self) -> int:
"""Remove expired entries. Returns count of removed entries."""
now = time.time()
removed = 0
with self._cache_lock:
expired_keys = [
k for k, v in self._cache.items()
if isinstance(v, dict) and v.get('expires', float('inf')) < now
]
for k in expired_keys:
del self._cache[k]
removed += 1
return removed
# Global provider cache instance (singleton, thread-safe)
_provider_cache = None
def get_provider_cache() -> ProviderCache:
"""Get the global provider cache instance."""
global _provider_cache
if _provider_cache is None:
_provider_cache = ProviderCache()
return _provider_cache
@lru_cache(maxsize=32)
def resolve_model_provider(model: str) -> Tuple[str, str]:
"""
Resolve model string to (provider, model_name) tuple.
Cached using LRU cache for repeated lookups.
"""
if '/' in model:
parts = model.split('/', 1)
return parts[0], parts[1]
# Default provider mappings
if model.startswith('gpt-') or model.startswith('o1-') or model.startswith('o3-'):
return 'openai', model
elif model.startswith('claude-'):
return 'anthropic', model
elif model.startswith('gemini-'):
return 'google', model
elif model.startswith('llama') or model.startswith('mistral'):
return 'ollama', model
return 'openai', model # Default to OpenAI
class LazyImporter:
"""
Lazy importer for heavy modules.
Defers import until first use, reducing CLI startup time.
Thread-safe and caches imported modules.
"""
_imports: Dict[str, Any] = {}
_lock = threading.Lock()
@classmethod
def get(cls, module_path: str, attr: Optional[str] = None) -> Any:
"""
Lazily import a module or attribute.
Args:
module_path: Full module path (e.g., 'openai.OpenAI')
attr: Optional attribute to get from module
Returns:
Imported module or attribute
"""
cache_key = f"{module_path}.{attr}" if attr else module_path
with cls._lock:
if cache_key in cls._imports:
return cls._imports[cache_key]
# Import outside lock to avoid deadlocks
import importlib
if attr:
module = importlib.import_module(module_path)
result = getattr(module, attr)
else:
result = importlib.import_module(module_path)
with cls._lock:
cls._imports[cache_key] = result
return result
@classmethod
def clear(cls) -> None:
"""Clear import cache."""
with cls._lock:
cls._imports.clear()
# =============================================================================
# TIER 1: Medium Effort (Guarded/opt-in)
# =============================================================================
class ClientPool:
"""
Connection pool for API clients.
Reuses client instances for repeated calls to the same provider.
Multi-agent safe: clients are isolated by key.
Does not leak API keys between different configurations.
"""
def __init__(self, max_size: int = 10):
self._pool: Dict[str, Any] = {}
self._lock = threading.Lock()
self._max_size = max_size
self._access_times: Dict[str, float] = {}
def get_or_create(
self,
key: str,
factory: Callable[[], Any],
) -> Any:
"""
Get existing client or create new one.
Args:
key: Unique key for this client configuration
factory: Function to create new client if needed
Returns:
Client instance
"""
with self._lock:
if key in self._pool:
self._access_times[key] = time.time()
return self._pool[key]
# Evict oldest if at capacity
if len(self._pool) >= self._max_size:
oldest_key = min(self._access_times, key=self._access_times.get)
del self._pool[oldest_key]
del self._access_times[oldest_key]
# Create outside lock
client = factory()
with self._lock:
self._pool[key] = client
self._access_times[key] = time.time()
return client
def remove(self, key: str) -> None:
"""Remove client from pool."""
with self._lock:
self._pool.pop(key, None)
self._access_times.pop(key, None)
def clear(self) -> None:
"""Clear all clients from pool."""
with self._lock:
self._pool.clear()
self._access_times.clear()
def size(self) -> int:
"""Get current pool size."""
with self._lock:
return len(self._pool)
# Global client pool (opt-in usage)
_client_pool: Optional[ClientPool] = None
def get_client_pool() -> ClientPool:
"""Get the global client pool instance."""
global _client_pool
if _client_pool is None:
_client_pool = ClientPool()
return _client_pool
class PrewarmManager:
"""
Manager for pre-warming provider connections.
OPT-IN ONLY: Must be explicitly enabled.
Pre-initializes provider clients in background to reduce first-call latency.
"""
_enabled = False
_prewarmed: Dict[str, bool] = {}
_lock = threading.Lock()
_background_thread: Optional[threading.Thread] = None
@classmethod
def enable(cls) -> None:
"""Enable pre-warming (opt-in)."""
cls._enabled = True
@classmethod
def disable(cls) -> None:
"""Disable pre-warming."""
cls._enabled = False
@classmethod
def is_enabled(cls) -> bool:
"""Check if pre-warming is enabled."""
return cls._enabled
@classmethod
def prewarm_provider(cls, provider: str, api_key: Optional[str] = None) -> None:
"""
Pre-warm a provider connection in background.
Only runs if pre-warming is enabled.
"""
if not cls._enabled:
return
with cls._lock:
if provider in cls._prewarmed:
return
cls._prewarmed[provider] = False
def _prewarm():
try:
if provider == 'openai':
# Just import and create client - don't make API call
from openai import OpenAI
key = api_key or os.environ.get('OPENAI_API_KEY')
if key:
_ = OpenAI(api_key=key)
elif provider == 'anthropic':
from anthropic import Anthropic
key = api_key or os.environ.get('ANTHROPIC_API_KEY')
if key:
_ = Anthropic(api_key=key)
with cls._lock:
cls._prewarmed[provider] = True
except Exception:
pass # Silently fail - pre-warming is optional
# Run in background thread
thread = threading.Thread(target=_prewarm, daemon=True)
thread.start()
@classmethod
def is_prewarmed(cls, provider: str) -> bool:
"""Check if provider is pre-warmed."""
with cls._lock:
return cls._prewarmed.get(provider, False)
@classmethod
def reset(cls) -> None:
"""Reset pre-warm state."""
with cls._lock:
cls._prewarmed.clear()
cls._enabled = False
# =============================================================================
# TIER 2: Architectural (Safe and non-breaking)
# =============================================================================
@dataclass
class LiteModeConfig:
"""
Configuration for lite runtime mode.
OPT-IN ONLY: Defaults to OFF.
Avoids expensive type/model loading when not required.
"""
enabled: bool = False
skip_type_validation: bool = False
skip_model_validation: bool = False
minimal_imports: bool = False
@classmethod
def from_env(cls) -> 'LiteModeConfig':
"""Create config from environment variables."""
return cls(
enabled=os.environ.get('PRAISONAI_LITE_MODE', '').lower() in ('1', 'true', 'yes'),
skip_type_validation=os.environ.get('PRAISONAI_SKIP_TYPE_VALIDATION', '').lower() in ('1', 'true'),
skip_model_validation=os.environ.get('PRAISONAI_SKIP_MODEL_VALIDATION', '').lower() in ('1', 'true'),
minimal_imports=os.environ.get('PRAISONAI_MINIMAL_IMPORTS', '').lower() in ('1', 'true'),
)
# Global lite mode config
_lite_mode_config: Optional[LiteModeConfig] = None
def get_lite_mode_config() -> LiteModeConfig:
"""Get lite mode configuration."""
global _lite_mode_config
if _lite_mode_config is None:
_lite_mode_config = LiteModeConfig.from_env()
return _lite_mode_config
def is_lite_mode() -> bool:
"""Check if lite mode is enabled."""
return get_lite_mode_config().enabled
@dataclass
class PerfSnapshot:
"""
Performance snapshot for baseline comparison.
Records timing data that can be compared against later runs.
"""
timestamp: str
name: str
startup_cold_ms: float
startup_warm_ms: float
import_time_ms: float
query_time_ms: float
first_token_ms: float
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
'timestamp': self.timestamp,
'name': self.name,
'startup_cold_ms': self.startup_cold_ms,
'startup_warm_ms': self.startup_warm_ms,
'import_time_ms': self.import_time_ms,
'query_time_ms': self.query_time_ms,
'first_token_ms': self.first_token_ms,
'metadata': self.metadata,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'PerfSnapshot':
return cls(
timestamp=data['timestamp'],
name=data['name'],
startup_cold_ms=data['startup_cold_ms'],
startup_warm_ms=data['startup_warm_ms'],
import_time_ms=data['import_time_ms'],
query_time_ms=data['query_time_ms'],
first_token_ms=data.get('first_token_ms', 0.0),
metadata=data.get('metadata', {}),
)
@dataclass
class PerfComparison:
"""Comparison between two performance snapshots."""
baseline: PerfSnapshot
current: PerfSnapshot
@property
def startup_cold_diff_ms(self) -> float:
return self.current.startup_cold_ms - self.baseline.startup_cold_ms
@property
def startup_cold_diff_pct(self) -> float:
if self.baseline.startup_cold_ms == 0:
return 0.0
return (self.startup_cold_diff_ms / self.baseline.startup_cold_ms) * 100
@property
def import_time_diff_ms(self) -> float:
return self.current.import_time_ms - self.baseline.import_time_ms
@property
def import_time_diff_pct(self) -> float:
if self.baseline.import_time_ms == 0:
return 0.0
return (self.import_time_diff_ms / self.baseline.import_time_ms) * 100
@property
def query_time_diff_ms(self) -> float:
return self.current.query_time_ms - self.baseline.query_time_ms
@property
def query_time_diff_pct(self) -> float:
if self.baseline.query_time_ms == 0:
return 0.0
return (self.query_time_diff_ms / self.baseline.query_time_ms) * 100
def is_regression(self, threshold_pct: float = 10.0) -> bool:
"""Check if there's a performance regression above threshold."""
return (
self.startup_cold_diff_pct > threshold_pct or
self.import_time_diff_pct > threshold_pct or
self.query_time_diff_pct > threshold_pct
)
def to_dict(self) -> Dict[str, Any]:
return {
'baseline': self.baseline.to_dict(),
'current': self.current.to_dict(),
'diffs': {
'startup_cold_ms': self.startup_cold_diff_ms,
'startup_cold_pct': self.startup_cold_diff_pct,
'import_time_ms': self.import_time_diff_ms,
'import_time_pct': self.import_time_diff_pct,
'query_time_ms': self.query_time_diff_ms,
'query_time_pct': self.query_time_diff_pct,
},
'is_regression': self.is_regression(),
}
class PerfSnapshotManager:
"""
Manager for performance snapshots.
Stores snapshots locally for baseline comparison.
OPT-IN: User must explicitly invoke snapshot commands.
"""
DEFAULT_DIR = Path.home() / '.praisonai' / 'perf_snapshots'
def __init__(self, snapshot_dir: Optional[Path] = None):
self.snapshot_dir = snapshot_dir or self.DEFAULT_DIR
self.snapshot_dir.mkdir(parents=True, exist_ok=True)
def save(self, snapshot: PerfSnapshot) -> Path:
"""Save a snapshot to disk."""
filename = f"{snapshot.name}_{snapshot.timestamp.replace(':', '-').replace('.', '-')}.json"
path = self.snapshot_dir / filename
with open(path, 'w') as f:
json.dump(snapshot.to_dict(), f, indent=2)
return path
def load(self, name: str) -> Optional[PerfSnapshot]:
"""Load the most recent snapshot with given name."""
pattern = f"{name}_*.json"
files = sorted(self.snapshot_dir.glob(pattern), reverse=True)
if not files:
return None
with open(files[0]) as f:
data = json.load(f)
return PerfSnapshot.from_dict(data)
def load_baseline(self, name: str = 'baseline') -> Optional[PerfSnapshot]:
"""Load the baseline snapshot."""
return self.load(name)
def save_baseline(self, snapshot: PerfSnapshot) -> Path:
"""Save as baseline (overwrites previous baseline)."""
snapshot.name = 'baseline'
# Remove old baselines
for f in self.snapshot_dir.glob('baseline_*.json'):
f.unlink()
return self.save(snapshot)
def compare(self, current: PerfSnapshot, baseline_name: str = 'baseline') -> Optional[PerfComparison]:
"""Compare current snapshot against baseline."""
baseline = self.load(baseline_name)
if not baseline:
return None
return PerfComparison(baseline=baseline, current=current)
def list_snapshots(self) -> List[str]:
"""List all saved snapshot names."""
files = self.snapshot_dir.glob('*.json')
names = set()
for f in files:
# Extract name from filename (before first underscore)
name = f.stem.rsplit('_', 1)[0] if '_' in f.stem else f.stem
names.add(name)
return sorted(names)
# =============================================================================
# Utility Functions
# =============================================================================
def create_snapshot_from_suite(suite_result: Any, name: str = 'current') -> PerfSnapshot:
"""
Create a PerfSnapshot from a SuiteResult.
Args:
suite_result: SuiteResult from profile suite
name: Name for the snapshot
Returns:
PerfSnapshot instance
"""
# Get average query time from scenarios
query_times = []
first_token_times = []
for scenario in suite_result.scenarios:
query_times.extend(scenario.total_times)
first_token_times.extend(scenario.first_token_times)
avg_query_time = sum(query_times) / len(query_times) if query_times else 0.0
avg_first_token = sum(first_token_times) / len(first_token_times) if first_token_times else 0.0
# Get import time from analysis
import_time = 0.0
if suite_result.import_analysis:
import_time = suite_result.import_analysis[0].get('cumulative_ms', 0.0)
return PerfSnapshot(
timestamp=suite_result.timestamp,
name=name,
startup_cold_ms=suite_result.startup_cold_ms,
startup_warm_ms=suite_result.startup_warm_ms,
import_time_ms=import_time,
query_time_ms=avg_query_time,
first_token_ms=avg_first_token,
metadata=suite_result.metadata,
)
def format_comparison_report(comparison: PerfComparison) -> str:
"""Format a comparison report as text."""
lines = []
lines.append("=" * 70)
lines.append("Performance Comparison Report")
lines.append("=" * 70)
lines.append("")
lines.append(f"Baseline: {comparison.baseline.name} ({comparison.baseline.timestamp})")
lines.append(f"Current: {comparison.current.name} ({comparison.current.timestamp})")
lines.append("")
lines.append("-" * 70)
lines.append(f"{'Metric':<25} {'Baseline':>12} {'Current':>12} {'Diff':>12} {'%':>8}")
lines.append("-" * 70)
def format_row(name: str, baseline: float, current: float, diff: float, pct: float) -> str:
sign = '+' if diff > 0 else ''
pct_sign = '+' if pct > 0 else ''
return f"{name:<25} {baseline:>12.2f} {current:>12.2f} {sign}{diff:>11.2f} {pct_sign}{pct:>7.1f}%"
lines.append(format_row(
"Startup Cold (ms)",
comparison.baseline.startup_cold_ms,
comparison.current.startup_cold_ms,
comparison.startup_cold_diff_ms,
comparison.startup_cold_diff_pct,
))
lines.append(format_row(
"Import Time (ms)",
comparison.baseline.import_time_ms,
comparison.current.import_time_ms,
comparison.import_time_diff_ms,
comparison.import_time_diff_pct,
))
lines.append(format_row(
"Query Time (ms)",
comparison.baseline.query_time_ms,
comparison.current.query_time_ms,
comparison.query_time_diff_ms,
comparison.query_time_diff_pct,
))
lines.append("-" * 70)
lines.append("")
if comparison.is_regression():
lines.append("⚠️ REGRESSION DETECTED (>10% slower)")
else:
lines.append("✅ No significant regression")
lines.append("=" * 70)
return "\n".join(lines)
@@ -0,0 +1,386 @@
"""
Profile Suite Runner for PraisonAI CLI.
Runs a matrix of profiling scenarios and produces aggregated reports.
Supports cold/warm start, streaming/non-streaming, and multiple iterations.
"""
import json
import statistics
import subprocess
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from .core import ProfilerConfig, ProfilerResult, QueryProfiler
@dataclass
class ScenarioConfig:
"""Configuration for a single profiling scenario."""
name: str
prompt: str
model: Optional[str] = None
stream: bool = False
iterations: int = 3
warmup: int = 1
show_files: bool = True
limit: int = 20
@dataclass
class ScenarioResult:
"""Result from running a scenario."""
name: str
config: ScenarioConfig
results: List[ProfilerResult] = field(default_factory=list)
@property
def import_times(self) -> List[float]:
return [r.timing.imports_ms for r in self.results]
@property
def total_times(self) -> List[float]:
return [r.timing.total_run_ms for r in self.results]
@property
def first_token_times(self) -> List[float]:
return [r.timing.first_token_ms for r in self.results if r.timing.first_token_ms > 0]
def get_stats(self, values: List[float]) -> Dict[str, float]:
if not values:
return {"mean": 0, "min": 0, "max": 0, "stdev": 0, "median": 0}
return {
"mean": statistics.mean(values),
"min": min(values),
"max": max(values),
"stdev": statistics.stdev(values) if len(values) > 1 else 0,
"median": statistics.median(values),
}
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"config": {
"prompt": self.config.prompt[:50] + "..." if len(self.config.prompt) > 50 else self.config.prompt,
"model": self.config.model or "default",
"stream": self.config.stream,
"iterations": self.config.iterations,
},
"import_time_stats": self.get_stats(self.import_times),
"total_time_stats": self.get_stats(self.total_times),
"first_token_stats": self.get_stats(self.first_token_times) if self.first_token_times else None,
"raw_import_times_ms": self.import_times,
"raw_total_times_ms": self.total_times,
}
@dataclass
class SuiteResult:
"""Result from running a full profiling suite."""
scenarios: List[ScenarioResult] = field(default_factory=list)
startup_cold_ms: float = 0.0
startup_warm_ms: float = 0.0
import_analysis: List[Dict[str, Any]] = field(default_factory=list)
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"timestamp": self.timestamp,
"metadata": self.metadata,
"startup": {
"cold_ms": self.startup_cold_ms,
"warm_ms": self.startup_warm_ms,
},
"import_analysis": self.import_analysis[:20],
"scenarios": [s.to_dict() for s in self.scenarios],
}
class ProfileSuiteRunner:
"""
Runs a suite of profiling scenarios.
Collects comprehensive timing data across multiple runs and scenarios.
"""
DEFAULT_SCENARIOS = [
ScenarioConfig(name="simple_non_stream", prompt="hi", stream=False, iterations=3),
ScenarioConfig(name="simple_stream", prompt="hi", stream=True, iterations=3),
ScenarioConfig(name="medium_non_stream", prompt="Explain what Python is in 2 sentences", stream=False, iterations=2),
ScenarioConfig(name="medium_stream", prompt="Explain what Python is in 2 sentences", stream=True, iterations=2),
]
def __init__(
self,
scenarios: Optional[List[ScenarioConfig]] = None,
output_dir: Optional[str] = None,
verbose: bool = False,
):
self.scenarios = scenarios or self.DEFAULT_SCENARIOS
self.output_dir = Path(output_dir) if output_dir else Path("/tmp/praisonai_profile_suite")
self.verbose = verbose
def run(self) -> SuiteResult:
"""Run the full profiling suite."""
result = SuiteResult()
result.metadata = self._collect_metadata()
# Create output directory
self.output_dir.mkdir(parents=True, exist_ok=True)
if self.verbose:
print("🔬 Starting Profile Suite...")
print(f" Output: {self.output_dir}")
# 1. Measure startup times
if self.verbose:
print("\n📊 Measuring startup times...")
result.startup_cold_ms, result.startup_warm_ms = self._measure_startup()
if self.verbose:
print(f" Cold: {result.startup_cold_ms:.2f}ms, Warm: {result.startup_warm_ms:.2f}ms")
# 2. Analyze imports
if self.verbose:
print("\n📊 Analyzing imports...")
result.import_analysis = self._analyze_imports()
if self.verbose and result.import_analysis:
print(f" Top import: {result.import_analysis[0]['module']} ({result.import_analysis[0]['cumulative_ms']:.2f}ms)")
# 3. Run scenarios
for scenario in self.scenarios:
if self.verbose:
print(f"\n📊 Running scenario: {scenario.name}")
scenario_result = self._run_scenario(scenario)
result.scenarios.append(scenario_result)
if self.verbose:
stats = scenario_result.get_stats(scenario_result.total_times)
print(f" Total time: {stats['mean']:.2f}ms (±{stats['stdev']:.2f}ms)")
# 4. Save results
self._save_results(result)
if self.verbose:
print(f"\n✅ Suite complete. Results saved to {self.output_dir}")
return result
def _collect_metadata(self) -> Dict[str, Any]:
"""Collect system metadata."""
import platform
try:
from praisonai.version import __version__ as praisonai_version
except ImportError:
praisonai_version = "unknown"
return {
"python_version": platform.python_version(),
"platform": platform.platform(),
"praisonai_version": praisonai_version,
"timestamp": datetime.utcnow().isoformat() + "Z",
}
def _measure_startup(self) -> tuple:
"""Measure cold and warm startup times."""
# Cold start
start = time.perf_counter()
subprocess.run(
[sys.executable, "-c", "import praisonai; import praisonai.cli"],
capture_output=True,
text=True,
)
cold_ms = (time.perf_counter() - start) * 1000
# Warm start
start = time.perf_counter()
subprocess.run(
[sys.executable, "-c", "import praisonai; import praisonai.cli"],
capture_output=True,
text=True,
)
warm_ms = (time.perf_counter() - start) * 1000
return cold_ms, warm_ms
def _analyze_imports(self) -> List[Dict[str, Any]]:
"""Analyze import times."""
import re
try:
result = subprocess.run(
[sys.executable, "-X", "importtime", "-c", "import praisonaiagents"],
capture_output=True,
text=True,
timeout=60,
)
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": module,
"self_us": self_time,
"cumulative_us": cumulative,
"self_ms": self_time / 1000,
"cumulative_ms": cumulative / 1000,
})
import_times.sort(key=lambda x: x["cumulative_us"], reverse=True)
return import_times[:30]
except Exception:
return []
def _run_scenario(self, scenario: ScenarioConfig) -> ScenarioResult:
"""Run a single scenario with multiple iterations."""
result = ScenarioResult(name=scenario.name, config=scenario)
config = ProfilerConfig(
stream=scenario.stream,
show_files=scenario.show_files,
limit=scenario.limit,
first_token=scenario.stream,
)
# Warmup runs (discarded)
for _ in range(scenario.warmup):
try:
profiler = QueryProfiler(config)
profiler.profile_query(scenario.prompt, model=scenario.model, stream=scenario.stream)
except Exception:
pass
# Actual runs
for i in range(scenario.iterations):
try:
profiler = QueryProfiler(config)
profile_result = profiler.profile_query(
scenario.prompt,
model=scenario.model,
stream=scenario.stream,
)
result.results.append(profile_result)
if self.verbose:
print(f" Iteration {i+1}: {profile_result.timing.total_run_ms:.2f}ms")
except Exception as e:
if self.verbose:
print(f" Iteration {i+1}: ERROR - {e}")
return result
def _save_results(self, result: SuiteResult):
"""Save results to files."""
# Save JSON summary
json_path = self.output_dir / "suite_results.json"
with open(json_path, 'w') as f:
json.dump(result.to_dict(), f, indent=2, default=str)
# Save human-readable report
report_path = self.output_dir / "suite_report.txt"
with open(report_path, 'w') as f:
f.write(self._format_report(result))
def _format_report(self, result: SuiteResult) -> str:
"""Format a human-readable report."""
lines = []
lines.append("=" * 70)
lines.append("PraisonAI Profile Suite Report")
lines.append("=" * 70)
lines.append("")
# Metadata
lines.append("## System Information")
lines.append(f" Timestamp: {result.timestamp}")
lines.append(f" Python Version: {result.metadata.get('python_version', 'N/A')}")
lines.append(f" Platform: {result.metadata.get('platform', 'N/A')}")
lines.append(f" PraisonAI: {result.metadata.get('praisonai_version', 'N/A')}")
lines.append("")
# Startup times
lines.append("## Startup Times")
lines.append(f" Cold Start: {result.startup_cold_ms:>10.2f} ms")
lines.append(f" Warm Start: {result.startup_warm_ms:>10.2f} ms")
lines.append("")
# Import analysis
if result.import_analysis:
lines.append("## Top Import Times")
lines.append("-" * 70)
lines.append(f"{'Module':<45} {'Self (ms)':>10} {'Cumul (ms)':>12}")
lines.append("-" * 70)
for imp in result.import_analysis[:15]:
module = imp["module"]
if len(module) > 43:
module = "..." + module[-40:]
lines.append(f"{module:<45} {imp['self_ms']:>10.2f} {imp['cumulative_ms']:>12.2f}")
lines.append("")
# Scenario results
lines.append("## Scenario Results")
lines.append("-" * 70)
lines.append(f"{'Scenario':<25} {'Stream':>8} {'Iters':>6} {'Mean (ms)':>12} {'StdDev':>10}")
lines.append("-" * 70)
for scenario in result.scenarios:
stats = scenario.get_stats(scenario.total_times)
stream_str = "Yes" if scenario.config.stream else "No"
lines.append(
f"{scenario.name:<25} {stream_str:>8} {len(scenario.results):>6} "
f"{stats['mean']:>12.2f} {stats['stdev']:>10.2f}"
)
lines.append("")
# Summary statistics
all_import_times = []
all_total_times = []
for scenario in result.scenarios:
all_import_times.extend(scenario.import_times)
all_total_times.extend(scenario.total_times)
if all_import_times:
lines.append("## Overall Statistics")
lines.append(f" Import Time (mean): {statistics.mean(all_import_times):>10.2f} ms")
lines.append(f" Total Time (mean): {statistics.mean(all_total_times):>10.2f} ms")
if len(all_total_times) > 1:
lines.append(f" Total Time (stdev): {statistics.stdev(all_total_times):>10.2f} ms")
lines.append("")
lines.append("=" * 70)
return "\n".join(lines)
def run_profile_suite(
output_dir: Optional[str] = None,
scenarios: Optional[List[ScenarioConfig]] = None,
verbose: bool = True,
) -> SuiteResult:
"""
Run a profile suite and return results.
Args:
output_dir: Directory to save results
scenarios: Custom scenarios to run
verbose: Print progress
Returns:
SuiteResult with all profiling data
"""
runner = ProfileSuiteRunner(
scenarios=scenarios,
output_dir=output_dir,
verbose=verbose,
)
return runner.run()
@@ -0,0 +1,73 @@
"""
Queue System for PraisonAI TUI.
Provides multi-run, multi-agent queueing with priorities, cancel/retry,
streaming output, and persistence.
Lazy-loaded to avoid import overhead when not used.
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .models import QueuedRun, RunState, RunPriority, QueueConfig
from .scheduler import QueueScheduler
from .worker import WorkerPool
from .persistence import QueuePersistence
_lazy_cache = {}
def __getattr__(name: str):
"""Lazy load queue components."""
global _lazy_cache
if name in _lazy_cache:
return _lazy_cache[name]
if name == "QueuedRun":
from .models import QueuedRun
_lazy_cache[name] = QueuedRun
return QueuedRun
elif name == "RunState":
from .models import RunState
_lazy_cache[name] = RunState
return RunState
elif name == "RunPriority":
from .models import RunPriority
_lazy_cache[name] = RunPriority
return RunPriority
elif name == "QueueConfig":
from .models import QueueConfig
_lazy_cache[name] = QueueConfig
return QueueConfig
elif name == "QueueScheduler":
from .scheduler import QueueScheduler
_lazy_cache[name] = QueueScheduler
return QueueScheduler
elif name == "WorkerPool":
from .worker import WorkerPool
_lazy_cache[name] = WorkerPool
return WorkerPool
elif name == "QueuePersistence":
from .persistence import QueuePersistence
_lazy_cache[name] = QueuePersistence
return QueuePersistence
elif name == "QueueManager":
from .manager import QueueManager
_lazy_cache[name] = QueueManager
return QueueManager
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"QueuedRun",
"RunState",
"RunPriority",
"QueueConfig",
"QueueScheduler",
"WorkerPool",
"QueuePersistence",
"QueueManager",
]
@@ -0,0 +1,431 @@
"""
Queue Manager for PraisonAI.
High-level interface that coordinates scheduler, workers, and persistence.
"""
import asyncio
import logging
import os
import time
from typing import Any, Callable, Coroutine, Dict, List, Optional
from .models import QueuedRun, RunState, RunPriority, QueueConfig, QueueStats, QueueEvent
from .scheduler import QueueScheduler, QueueFullError
from .worker import WorkerPool
from .persistence import QueuePersistence
logger = logging.getLogger(__name__)
class QueueManager:
"""
High-level queue manager that coordinates all queue components.
Provides a unified interface for:
- Submitting and managing runs
- Starting/stopping workers
- Persistence and crash recovery
- Event handling
"""
# Runtime tools registry - stores tools that can't be serialized to JSON
# Key: run_id or "default", Value: list of tool callables
_tools_registry: Dict[str, list] = {}
def __init__(
self,
config: Optional[QueueConfig] = None,
on_output: Optional[Callable[[str, str], Coroutine[Any, Any, None]]] = None,
on_complete: Optional[Callable[[str, QueuedRun], Coroutine[Any, Any, None]]] = None,
on_error: Optional[Callable[[str, Exception], Coroutine[Any, Any, None]]] = None,
on_event: Optional[Callable[[QueueEvent], Coroutine[Any, Any, None]]] = None,
default_tools: Optional[list] = None,
):
"""
Initialize queue manager.
Args:
config: Queue configuration.
on_output: Callback for streaming output.
on_complete: Callback when run completes.
on_error: Callback when run fails.
on_event: Callback for queue events.
default_tools: Default tools to use for all runs (stored in runtime registry).
"""
self.config = config or QueueConfig()
# Initialize components
self.scheduler = QueueScheduler(self.config)
self.persistence = QueuePersistence(self.config.db_path) if self.config.enable_persistence else None
# Store callbacks
self._on_output = on_output
self._on_complete = on_complete
self._on_error = on_error
self._on_event = on_event
# Worker pool (created on start)
self.workers: Optional[WorkerPool] = None
# Autosave task
self._autosave_task: Optional[asyncio.Task] = None
# Running state
self._running = False
# Session tracking
self._current_session_id: Optional[str] = None
# Store default tools in registry
if default_tools:
QueueManager._tools_registry["default"] = default_tools
async def start(self, recover: bool = True) -> None:
"""
Start the queue manager.
Args:
recover: If True, recover pending runs from persistence.
"""
if self._running:
return
logger.info("Starting queue manager")
# Initialize persistence
if self.persistence:
self.persistence.initialize()
if recover:
await self._recover_runs()
# Create and start worker pool
self.workers = WorkerPool(
scheduler=self.scheduler,
on_output=self._on_output,
on_complete=self._wrap_complete_callback(),
on_error=self._wrap_error_callback(),
on_event=self._on_event,
max_workers=self.config.max_concurrent_global,
poll_interval=self.config.worker_poll_interval,
stream_buffer_size=self.config.stream_buffer_size,
)
await self.workers.start()
# Start autosave
if self.persistence and self.config.autosave_interval_seconds > 0:
self._autosave_task = asyncio.create_task(
self._autosave_loop(),
name="queue-autosave"
)
self._running = True
logger.info("Queue manager started")
async def stop(self, timeout: float = 10.0) -> None:
"""
Stop the queue manager gracefully.
Args:
timeout: Seconds to wait for workers to finish.
"""
if not self._running:
return
logger.info("Stopping queue manager")
# Stop autosave
if self._autosave_task:
self._autosave_task.cancel()
try:
await self._autosave_task
except asyncio.CancelledError:
pass
self._autosave_task = None
# Stop workers
if self.workers:
await self.workers.stop(timeout=timeout)
self.workers = None
# Final save
if self.persistence:
await self._save_all_runs()
self.persistence.close()
self._running = False
logger.info("Queue manager stopped")
async def submit(
self,
input_content: str,
agent_name: str = "Assistant",
priority: RunPriority = RunPriority.NORMAL,
session_id: Optional[str] = None,
workspace: Optional[str] = None,
config: Optional[Dict[str, Any]] = None,
run_id: Optional[str] = None,
chat_history: Optional[List[Dict[str, str]]] = None,
) -> str:
"""
Submit a new run to the queue.
Args:
input_content: The input/prompt for the agent.
agent_name: Name of the agent to use.
priority: Run priority.
session_id: Session ID for grouping.
workspace: Workspace path.
config: Additional configuration.
run_id: Optional specific run ID.
chat_history: Previous chat messages for context continuity.
Returns:
The run ID.
"""
run = QueuedRun(
agent_name=agent_name,
input_content=input_content,
priority=priority,
session_id=session_id or self._current_session_id,
workspace=workspace or os.getcwd(),
config=config or {},
max_retries=self.config.default_max_retries,
chat_history=chat_history or [],
)
if run_id:
run.run_id = run_id
# Submit to scheduler
result_id = await self.scheduler.submit(run)
# Persist
if self.persistence:
self.persistence.save_run(run)
return result_id
async def cancel(self, run_id: str) -> bool:
"""Cancel a run."""
result = await self.scheduler.cancel(run_id)
if result and self.persistence:
run = self.scheduler.get_run(run_id)
if run:
self.persistence.save_run(run)
return result
async def retry(self, run_id: str) -> Optional[str]:
"""Retry a failed run."""
new_id = await self.scheduler.retry(run_id)
if new_id and self.persistence:
run = self.scheduler.get_run(new_id)
if run:
self.persistence.save_run(run)
return new_id
async def pause(self, run_id: str) -> bool:
"""Pause a running run."""
return await self.scheduler.pause(run_id)
async def resume(self, run_id: str) -> bool:
"""Resume a paused run."""
return await self.scheduler.resume(run_id)
async def update_input(self, run_id: str, new_input: str) -> bool:
"""Update the input content of a queued run."""
return await self.scheduler.update_input(run_id, new_input)
def get_run(self, run_id: str) -> Optional[QueuedRun]:
"""Get a run by ID."""
return self.scheduler.get_run(run_id)
def get_queued(self) -> List[QueuedRun]:
"""Get all queued runs."""
return self.scheduler.get_queued()
def get_running(self) -> List[QueuedRun]:
"""Get all running runs."""
return self.scheduler.get_running()
def list_runs(
self,
state: Optional[RunState] = None,
session_id: Optional[str] = None,
limit: int = 100,
) -> List[QueuedRun]:
"""
List runs with optional filters.
For active runs, uses scheduler. For historical, uses persistence.
"""
if state and state.is_terminal() and self.persistence:
return self.persistence.list_runs(
state=state,
session_id=session_id,
limit=limit,
)
# Get from scheduler
runs = self.scheduler.get_all()
if state:
runs = [r for r in runs if r.state == state]
if session_id:
runs = [r for r in runs if r.session_id == session_id]
return runs[:limit]
async def clear_queue(self) -> int:
"""Clear all queued runs."""
count = await self.scheduler.clear_queue()
return count
def get_stats(self) -> QueueStats:
"""Get queue statistics."""
if self.persistence:
return self.persistence.get_stats(self._current_session_id)
# Calculate from scheduler
queued = self.scheduler.get_queued()
running = self.scheduler.get_running()
return QueueStats(
queued_count=len(queued),
running_count=len(running),
)
# Session management
def set_session(self, session_id: str) -> None:
"""Set the current session ID."""
self._current_session_id = session_id
def save_session_state(self, state: Dict[str, Any]) -> None:
"""Save session state."""
if self.persistence and self._current_session_id:
self.persistence.save_session(
self._current_session_id,
state=state,
)
def load_session_state(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Load session state."""
if self.persistence:
return self.persistence.load_session(session_id)
return None
def list_sessions(self, limit: int = 50) -> List[Dict[str, Any]]:
"""List recent sessions."""
if self.persistence:
return self.persistence.list_sessions(limit)
return []
# Internal methods
async def _recover_runs(self) -> None:
"""Recover pending runs from persistence."""
if not self.persistence:
return
# Mark interrupted runs as failed
interrupted = self.persistence.mark_interrupted_as_failed()
if interrupted:
logger.warning(f"Marked {interrupted} interrupted runs as failed")
# Load pending runs
pending = self.persistence.load_pending_runs()
if pending:
logger.info(f"Recovering {len(pending)} pending runs")
self.scheduler.load_runs(pending)
for run in pending:
self.persistence.mark_recovered(run.run_id)
async def _save_all_runs(self) -> None:
"""Save all active runs to persistence."""
if not self.persistence:
return
for run in self.scheduler.get_all():
self.persistence.save_run(run)
async def _autosave_loop(self) -> None:
"""Periodically save runs to persistence."""
while True:
try:
await asyncio.sleep(self.config.autosave_interval_seconds)
await self._save_all_runs()
logger.debug("Autosaved queue state")
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Autosave error: {e}")
def _wrap_complete_callback(self):
"""Wrap complete callback to include persistence."""
async def wrapper(run_id: str, run: QueuedRun):
# Persist
if self.persistence:
self.persistence.save_run(run)
# Call user callback
if self._on_complete:
await self._on_complete(run_id, run)
return wrapper
def _wrap_error_callback(self):
"""Wrap error callback to include persistence."""
async def wrapper(run_id: str, error: Exception):
# Persist
if self.persistence:
run = self.scheduler.get_run(run_id)
if run:
self.persistence.save_run(run)
# Call user callback
if self._on_error:
await self._on_error(run_id, error)
return wrapper
@property
def is_running(self) -> bool:
"""Whether the queue manager is running."""
return self._running
@property
def queued_count(self) -> int:
"""Number of queued runs."""
return self.scheduler.queued_count
@property
def running_count(self) -> int:
"""Number of running runs."""
return self.scheduler.running_count
@classmethod
def get_tools_for_run(cls, run_id: str) -> list:
"""
Get tools for a specific run from the registry.
Falls back to default tools if no run-specific tools are registered.
"""
return cls._tools_registry.get(run_id) or cls._tools_registry.get("default", [])
@classmethod
def register_tools(cls, run_id: str, tools: list) -> None:
"""Register tools for a specific run."""
cls._tools_registry[run_id] = tools
@classmethod
def unregister_tools(cls, run_id: str) -> None:
"""Unregister tools for a specific run."""
cls._tools_registry.pop(run_id, None)
@@ -0,0 +1,289 @@
"""
Data models for the PraisonAI Queue System.
Defines QueuedRun, RunState, RunPriority, and QueueConfig.
"""
from dataclasses import dataclass, field
from enum import Enum, IntEnum
from typing import Any, Dict, List, Optional
import time
import uuid
class RunState(str, Enum):
"""State of a queued run."""
QUEUED = "queued"
RUNNING = "running"
PAUSED = "paused"
SUCCEEDED = "succeeded"
FAILED = "failed"
CANCELLED = "cancelled"
def is_terminal(self) -> bool:
"""Check if this is a terminal state."""
return self in (RunState.SUCCEEDED, RunState.FAILED, RunState.CANCELLED)
def is_active(self) -> bool:
"""Check if this is an active (non-terminal) state."""
return not self.is_terminal()
class RunPriority(IntEnum):
"""Priority levels for queued runs. Higher value = higher priority."""
LOW = 0
NORMAL = 1
HIGH = 2
URGENT = 3
@classmethod
def from_string(cls, s: str) -> "RunPriority":
"""Parse priority from string."""
mapping = {
"low": cls.LOW,
"normal": cls.NORMAL,
"high": cls.HIGH,
"urgent": cls.URGENT,
}
return mapping.get(s.lower(), cls.NORMAL)
@dataclass
class QueuedRun:
"""A single queued agent run."""
# Identity
run_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
agent_name: str = ""
input_content: str = ""
# State
state: RunState = RunState.QUEUED
priority: RunPriority = RunPriority.NORMAL
# Attribution / tracing
session_id: Optional[str] = None
trace_id: Optional[str] = None
workspace: Optional[str] = None
user_id: Optional[str] = None
# Timing
created_at: float = field(default_factory=time.time)
started_at: Optional[float] = None
ended_at: Optional[float] = None
# Results
output_content: Optional[str] = None
error: Optional[str] = None
metrics: Dict[str, Any] = field(default_factory=dict)
# Retry tracking
retry_count: int = 0
max_retries: int = 3
parent_run_id: Optional[str] = None # Link to original run if this is a retry
# Configuration
config: Dict[str, Any] = field(default_factory=dict)
# Chat history for session continuity
chat_history: List[Dict[str, str]] = field(default_factory=list)
# Streaming state
output_chunks: List[str] = field(default_factory=list)
def __post_init__(self):
"""Ensure proper types after initialization."""
if isinstance(self.state, str):
self.state = RunState(self.state)
if isinstance(self.priority, int) and not isinstance(self.priority, RunPriority):
self.priority = RunPriority(self.priority)
@property
def duration_seconds(self) -> Optional[float]:
"""Get run duration in seconds."""
if self.started_at is None:
return None
end = self.ended_at or time.time()
return end - self.started_at
@property
def wait_seconds(self) -> float:
"""Get time spent waiting in queue."""
start = self.started_at or time.time()
return start - self.created_at
def can_retry(self) -> bool:
"""Check if this run can be retried."""
return (
self.state == RunState.FAILED and
self.retry_count < self.max_retries
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for persistence."""
return {
"run_id": self.run_id,
"agent_name": self.agent_name,
"input_content": self.input_content,
"state": self.state.value,
"priority": int(self.priority),
"session_id": self.session_id,
"trace_id": self.trace_id,
"workspace": self.workspace,
"user_id": self.user_id,
"created_at": self.created_at,
"started_at": self.started_at,
"ended_at": self.ended_at,
"output_content": self.output_content,
"error": self.error,
"metrics": self.metrics,
"retry_count": self.retry_count,
"max_retries": self.max_retries,
"parent_run_id": self.parent_run_id,
"config": self.config,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "QueuedRun":
"""Create from dictionary."""
# Handle state conversion
state = data.get("state", "queued")
if isinstance(state, str):
state = RunState(state)
# Handle priority conversion
priority = data.get("priority", 1)
if isinstance(priority, int):
priority = RunPriority(priority)
return cls(
run_id=data.get("run_id", str(uuid.uuid4())[:8]),
agent_name=data.get("agent_name", ""),
input_content=data.get("input_content", ""),
state=state,
priority=priority,
session_id=data.get("session_id"),
trace_id=data.get("trace_id"),
workspace=data.get("workspace"),
user_id=data.get("user_id"),
created_at=data.get("created_at", time.time()),
started_at=data.get("started_at"),
ended_at=data.get("ended_at"),
output_content=data.get("output_content"),
error=data.get("error"),
metrics=data.get("metrics", {}),
retry_count=data.get("retry_count", 0),
max_retries=data.get("max_retries", 3),
parent_run_id=data.get("parent_run_id"),
config=data.get("config", {}),
)
@dataclass
class QueueConfig:
"""Configuration for the queue system."""
# Concurrency limits
max_concurrent_global: int = 4
max_concurrent_per_agent: int = 2
max_concurrent_per_workspace: int = 4
# Queue limits
max_queue_size: int = 100
# Defaults
default_priority: RunPriority = RunPriority.NORMAL
default_max_retries: int = 3
# Persistence
enable_persistence: bool = True
db_path: str = ".praison/queue.db"
# Autosave
autosave_interval_seconds: float = 30.0
# Backpressure
stream_buffer_size: int = 1000
drop_strategy: str = "oldest" # "oldest" or "newest"
# Timeouts
run_timeout_seconds: Optional[float] = None # None = no timeout
worker_poll_interval: float = 0.1
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"max_concurrent_global": self.max_concurrent_global,
"max_concurrent_per_agent": self.max_concurrent_per_agent,
"max_concurrent_per_workspace": self.max_concurrent_per_workspace,
"max_queue_size": self.max_queue_size,
"default_priority": int(self.default_priority),
"default_max_retries": self.default_max_retries,
"enable_persistence": self.enable_persistence,
"db_path": self.db_path,
"autosave_interval_seconds": self.autosave_interval_seconds,
"stream_buffer_size": self.stream_buffer_size,
"drop_strategy": self.drop_strategy,
"run_timeout_seconds": self.run_timeout_seconds,
"worker_poll_interval": self.worker_poll_interval,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "QueueConfig":
"""Create from dictionary."""
priority = data.get("default_priority", 1)
if isinstance(priority, int):
priority = RunPriority(priority)
return cls(
max_concurrent_global=data.get("max_concurrent_global", 4),
max_concurrent_per_agent=data.get("max_concurrent_per_agent", 2),
max_concurrent_per_workspace=data.get("max_concurrent_per_workspace", 4),
max_queue_size=data.get("max_queue_size", 100),
default_priority=priority,
default_max_retries=data.get("default_max_retries", 3),
enable_persistence=data.get("enable_persistence", True),
db_path=data.get("db_path", ".praison/queue.db"),
autosave_interval_seconds=data.get("autosave_interval_seconds", 30.0),
stream_buffer_size=data.get("stream_buffer_size", 1000),
drop_strategy=data.get("drop_strategy", "oldest"),
run_timeout_seconds=data.get("run_timeout_seconds"),
worker_poll_interval=data.get("worker_poll_interval", 0.1),
)
@dataclass
class QueueStats:
"""Statistics about the queue."""
queued_count: int = 0
running_count: int = 0
succeeded_count: int = 0
failed_count: int = 0
cancelled_count: int = 0
total_runs: int = 0
avg_wait_seconds: float = 0.0
avg_duration_seconds: float = 0.0
@property
def active_count(self) -> int:
"""Count of active (non-terminal) runs."""
return self.queued_count + self.running_count
@dataclass
class StreamChunk:
"""A chunk of streaming output."""
run_id: str
content: str
timestamp: float = field(default_factory=time.time)
chunk_index: int = 0
is_final: bool = False
@dataclass
class QueueEvent:
"""An event from the queue system."""
event_type: str # "run_started", "run_completed", "run_failed", "output_chunk", "state_changed"
run_id: str
timestamp: float = field(default_factory=time.time)
data: Dict[str, Any] = field(default_factory=dict)
@@ -0,0 +1,564 @@
"""
SQLite persistence layer for the PraisonAI Queue System.
Provides crash recovery, session persistence, and run history.
"""
import json
import logging
import os
import sqlite3
import threading
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Dict, List, Optional
from .models import QueuedRun, RunState, RunPriority, QueueStats
logger = logging.getLogger(__name__)
SCHEMA_VERSION = "1.0"
SCHEMA_SQL = """
-- Schema version tracking
CREATE TABLE IF NOT EXISTS schema_version (
version TEXT PRIMARY KEY,
applied_at REAL NOT NULL
);
-- Queue runs
CREATE TABLE IF NOT EXISTS runs (
run_id TEXT PRIMARY KEY,
agent_name TEXT NOT NULL,
input_content TEXT,
output_content TEXT,
state TEXT NOT NULL DEFAULT 'queued',
priority INTEGER NOT NULL DEFAULT 1,
session_id TEXT,
trace_id TEXT,
workspace TEXT,
user_id TEXT,
created_at REAL NOT NULL,
started_at REAL,
ended_at REAL,
error TEXT,
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3,
parent_run_id TEXT,
config TEXT,
metrics TEXT,
recovered INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_runs_state ON runs(state);
CREATE INDEX IF NOT EXISTS idx_runs_session ON runs(session_id);
CREATE INDEX IF NOT EXISTS idx_runs_priority_created ON runs(priority DESC, created_at ASC);
CREATE INDEX IF NOT EXISTS idx_runs_workspace ON runs(workspace);
-- Messages (chat history per run)
CREATE TABLE IF NOT EXISTS messages (
message_id TEXT PRIMARY KEY,
run_id TEXT REFERENCES runs(run_id),
role TEXT NOT NULL,
content TEXT NOT NULL,
timestamp REAL NOT NULL,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_messages_run ON messages(run_id);
-- Tool calls
CREATE TABLE IF NOT EXISTS tool_calls (
call_id TEXT PRIMARY KEY,
run_id TEXT REFERENCES runs(run_id),
tool_name TEXT NOT NULL,
args TEXT,
result TEXT,
started_at REAL NOT NULL,
ended_at REAL,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_tool_calls_run ON tool_calls(run_id);
-- Sessions
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
user_id TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
state TEXT,
config TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
"""
class QueuePersistence:
"""SQLite-backed persistence for the queue system."""
def __init__(self, db_path: str = ".praison/queue.db"):
"""
Initialize persistence layer.
Args:
db_path: Path to SQLite database file.
"""
self.db_path = db_path
self._conn: Optional[sqlite3.Connection] = None
self._lock = threading.Lock()
self._initialized = False
def _ensure_dir(self) -> None:
"""Ensure database directory exists."""
db_dir = os.path.dirname(self.db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
def _get_connection(self) -> sqlite3.Connection:
"""Get or create database connection."""
if self._conn is None:
self._ensure_dir()
self._conn = sqlite3.connect(
self.db_path,
check_same_thread=False,
timeout=30.0
)
self._conn.row_factory = sqlite3.Row
# Enable WAL mode for better concurrency
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA synchronous=NORMAL")
return self._conn
@contextmanager
def _transaction(self):
"""Context manager for database transactions."""
conn = self._get_connection()
with self._lock:
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
def initialize(self) -> None:
"""Initialize database schema."""
if self._initialized:
return
with self._transaction() as conn:
conn.executescript(SCHEMA_SQL)
# Check/set schema version
cursor = conn.execute(
"SELECT version FROM schema_version ORDER BY applied_at DESC LIMIT 1"
)
row = cursor.fetchone()
if row is None:
conn.execute(
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
(SCHEMA_VERSION, time.time())
)
elif row["version"] != SCHEMA_VERSION:
# Future: handle migrations
logger.warning(
f"Schema version mismatch: {row['version']} vs {SCHEMA_VERSION}"
)
self._initialized = True
def close(self) -> None:
"""Close database connection."""
if self._conn is not None:
self._conn.close()
self._conn = None
self._initialized = False
# Run operations
def save_run(self, run: QueuedRun) -> None:
"""Save or update a run."""
self.initialize()
with self._transaction() as conn:
conn.execute("""
INSERT OR REPLACE INTO runs (
run_id, agent_name, input_content, output_content,
state, priority, session_id, trace_id, workspace, user_id,
created_at, started_at, ended_at, error,
retry_count, max_retries, parent_run_id,
config, metrics
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
run.run_id,
run.agent_name,
run.input_content,
run.output_content,
run.state.value,
int(run.priority),
run.session_id,
run.trace_id,
run.workspace,
run.user_id,
run.created_at,
run.started_at,
run.ended_at,
run.error,
run.retry_count,
run.max_retries,
run.parent_run_id,
json.dumps(run.config) if run.config else None,
json.dumps(run.metrics) if run.metrics else None,
))
def load_run(self, run_id: str) -> Optional[QueuedRun]:
"""Load a run by ID."""
self.initialize()
with self._transaction() as conn:
cursor = conn.execute(
"SELECT * FROM runs WHERE run_id = ?",
(run_id,)
)
row = cursor.fetchone()
if row is None:
return None
return self._row_to_run(row)
def list_runs(
self,
state: Optional[RunState] = None,
session_id: Optional[str] = None,
workspace: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> List[QueuedRun]:
"""List runs with optional filters."""
self.initialize()
query = "SELECT * FROM runs WHERE 1=1"
params: List[Any] = []
if state is not None:
query += " AND state = ?"
params.append(state.value)
if session_id is not None:
query += " AND session_id = ?"
params.append(session_id)
if workspace is not None:
query += " AND workspace = ?"
params.append(workspace)
query += " ORDER BY priority DESC, created_at ASC LIMIT ? OFFSET ?"
params.extend([limit, offset])
with self._transaction() as conn:
cursor = conn.execute(query, params)
return [self._row_to_run(row) for row in cursor.fetchall()]
def delete_run(self, run_id: str) -> bool:
"""Delete a run and its related data."""
self.initialize()
with self._transaction() as conn:
# Delete related data first
conn.execute("DELETE FROM messages WHERE run_id = ?", (run_id,))
conn.execute("DELETE FROM tool_calls WHERE run_id = ?", (run_id,))
cursor = conn.execute("DELETE FROM runs WHERE run_id = ?", (run_id,))
return cursor.rowcount > 0
def update_run_state(
self,
run_id: str,
state: RunState,
error: Optional[str] = None,
output: Optional[str] = None,
) -> bool:
"""Update run state."""
self.initialize()
updates = ["state = ?"]
params: List[Any] = [state.value]
if state == RunState.RUNNING:
updates.append("started_at = ?")
params.append(time.time())
elif state.is_terminal():
updates.append("ended_at = ?")
params.append(time.time())
if error is not None:
updates.append("error = ?")
params.append(error)
if output is not None:
updates.append("output_content = ?")
params.append(output)
params.append(run_id)
with self._transaction() as conn:
cursor = conn.execute(
f"UPDATE runs SET {', '.join(updates)} WHERE run_id = ?",
params
)
return cursor.rowcount > 0
# Crash recovery
def load_pending_runs(self) -> List[QueuedRun]:
"""Load runs that were QUEUED or RUNNING at crash."""
self.initialize()
with self._transaction() as conn:
cursor = conn.execute("""
SELECT * FROM runs
WHERE state IN ('queued', 'running')
ORDER BY priority DESC, created_at ASC
""")
return [self._row_to_run(row) for row in cursor.fetchall()]
def mark_recovered(self, run_id: str) -> None:
"""Mark a run as recovered after restart."""
self.initialize()
with self._transaction() as conn:
conn.execute(
"UPDATE runs SET recovered = 1 WHERE run_id = ?",
(run_id,)
)
def mark_interrupted_as_failed(self) -> int:
"""Mark all RUNNING runs as FAILED (for crash recovery)."""
self.initialize()
with self._transaction() as conn:
cursor = conn.execute("""
UPDATE runs
SET state = 'failed',
error = 'Interrupted by crash/restart',
ended_at = ?
WHERE state = 'running'
""", (time.time(),))
return cursor.rowcount
# Statistics
def get_stats(self, session_id: Optional[str] = None) -> QueueStats:
"""Get queue statistics."""
self.initialize()
with self._transaction() as conn:
where = ""
params: List[Any] = []
if session_id:
where = "WHERE session_id = ?"
params = [session_id]
# Count by state
cursor = conn.execute(f"""
SELECT state, COUNT(*) as count
FROM runs {where}
GROUP BY state
""", params)
counts = {row["state"]: row["count"] for row in cursor.fetchall()}
# Average wait time
cursor = conn.execute(f"""
SELECT AVG(started_at - created_at) as avg_wait
FROM runs
{where + ' AND' if where else 'WHERE'} started_at IS NOT NULL
""", params)
row = cursor.fetchone()
avg_wait = row["avg_wait"] or 0.0
# Average duration
cursor = conn.execute(f"""
SELECT AVG(ended_at - started_at) as avg_duration
FROM runs
{where + ' AND' if where else 'WHERE'} ended_at IS NOT NULL AND started_at IS NOT NULL
""", params)
row = cursor.fetchone()
avg_duration = row["avg_duration"] or 0.0
return QueueStats(
queued_count=counts.get("queued", 0),
running_count=counts.get("running", 0),
succeeded_count=counts.get("succeeded", 0),
failed_count=counts.get("failed", 0),
cancelled_count=counts.get("cancelled", 0),
total_runs=sum(counts.values()),
avg_wait_seconds=avg_wait,
avg_duration_seconds=avg_duration,
)
# Session operations
def save_session(
self,
session_id: str,
user_id: Optional[str] = None,
state: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
) -> None:
"""Save or update a session."""
self.initialize()
now = time.time()
with self._transaction() as conn:
# Check if exists
cursor = conn.execute(
"SELECT session_id FROM sessions WHERE session_id = ?",
(session_id,)
)
exists = cursor.fetchone() is not None
if exists:
conn.execute("""
UPDATE sessions SET
user_id = COALESCE(?, user_id),
updated_at = ?,
state = COALESCE(?, state),
config = COALESCE(?, config)
WHERE session_id = ?
""", (
user_id,
now,
json.dumps(state) if state else None,
json.dumps(config) if config else None,
session_id,
))
else:
conn.execute("""
INSERT INTO sessions (session_id, user_id, created_at, updated_at, state, config)
VALUES (?, ?, ?, ?, ?, ?)
""", (
session_id,
user_id,
now,
now,
json.dumps(state) if state else None,
json.dumps(config) if config else None,
))
def load_session(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Load a session."""
self.initialize()
with self._transaction() as conn:
cursor = conn.execute(
"SELECT * FROM sessions WHERE session_id = ?",
(session_id,)
)
row = cursor.fetchone()
if row is None:
return None
return {
"session_id": row["session_id"],
"user_id": row["user_id"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"state": json.loads(row["state"]) if row["state"] else None,
"config": json.loads(row["config"]) if row["config"] else None,
}
def list_sessions(self, limit: int = 50) -> List[Dict[str, Any]]:
"""List recent sessions."""
self.initialize()
with self._transaction() as conn:
cursor = conn.execute("""
SELECT * FROM sessions
ORDER BY updated_at DESC
LIMIT ?
""", (limit,))
return [
{
"session_id": row["session_id"],
"user_id": row["user_id"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"state": json.loads(row["state"]) if row["state"] else None,
"config": json.loads(row["config"]) if row["config"] else None,
}
for row in cursor.fetchall()
]
# Helper methods
def _row_to_run(self, row: sqlite3.Row) -> QueuedRun:
"""Convert database row to QueuedRun."""
return QueuedRun(
run_id=row["run_id"],
agent_name=row["agent_name"],
input_content=row["input_content"] or "",
output_content=row["output_content"],
state=RunState(row["state"]),
priority=RunPriority(row["priority"]),
session_id=row["session_id"],
trace_id=row["trace_id"],
workspace=row["workspace"],
user_id=row["user_id"],
created_at=row["created_at"],
started_at=row["started_at"],
ended_at=row["ended_at"],
error=row["error"],
retry_count=row["retry_count"] or 0,
max_retries=row["max_retries"] or 3,
parent_run_id=row["parent_run_id"],
config=json.loads(row["config"]) if row["config"] else {},
metrics=json.loads(row["metrics"]) if row["metrics"] else {},
)
# Cleanup
def cleanup_old_runs(self, days: int = 30) -> int:
"""Delete runs older than specified days."""
self.initialize()
cutoff = time.time() - (days * 24 * 60 * 60)
with self._transaction() as conn:
# Get run IDs to delete
cursor = conn.execute("""
SELECT run_id FROM runs
WHERE created_at < ? AND state IN ('succeeded', 'failed', 'cancelled')
""", (cutoff,))
run_ids = [row["run_id"] for row in cursor.fetchall()]
if not run_ids:
return 0
placeholders = ",".join("?" * len(run_ids))
# Delete related data
conn.execute(
f"DELETE FROM messages WHERE run_id IN ({placeholders})",
run_ids
)
conn.execute(
f"DELETE FROM tool_calls WHERE run_id IN ({placeholders})",
run_ids
)
# Delete runs
cursor = conn.execute(
f"DELETE FROM runs WHERE run_id IN ({placeholders})",
run_ids
)
return cursor.rowcount
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more