Compare commits

...
29 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
105 changed files with 18068 additions and 1179 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.2" \
"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.2" \
"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.2" \
"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
+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)
+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()
@@ -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
@@ -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.3"
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.3"
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.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.9.2.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]"
+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
+62 -2
View File
@@ -160,7 +160,7 @@ def get_output_controller() -> OutputController:
# Import and register command groups
def register_commands():
"""Register all command groups."""
# Import command modules
# 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
@@ -176,12 +176,42 @@ def register_commands():
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
# 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")
@@ -197,6 +227,36 @@ def register_commands():
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()
@@ -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,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,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,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,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,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
@@ -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
@@ -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,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,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,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,6 +94,8 @@ __all__ = [
'CapabilitiesHandler',
# Performance benchmarking
'PerformanceHandler',
# Comprehensive benchmarking
'BenchmarkHandler',
# Lite agent (BYO-LLM)
'LiteHandler',
# Agent-centric tools (LSP/ACP powered)
@@ -102,6 +104,11 @@ __all__ = [
'RuntimeConfig',
'CodeIntelligenceRouter',
'ActionOrchestrator',
# Interactive tools provider (canonical source)
'get_interactive_tools',
'resolve_tool_groups',
'ToolConfig',
'TOOL_GROUPS',
]
def __getattr__(name):
@@ -228,6 +235,9 @@ 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
@@ -247,4 +257,17 @@ def __getattr__(name):
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}")
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()
@@ -29,6 +29,10 @@ class QueueManager:
- 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,
@@ -36,6 +40,7 @@ class QueueManager:
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.
@@ -46,6 +51,7 @@ class QueueManager:
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()
@@ -70,6 +76,10 @@ class QueueManager:
# 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:
"""
@@ -157,6 +167,7 @@ class QueueManager:
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.
@@ -169,6 +180,7 @@ class QueueManager:
workspace: Workspace path.
config: Additional configuration.
run_id: Optional specific run ID.
chat_history: Previous chat messages for context continuity.
Returns:
The run ID.
@@ -181,6 +193,7 @@ class QueueManager:
workspace=workspace or os.getcwd(),
config=config or {},
max_retries=self.config.default_max_retries,
chat_history=chat_history or [],
)
if run_id:
@@ -225,6 +238,10 @@ class QueueManager:
"""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)
@@ -393,3 +410,22 @@ class QueueManager:
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)
@@ -85,6 +85,9 @@ class QueuedRun:
# 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)
@@ -302,6 +302,40 @@ class QueueScheduler:
"""Clear a cancellation token."""
self._cancel_tokens.discard(run_id)
async def update_input(self, run_id: str, new_input: str) -> bool:
"""
Update the input content of a queued run.
Only works for runs in QUEUED state.
Args:
run_id: The run ID to update.
new_input: The new input content.
Returns:
True if updated, False if not found or not editable.
"""
async with self._lock:
run = self._all_runs.get(run_id)
if run is None:
return False
if run.state != RunState.QUEUED:
logger.warning(f"Cannot edit run {run_id} in state {run.state}")
return False
run.input_content = new_input
logger.debug(f"Updated input for run {run_id}")
self._emit_event(QueueEvent(
event_type="run_updated",
run_id=run_id,
data={"new_input": new_input[:50]}
))
return True
async def retry(self, run_id: str) -> Optional[str]:
"""
Retry a failed run.
@@ -210,20 +210,34 @@ class WorkerPool:
try:
# Import agent lazily to avoid circular imports
from praisonaiagents import Agent
from .manager import QueueManager
# Get agent configuration
agent_config = run.config.get("agent_config", {})
agent_name = run.agent_name or agent_config.get("name", "Assistant")
# Create agent
# Get tools from runtime registry (not from config, as functions can't be serialized)
tools = QueueManager.get_tools_for_run(run.run_id)
# Create agent with session_id for history persistence
# Agent automatically restores history from JSON store when session_id is provided
# Note: Agent uses 'llm' parameter, not 'model'
agent = Agent(
name=agent_name,
instructions=agent_config.get("instructions", "You are a helpful assistant."),
model=agent_config.get("model"),
tools=agent_config.get("tools", []),
llm=agent_config.get("model"),
tools=tools,
verbose=agent_config.get("verbose", False),
session_id=agent_config.get("session_id") or run.session_id, # Use session for history
)
# Legacy: Inject chat history if provided (for backward compatibility)
# This is no longer needed as Agent now has built-in JSON persistence
# but we keep it for cases where chat_history is explicitly passed
if run.chat_history and not agent.chat_history:
agent.chat_history = list(run.chat_history)
logger.debug(f"Injected {len(run.chat_history)} messages from run.chat_history (legacy)")
# Check for cancellation before starting
if self.scheduler.is_cancelled(run.run_id):
logger.debug(f"Run {run.run_id} was cancelled before execution")
+310 -25
View File
@@ -13,12 +13,12 @@ from typing import Any, Dict, List, Optional
try:
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual import events
from textual import work
TEXTUAL_AVAILABLE = True
except ImportError:
TEXTUAL_AVAILABLE = False
App = object
work = None
logger = logging.getLogger(__name__)
@@ -28,10 +28,13 @@ if TEXTUAL_AVAILABLE:
from .screens.queue import QueueScreen
from .screens.settings import SettingsScreen
from .screens.session import SessionScreen
from .screens.help import HelpScreen
from .widgets.chat import ChatMessage
from .widgets.status import StatusInfo
from .widgets.queue_panel import QueueItem
from ..queue import QueueManager, QueueConfig, QueuedRun, RunPriority
from .session_store import SessionStore
from praisonai.cli.session import get_session_store, UnifiedSession
class TUIApp(App):
"""
@@ -54,9 +57,12 @@ if TEXTUAL_AVAILABLE:
}
"""
# Safe default bindings - Ctrl keys only at app level for emergency exit
# MainScreen handles all other bindings with safe defaults
BINDINGS = [
Binding("ctrl+q", "quit", "Quit", show=True, priority=True),
Binding("ctrl+c", "cancel", "Cancel", show=True),
# Emergency quit - kept at app level for reliability
Binding("ctrl+q", "quit", "Quit", show=False, priority=True),
Binding("ctrl+c", "cancel", "Cancel", show=False),
]
SCREENS = {
@@ -64,6 +70,7 @@ if TEXTUAL_AVAILABLE:
"queue": QueueScreen,
"settings": SettingsScreen,
"sessions": SessionScreen,
"help": HelpScreen,
}
def __init__(
@@ -73,6 +80,8 @@ if TEXTUAL_AVAILABLE:
model: Optional[str] = None,
agent_config: Optional[Dict[str, Any]] = None,
queue_config: Optional[QueueConfig] = None,
enable_acp: bool = True,
enable_lsp: bool = True,
):
super().__init__()
@@ -80,11 +89,21 @@ if TEXTUAL_AVAILABLE:
self.session_id = session_id or str(uuid.uuid4())[:8]
self.model = model or os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
self.agent_config = agent_config or {}
self.enable_acp = enable_acp
self.enable_lsp = enable_lsp
# Load default interactive tools (ACP + LSP) if not provided
if "tools" not in self.agent_config or not self.agent_config["tools"]:
self.agent_config["tools"] = self._load_default_tools()
# Queue manager
self.queue_config = queue_config or QueueConfig()
self.queue_manager: Optional[QueueManager] = None
# Session store for chat history persistence (uses UnifiedSession)
self._session_store = get_session_store()
self._unified_session = self._session_store.get_or_create(self.session_id)
# State
self._current_run_id: Optional[str] = None
self._streaming_content: str = ""
@@ -95,6 +114,43 @@ if TEXTUAL_AVAILABLE:
self._command_handlers: Dict[str, callable] = {}
self._setup_commands()
def _load_default_tools(self) -> list:
"""
Load default interactive tools (ACP + LSP) for TUI.
Uses the canonical interactive_tools provider.
"""
try:
from ..interactive_tools import get_interactive_tools, ToolConfig
# Build disable list based on flags
disable = []
if not self.enable_acp:
disable.append('acp')
if not self.enable_lsp:
disable.append('lsp')
config = ToolConfig(
workspace=self.workspace,
enable_acp=self.enable_acp,
enable_lsp=self.enable_lsp,
)
tools = get_interactive_tools(
config=config,
disable=disable if disable else None,
)
logger.info(f"TUI loaded {len(tools)} default tools (ACP: {self.enable_acp}, LSP: {self.enable_lsp})")
return tools
except ImportError as e:
logger.warning(f"Could not load interactive tools: {e}")
return []
except Exception as e:
logger.error(f"Error loading default tools: {e}")
return []
def _setup_commands(self) -> None:
"""Setup slash command handlers."""
self._command_handlers = {
@@ -109,16 +165,25 @@ if TEXTUAL_AVAILABLE:
"cost": self._cmd_cost,
"exit": self._cmd_exit,
"quit": self._cmd_exit,
# Additional commands
"tokens": self._cmd_tokens,
"plan": self._cmd_plan,
"map": self._cmd_map,
"undo": self._cmd_undo,
"diff": self._cmd_diff,
"commit": self._cmd_commit,
"tools": self._cmd_tools,
}
async def on_mount(self) -> None:
"""Handle app mount."""
# Initialize queue manager
# Initialize queue manager with default tools (stored in runtime registry)
self.queue_manager = QueueManager(
config=self.queue_config,
on_output=self._handle_output,
on_complete=self._handle_complete,
on_error=self._handle_error,
default_tools=self.agent_config.get("tools", []),
)
await self.queue_manager.start(recover=True)
@@ -127,6 +192,9 @@ if TEXTUAL_AVAILABLE:
# Push main screen
await self.push_screen("main")
# Update tools panel with available tools
self._update_tools_panel()
# Update status
self._update_status()
@@ -141,23 +209,45 @@ if TEXTUAL_AVAILABLE:
# Event handlers
async def on_main_screen_message_submitted(
def on_main_screen_message_submitted(
self, event: MainScreen.MessageSubmitted
) -> None:
"""Handle message submission from main screen."""
content = event.content
"""Handle message submission - dispatch to worker for non-blocking execution."""
# Use run_worker to avoid blocking the UI
self.run_worker(
self._process_message_submission(event.content),
exclusive=False, # Allow multiple messages to queue
exit_on_error=False,
)
@work(exclusive=False, exit_on_error=False)
async def _process_message_submission(self, content: str) -> None:
"""Process message submission in background worker.
# Add user message to chat
Using @work decorator ensures the UI remains responsive.
User can type while agent is processing.
"""
# Add user message to chat display
main_screen = self.screen
if isinstance(main_screen, MainScreen):
await main_screen.add_user_message(content)
main_screen.set_processing(True)
# Submit to queue
# Add user message to session store for history persistence
self.session_store.add_user_message(self.session_id, content)
# Get chat history for context continuity
chat_history = self.session_store.get_chat_history(self.session_id, max_messages=50)
# Submit to queue with chat history
# Note: tools are NOT included in config (they can't be JSON serialized)
# Instead, they are stored in QueueManager._tools_registry and retrieved by worker
try:
run_id = await self.queue_manager.submit(
input_content=content,
agent_name=self.agent_config.get("name", "Assistant"),
session_id=self.session_id, # Pass session_id for history
chat_history=chat_history, # Pass previous messages for context
config={
"agent_config": {
"name": self.agent_config.get("name", "Assistant"),
@@ -166,8 +256,8 @@ if TEXTUAL_AVAILABLE:
"You are a helpful AI assistant."
),
"model": self.model,
"tools": self.agent_config.get("tools", []),
"verbose": False,
"session_id": self.session_id, # Pass to agent for history
}
}
)
@@ -175,6 +265,9 @@ if TEXTUAL_AVAILABLE:
self._current_run_id = run_id
self._streaming_content = ""
# IMMEDIATELY update queue panel - increment the moment message is queued
self._update_queue_panel_live()
# Add streaming placeholder
if isinstance(main_screen, MainScreen):
await main_screen.add_assistant_message(
@@ -194,6 +287,7 @@ if TEXTUAL_AVAILABLE:
)
self._update_status()
self._update_queue_panel_live()
async def on_main_screen_command_executed(
self, event: MainScreen.CommandExecuted
@@ -278,6 +372,22 @@ if TEXTUAL_AVAILABLE:
self.queue_manager.set_session(self.session_id)
self._update_status()
async def on_queue_panel_widget_edit_requested(
self, event
) -> None:
"""Handle queue item edit request."""
from .widgets.queue_panel import QueuePanelWidget
if isinstance(event, QueuePanelWidget.EditRequested):
success = await self.queue_manager.update_input(event.run_id, event.new_content)
if success:
self._update_queue_panel_live()
main_screen = self.screen
if isinstance(main_screen, MainScreen):
await main_screen.add_assistant_message(
content=f"Updated queue item {event.run_id[:8]}",
agent_name="System",
)
# Queue callbacks
async def _handle_output(self, run_id: str, chunk: str) -> None:
@@ -293,14 +403,17 @@ if TEXTUAL_AVAILABLE:
async def _handle_complete(self, run_id: str, run: QueuedRun) -> None:
"""Handle run completion."""
output_content = run.output_content or self._streaming_content
main_screen = self.screen
if isinstance(main_screen, MainScreen):
await main_screen.complete_streaming(
run_id,
run.output_content or self._streaming_content
)
await main_screen.complete_streaming(run_id, output_content)
main_screen.set_processing(False)
# Store assistant response in session store for history persistence
if output_content and run.session_id:
self.session_store.add_assistant_message(run.session_id, output_content, run_id)
# Update metrics
if run.metrics:
self._total_tokens += run.metrics.get("tokens", 0)
@@ -311,6 +424,7 @@ if TEXTUAL_AVAILABLE:
self._streaming_content = ""
self._update_status()
self._update_queue_panel_live()
async def _handle_error(self, run_id: str, error: Exception) -> None:
"""Handle run error."""
@@ -324,6 +438,7 @@ if TEXTUAL_AVAILABLE:
self._streaming_content = ""
self._update_status()
self._update_queue_panel_live()
# Command handlers
@@ -343,18 +458,23 @@ if TEXTUAL_AVAILABLE:
- `/cost` - Show cost summary
- `/exit` or `/quit` - Exit TUI
**Keyboard Shortcuts:**
**Keyboard Shortcuts (Safe Defaults):**
- `Ctrl+Enter` - Send message
- `Ctrl+C` - Cancel current operation
- `Ctrl+Q` - Quit
- `Ctrl+L` - Clear screen
- `F1` - Help
- `F2` - Toggle queue panel
- `F3` - Settings
- `F5` - Clear chat
- `Tab` - Cycle focus
- `Enter` - Send message
- `Shift+Enter` - New line
- `q` - Quit (when not typing)
- `:` - Command mode
- `?` - Help
- `Esc` - Cancel/close
**Command Mode:**
Press `:` then type a command:
- `:quit` or `:q` - Quit
- `:clear` or `:cl` - Clear chat
- `:help` or `:h` - Help
- `:tools` or `:t` - Toggle tools
- `:queue` or `:qu` - Toggle queue
"""
main_screen = self.screen
if isinstance(main_screen, MainScreen):
@@ -431,6 +551,8 @@ if TEXTUAL_AVAILABLE:
async def _cmd_sessions(self, args: str) -> None:
"""Show sessions."""
await self.push_screen("sessions")
# Load and display sessions
await self._update_sessions_screen()
async def _cmd_cost(self, args: str) -> None:
"""Show cost summary."""
@@ -449,6 +571,115 @@ if TEXTUAL_AVAILABLE:
"""Exit TUI."""
self.exit()
async def _cmd_tokens(self, args: str) -> None:
"""Show token usage breakdown."""
msg = f"""
**Token Usage:**
- Total Tokens: {self._total_tokens:,}
- Estimated Cost: ${self._total_cost:.4f}
- Session: {self.session_id}
"""
main_screen = self.screen
if isinstance(main_screen, MainScreen):
await main_screen.add_assistant_message(content=msg, agent_name="System")
async def _cmd_plan(self, args: str) -> None:
"""Show or create a plan."""
msg = """
**Plan Command:**
The /plan command is used to create or view task plans.
Currently, plans are managed through the agent's task execution.
Usage:
- `/plan` - View current plan
- `/plan <task>` - Create a plan for a task
"""
main_screen = self.screen
if isinstance(main_screen, MainScreen):
await main_screen.add_assistant_message(content=msg, agent_name="System")
async def _cmd_map(self, args: str) -> None:
"""Show repository map."""
msg = """
**Repository Map:**
The /map command shows a map of the repository structure.
Use the file tools to explore the codebase.
Available tools:
- `acp_list_files` - List files in a directory
- `acp_read_file` - Read file contents
"""
main_screen = self.screen
if isinstance(main_screen, MainScreen):
await main_screen.add_assistant_message(content=msg, agent_name="System")
async def _cmd_undo(self, args: str) -> None:
"""Undo last change."""
msg = """
**Undo:**
The /undo command reverts the last file change.
Use git commands or the agent to manage file changes.
Tip: Ask the agent to "undo the last change" or use git.
"""
main_screen = self.screen
if isinstance(main_screen, MainScreen):
await main_screen.add_assistant_message(content=msg, agent_name="System")
async def _cmd_diff(self, args: str) -> None:
"""Show diff of changes."""
msg = """
**Diff:**
The /diff command shows changes made to files.
Use git diff or ask the agent to show changes.
Tip: Ask the agent "show me the diff" or use `git diff`.
"""
main_screen = self.screen
if isinstance(main_screen, MainScreen):
await main_screen.add_assistant_message(content=msg, agent_name="System")
async def _cmd_commit(self, args: str) -> None:
"""Commit changes."""
msg = """
**Commit:**
The /commit command creates a git commit.
Use git commands or ask the agent to commit changes.
Tip: Ask the agent to "commit the changes with message X".
"""
main_screen = self.screen
if isinstance(main_screen, MainScreen):
await main_screen.add_assistant_message(content=msg, agent_name="System")
async def _cmd_tools(self, args: str) -> None:
"""Show available tools."""
tools = self.agent_config.get("tools", [])
tool_names = []
for tool in tools:
if hasattr(tool, '__name__'):
tool_names.append(tool.__name__)
elif hasattr(tool, 'name'):
tool_names.append(tool.name)
else:
tool_names.append(str(tool)[:30])
msg = f"""
**Available Tools ({len(tools)}):**
{chr(10).join(f'- {name}' for name in tool_names[:20])}
{"..." if len(tool_names) > 20 else ""}
"""
main_screen = self.screen
if isinstance(main_screen, MainScreen):
await main_screen.add_assistant_message(content=msg, agent_name="System")
# Helper methods
def _update_status(self) -> None:
@@ -465,6 +696,19 @@ if TEXTUAL_AVAILABLE:
is_processing=self._current_run_id is not None,
))
def _update_queue_panel_live(self) -> None:
"""Update queue panel on MainScreen with live counts."""
from .widgets.queue_panel import QueuePanelWidget
main_screen = self.screen
if isinstance(main_screen, MainScreen):
try:
queue_panel = main_screen.query_one("#queue-panel", QueuePanelWidget)
# Update counts immediately
queue_panel.queued_count = self.queue_manager.queued_count if self.queue_manager else 0
queue_panel.running_count = self.queue_manager.running_count if self.queue_manager else 0
except Exception:
pass
def _update_queue_screen(self) -> None:
"""Update queue screen if visible."""
if isinstance(self.screen, QueueScreen):
@@ -482,6 +726,41 @@ if TEXTUAL_AVAILABLE:
for r in runs
])
async def _update_sessions_screen(self) -> None:
"""Update sessions screen with session list."""
if isinstance(self.screen, SessionScreen):
# Get sessions from queue manager or session store
sessions = []
if self.queue_manager:
try:
# Try to get sessions from the persistence layer
from ..queue.persistence import QueuePersistence
persistence = QueuePersistence()
sessions = persistence.list_sessions(limit=50)
except Exception as e:
logger.debug(f"Could not load sessions: {e}")
# Fallback: show current session only
sessions = [{
"session_id": self.session_id,
"created_at": "-",
"updated_at": "-",
"run_count": self.queue_manager.queued_count + self.queue_manager.running_count,
}]
self.screen.update_sessions(sessions)
def _update_tools_panel(self) -> None:
"""Update tools panel with available tools."""
from .widgets.tool_panel import ToolPanelWidget
main_screen = self.screen
if isinstance(main_screen, MainScreen):
try:
tool_panel = main_screen.query_one("#tool-panel", ToolPanelWidget)
tools = self.agent_config.get("tools", [])
tool_panel.set_available_tools(tools)
except Exception:
pass
def action_quit(self) -> None:
"""Quit the application."""
self.exit()
@@ -505,6 +784,8 @@ def run_tui(
session_id: Optional[str] = None,
model: Optional[str] = None,
agent_config: Optional[Dict[str, Any]] = None,
enable_acp: bool = True,
enable_lsp: bool = True,
) -> None:
"""
Run the PraisonAI TUI.
@@ -514,6 +795,8 @@ def run_tui(
session_id: Session ID to resume.
model: Default model.
agent_config: Agent configuration.
enable_acp: Enable ACP tools (default: True).
enable_lsp: Enable LSP tools (default: True).
"""
if not TEXTUAL_AVAILABLE:
raise ImportError(
@@ -525,5 +808,7 @@ def run_tui(
session_id=session_id,
model=model,
agent_config=agent_config,
enable_acp=enable_acp,
enable_lsp=enable_lsp,
)
app.run()
@@ -56,8 +56,16 @@ def create_tui_app():
None, "--agent", "-a",
help="Agent config file (YAML)"
),
no_acp: bool = typer.Option(
False, "--no-acp",
help="Disable ACP tools (file operations with plan/approve/apply)"
),
no_lsp: bool = typer.Option(
False, "--no-lsp",
help="Disable LSP tools (code intelligence: symbols, definitions)"
),
):
"""Launch the interactive TUI."""
"""Launch the interactive TUI with ACP + LSP tools enabled by default."""
try:
from .app import run_tui
except ImportError:
@@ -79,6 +87,8 @@ def create_tui_app():
session_id=session,
model=model,
agent_config=agent_config,
enable_acp=not no_acp,
enable_lsp=not no_lsp,
)
return app
@@ -0,0 +1,150 @@
"""
TUI Configuration for PraisonAI.
Handles keyboard shortcuts and display settings with safe defaults.
"""
import os
from dataclasses import dataclass, field
from typing import Dict, List, Optional
@dataclass
class TUIConfig:
"""
Configuration for TUI behavior.
Safe defaults:
- Ctrl keys disabled by default (conflict with IDEs/terminals)
- Function keys disabled by default (conflict with IDEs/OS)
- Single-character shortcuts enabled by default
- Command mode (:) enabled by default
"""
# Keyboard configuration
enable_ctrl_keys: bool = False # Ctrl+Q, Ctrl+L, etc.
enable_fn_keys: bool = False # F1-F12
enable_single_char: bool = True # q, l, etc. (when not in input)
enable_command_mode: bool = True # :quit, :clear, etc.
leader_key: str = ":" # Command mode prefix
# Display configuration
auto_scroll: bool = True
show_tools_panel: bool = True
show_queue_panel: bool = True
@classmethod
def from_env(cls) -> "TUIConfig":
"""Create config from environment variables."""
config = cls()
# Check env vars for enabling Ctrl keys (opt-in)
if os.environ.get("PRAISONAI_TUI_CTRL_KEYS", "").lower() in ("1", "true", "yes"):
config.enable_ctrl_keys = True
# Check env vars for enabling Function keys (opt-in)
if os.environ.get("PRAISONAI_TUI_FN_KEYS", "").lower() in ("1", "true", "yes"):
config.enable_fn_keys = True
# Check for disabling single-char shortcuts
if os.environ.get("PRAISONAI_TUI_NO_SINGLE_CHAR", "").lower() in ("1", "true", "yes"):
config.enable_single_char = False
# Check for custom leader key
leader = os.environ.get("PRAISONAI_TUI_LEADER", "")
if leader and len(leader) == 1:
config.leader_key = leader
return config
@classmethod
def from_cli_args(
cls,
enable_ctrl_keys: bool = False,
enable_fn_keys: bool = False,
leader_key: Optional[str] = None,
) -> "TUIConfig":
"""Create config from CLI arguments, merged with env."""
config = cls.from_env()
# CLI args override env
if enable_ctrl_keys:
config.enable_ctrl_keys = True
if enable_fn_keys:
config.enable_fn_keys = True
if leader_key and len(leader_key) == 1:
config.leader_key = leader_key
return config
# Command mode commands
COMMANDS = {
"quit": {"aliases": ["q", "exit"], "description": "Quit the TUI"},
"clear": {"aliases": ["cl"], "description": "Clear the chat history"},
"help": {"aliases": ["h", "?"], "description": "Show help"},
"tools": {"aliases": ["t"], "description": "Toggle tools panel"},
"queue": {"aliases": ["qu"], "description": "Toggle queue panel"},
"settings": {"aliases": ["set"], "description": "Open settings"},
"cancel": {"aliases": ["c"], "description": "Cancel current operation"},
"sessions": {"aliases": ["sess"], "description": "Manage sessions"},
}
def get_command(input_str: str) -> Optional[str]:
"""
Parse command input and return the canonical command name.
Args:
input_str: The command string (without leader key)
Returns:
Canonical command name or None if not found
"""
input_str = input_str.strip().lower()
# Check direct match
if input_str in COMMANDS:
return input_str
# Check aliases
for cmd, info in COMMANDS.items():
if input_str in info.get("aliases", []):
return cmd
return None
def get_help_text() -> str:
"""Get formatted help text for all commands."""
lines = [
"PraisonAI TUI Commands",
"=" * 40,
"",
"Command Mode (press : to enter):",
]
for cmd, info in COMMANDS.items():
aliases = ", ".join(info.get("aliases", []))
desc = info.get("description", "")
if aliases:
lines.append(f" :{cmd} (or :{aliases}) - {desc}")
else:
lines.append(f" :{cmd} - {desc}")
lines.extend([
"",
"Single-key shortcuts (when not typing):",
" q - Quit",
" l - Clear screen",
" ? - Help",
" / - Search/filter",
"",
"Input shortcuts:",
" Enter - Send message",
" Shift+Enter - New line",
" Escape - Cancel",
" Up/Down - History navigation",
])
return "\n".join(lines)
@@ -0,0 +1,157 @@
"""
Help Screen for PraisonAI TUI.
Displays keyboard shortcuts and usage information.
"""
from typing import Optional
try:
from textual.screen import Screen
from textual.containers import Vertical, Container
from textual.widgets import Static, Footer, Button
from textual.binding import Binding
TEXTUAL_AVAILABLE = True
except ImportError:
TEXTUAL_AVAILABLE = False
Screen = object
if TEXTUAL_AVAILABLE:
from rich.text import Text
from rich.panel import Panel
from rich.table import Table
class HelpScreen(Screen):
"""
Help screen showing keyboard shortcuts and usage.
"""
BINDINGS = [
Binding("escape", "dismiss", "Close", show=True),
Binding("q", "dismiss", "Close", show=True),
]
DEFAULT_CSS = """
HelpScreen {
align: center middle;
}
HelpScreen #help-container {
width: 80;
height: auto;
max-height: 90%;
background: $surface;
border: solid $primary;
padding: 1 2;
}
HelpScreen #help-title {
text-align: center;
text-style: bold;
margin-bottom: 1;
}
HelpScreen #close-button {
margin-top: 1;
width: 100%;
}
"""
def compose(self):
"""Compose the help screen."""
with Container(id="help-container"):
yield Static("PraisonAI TUI Help", id="help-title")
yield Static(self._build_help_content())
yield Button("Close (Esc)", id="close-button", variant="primary")
def _build_help_content(self) -> Text:
"""Build the help content."""
content = Text()
# Safe keyboard shortcuts section
content.append("\n📋 Keyboard Shortcuts (Safe Defaults)\n", style="bold cyan")
content.append("─" * 40 + "\n", style="dim")
shortcuts = [
("\\", "Show command popup"),
("@path", "Reference file (type freely)"),
("/", "Show command discovery"),
("Enter", "Send message"),
("Shift+Enter", "New line in message"),
("q", "Quit (when not typing)"),
("?", "Show this help"),
(":", "Enter command mode"),
("Escape", "Cancel/Close/Dismiss"),
("Tab", "Focus next element"),
("Up/Down", "Navigate history"),
]
for key, desc in shortcuts:
content.append(f" {key:<15}", style="yellow")
content.append(f" {desc}\n")
# Command mode section
content.append("\n⌨️ Command Mode (: prefix)\n", style="bold cyan")
content.append("─" * 40 + "\n", style="dim")
colon_cmds = [
(":quit, :q", "Quit application"),
(":clear, :cl", "Clear chat history"),
(":help, :h", "Show help"),
(":tools, :t", "Toggle tools panel"),
(":queue, :qu", "Toggle queue panel"),
(":settings", "Open settings"),
(":cancel", "Cancel current operation"),
]
for cmd, desc in colon_cmds:
content.append(f" {cmd:<15}", style="green")
content.append(f" {desc}\n")
# Slash commands section
content.append("\n💬 Slash Commands (/ prefix)\n", style="bold cyan")
content.append("─" * 40 + "\n", style="dim")
commands = [
("/help", "Show help"),
("/clear", "Clear chat"),
("/model", "Show/change model"),
("/queue", "Show queue status"),
("/cancel", "Cancel current run"),
("/cost", "Show cost summary"),
("/settings", "Open settings"),
("/sessions", "Manage sessions"),
("/exit", "Exit TUI"),
]
for cmd, desc in commands:
content.append(f" {cmd:<15}", style="green")
content.append(f" {desc}\n")
# Optional keys note
content.append("\n⚙️ Optional Keys (disabled by default)\n", style="bold cyan")
content.append("─" * 40 + "\n", style="dim")
content.append(" Ctrl keys: ", style="dim")
content.append("PRAISONAI_TUI_CTRL_KEYS=1\n", style="yellow")
content.append(" Fn keys: ", style="dim")
content.append("PRAISONAI_TUI_FN_KEYS=1\n", style="yellow")
return content
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button press."""
if event.button.id == "close-button":
self.dismiss()
def action_dismiss(self) -> None:
"""Dismiss the help screen."""
self.dismiss()
else:
class HelpScreen:
"""Placeholder when Textual is not available."""
def __init__(self, *args, **kwargs):
raise ImportError(
"Textual is required for TUI. Install with: pip install praisonai[tui]"
)
@@ -25,6 +25,9 @@ if TEXTUAL_AVAILABLE:
from ..widgets.status import StatusWidget, StatusInfo
from ..widgets.queue_panel import QueuePanelWidget
from ..widgets.tool_panel import ToolPanelWidget
from ..widgets.command_popup import CommandPopupWidget, get_default_commands
from ..widgets.file_popup import FilePopupWidget
from ..config import TUIConfig, get_command
class MainScreen(Screen):
"""
@@ -39,16 +42,18 @@ if TEXTUAL_AVAILABLE:
- Footer with keybindings
"""
# Safe default bindings - no Ctrl or Fn keys by default
# These work in VS Code, Windsurf, tmux, and plain terminals
BINDINGS = [
Binding("ctrl+q", "quit", "Quit", show=True),
Binding("ctrl+c", "cancel", "Cancel", show=True),
Binding("ctrl+l", "clear", "Clear", show=True),
Binding("f1", "help", "Help", show=True),
Binding("f2", "toggle_queue", "Queue", show=True),
Binding("f3", "settings", "Settings", show=True),
Binding("f5", "clear_chat", "Clear Chat", show=True),
# Single-character shortcuts (safe defaults)
Binding("q", "quit", "Quit", show=True, priority=True),
Binding("question_mark", "help", "Help", show=True),
Binding("backslash", "show_commands", "\\cmds", show=True),
Binding("colon", "command_mode", ":cmd", show=False),
Binding("slash", "search", "/search", show=False),
# Navigation
Binding("tab", "focus_next", "Next", show=False),
Binding("escape", "cancel_input", "Cancel", show=False),
Binding("escape", "cancel_or_dismiss", "Cancel", show=True),
]
DEFAULT_CSS = """
@@ -57,6 +62,7 @@ if TEXTUAL_AVAILABLE:
grid-size: 2;
grid-columns: 3fr 1fr;
grid-rows: 1 1fr auto 1;
layers: base popup;
}
MainScreen #status-bar {
@@ -84,6 +90,18 @@ if TEXTUAL_AVAILABLE:
MainScreen .sidebar-panel {
height: 1fr;
}
MainScreen #command-popup {
layer: popup;
dock: bottom;
margin: 0 2 2 2;
}
MainScreen #file-popup {
layer: popup;
dock: bottom;
margin: 0 2 2 2;
}
"""
class MessageSubmitted(Message):
@@ -107,6 +125,7 @@ if TEXTUAL_AVAILABLE:
self,
show_queue: bool = True,
show_tools: bool = True,
config: Optional[TUIConfig] = None,
name: Optional[str] = None,
id: Optional[str] = None,
classes: Optional[str] = None,
@@ -114,6 +133,15 @@ if TEXTUAL_AVAILABLE:
super().__init__(name=name, id=id, classes=classes)
self._show_queue = show_queue
self._show_tools = show_tools
self._config = config or TUIConfig.from_env()
self._command_mode = False
self._command_buffer = ""
self._command_popup_visible = False
self._file_popup_visible = False
self._workspace = None
# Dynamically add Ctrl/Fn bindings if enabled
self._setup_optional_bindings()
def compose(self):
"""Compose the screen."""
@@ -131,6 +159,21 @@ if TEXTUAL_AVAILABLE:
yield Footer()
def _setup_optional_bindings(self) -> None:
"""Setup optional Ctrl/Fn key bindings based on config."""
if self._config.enable_ctrl_keys:
# Add Ctrl key bindings (opt-in)
self._bindings.bind("ctrl+q", "quit", "^Q Quit", show=True)
self._bindings.bind("ctrl+c", "cancel", "^C Cancel", show=True)
self._bindings.bind("ctrl+l", "clear_chat", "^L Clear", show=True)
if self._config.enable_fn_keys:
# Add Function key bindings (opt-in)
self._bindings.bind("f1", "help", "F1 Help", show=True)
self._bindings.bind("f2", "toggle_queue", "F2 Queue", show=True)
self._bindings.bind("f3", "settings", "F3 Settings", show=True)
self._bindings.bind("f5", "clear_chat", "F5 Clear", show=True)
def on_mount(self) -> None:
"""Handle mount."""
# Focus composer
@@ -140,14 +183,52 @@ if TEXTUAL_AVAILABLE:
def on_composer_widget_submitted(self, event: ComposerWidget.Submitted) -> None:
"""Handle message submission."""
if event.is_command:
# Parse command
parts = event.content[1:].split(maxsplit=1)
# Parse command (supports both / and : prefix)
content = event.content
if content.startswith("/") or content.startswith(":"):
content = content[1:] # Remove prefix
parts = content.split(maxsplit=1)
command = parts[0] if parts else ""
args = parts[1] if len(parts) > 1 else ""
# Handle local screen commands first
if self._handle_local_command(command, args):
return
# Forward to app for other commands
self.post_message(self.CommandExecuted(command, args))
else:
self.post_message(self.MessageSubmitted(event.content))
def _handle_local_command(self, command: str, args: str) -> bool:
"""Handle commands that can be processed locally without app involvement."""
cmd = get_command(command)
if cmd == "quit":
self.app.exit()
return True
elif cmd == "clear":
self.action_clear_chat()
return True
elif cmd == "help":
self.action_help()
return True
elif cmd == "tools":
self.action_toggle_tools()
return True
elif cmd == "queue":
self.action_toggle_queue()
return True
elif cmd == "settings":
self.action_settings()
return True
elif cmd == "cancel":
self.action_cancel()
return True
return False # Not handled locally
def action_quit(self) -> None:
"""Quit the application."""
self.app.exit()
@@ -186,6 +267,183 @@ if TEXTUAL_AVAILABLE:
composer = self.query_one("#composer", ComposerWidget)
composer.action_cancel()
def action_command_mode(self) -> None:
"""Enter command mode - focus composer with : prefix."""
composer = self.query_one("#composer", ComposerWidget)
composer.focus_input()
composer.set_text(":")
def action_search(self) -> None:
"""Enter search mode - focus composer with / prefix."""
composer = self.query_one("#composer", ComposerWidget)
composer.focus_input()
composer.set_text("/")
def action_toggle_tools(self) -> None:
"""Toggle tools panel visibility."""
try:
tool_panel = self.query_one("#tool-panel", ToolPanelWidget)
tool_panel.display = not tool_panel.display
except Exception:
pass
def action_show_commands(self) -> None:
"""Show command popup (triggered by backslash)."""
if self._command_popup_visible:
return
self._command_popup_visible = True
# Create and mount command popup
popup = CommandPopupWidget(
commands=get_default_commands(),
id="command-popup",
)
self.mount(popup)
popup.focus_search()
def action_cancel_or_dismiss(self) -> None:
"""Cancel input or dismiss popup."""
if self._command_popup_visible:
self._dismiss_command_popup()
else:
self.action_cancel_input()
def _dismiss_command_popup(self) -> None:
"""Dismiss the command popup."""
if not self._command_popup_visible:
return
try:
popup = self.query_one("#command-popup", CommandPopupWidget)
popup.remove()
except Exception:
pass
self._command_popup_visible = False
# Return focus to composer
composer = self.query_one("#composer", ComposerWidget)
composer.focus_input()
def on_command_popup_widget_command_selected(
self, event: CommandPopupWidget.CommandSelected
) -> None:
"""Handle command selection from popup."""
self._dismiss_command_popup()
# Execute the selected command
if self._handle_local_command(event.command, event.args):
return
# Forward to app for other commands
self.post_message(self.CommandExecuted(event.command, event.args))
def on_command_popup_widget_dismissed(
self, event: CommandPopupWidget.Dismissed
) -> None:
"""Handle popup dismissal."""
self._dismiss_command_popup()
# Auto-trigger event handlers from ComposerWidget
def on_composer_widget_backslash_typed(
self, event: ComposerWidget.BackslashTyped
) -> None:
"""Handle backslash typed - auto-show command popup."""
# Clear the backslash from composer and show popup
composer = self.query_one("#composer", ComposerWidget)
composer.set_text("")
self.action_show_commands()
def on_composer_widget_at_typed(
self, event: ComposerWidget.AtTyped
) -> None:
"""Handle @ typed - DO NOT block typing.
MINIMUM VIABLE UX: User can freely type @path/to/file.
The popup is optional and must NOT steal focus or block input.
For now, we simply allow typing to continue uninterrupted.
"""
# DO NOT show popup that blocks typing
# User can manually type: @README.md, @src/main.py, etc.
pass
def on_composer_widget_slash_typed(
self, event: ComposerWidget.SlashTyped
) -> None:
"""Handle / typed - auto-show command popup with filter."""
if not self._command_popup_visible:
self._command_popup_visible = True
popup = CommandPopupWidget(
commands=get_default_commands(),
id="command-popup",
)
self.mount(popup)
# Set filter to current query
popup.filter_text = event.query
def _show_file_popup(self, query: str, at_pos: int) -> None:
"""Show file autocomplete popup."""
if self._file_popup_visible:
# Update existing popup
try:
popup = self.query_one("#file-popup", FilePopupWidget)
popup.set_query(query, at_pos)
except Exception:
pass
return
self._file_popup_visible = True
# Create and mount file popup
import os
popup = FilePopupWidget(
root_dir=self._workspace or os.getcwd(),
at_pos=at_pos,
id="file-popup",
)
self.mount(popup)
popup.set_query(query, at_pos)
def _dismiss_file_popup(self) -> None:
"""Dismiss the file popup."""
if not self._file_popup_visible:
return
try:
popup = self.query_one("#file-popup", FilePopupWidget)
popup.remove()
except Exception:
pass
self._file_popup_visible = False
# Return focus to composer
composer = self.query_one("#composer", ComposerWidget)
composer.focus_input()
def on_file_popup_widget_file_selected(
self, event: FilePopupWidget.FileSelected
) -> None:
"""Handle file selection from popup."""
self._dismiss_file_popup()
# Insert the file path into composer
composer = self.query_one("#composer", ComposerWidget)
current_text = composer.text
# Replace from @ position to end with selected path
new_text = current_text[:event.at_pos] + "@" + event.path
composer.set_text(new_text)
composer.focus_input()
def on_file_popup_widget_dismissed(
self, event: FilePopupWidget.Dismissed
) -> None:
"""Handle file popup dismissal."""
self._dismiss_file_popup()
# Public methods for updating UI
async def add_user_message(self, content: str) -> None:
@@ -0,0 +1,198 @@
"""
Session Store for PraisonAI TUI.
DEPRECATED: This module is deprecated in favor of the core SDK session store.
Use `praisonaiagents.session.DefaultSessionStore` instead, which provides:
- JSON-based persistence (survives restarts)
- File locking for multi-process safety
- Automatic integration with Agent class
This TUI-specific in-memory store is kept for backward compatibility only.
New code should use the core SDK session store.
Example:
from praisonaiagents.session import get_default_session_store
store = get_default_session_store()
store.add_user_message("session-id", "Hello")
history = store.get_chat_history("session-id")
Maintains chat history in memory for session persistence.
This allows the AI to remember previous messages within a session.
"""
import warnings
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from datetime import datetime
logger = logging.getLogger(__name__)
@dataclass
class ChatMessage:
"""A single chat message."""
role: str # "user" or "assistant"
content: str
timestamp: datetime = field(default_factory=datetime.now)
run_id: Optional[str] = None
@dataclass
class Session:
"""A chat session with history."""
session_id: str
messages: List[ChatMessage] = field(default_factory=list)
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
def add_message(self, role: str, content: str, run_id: Optional[str] = None) -> None:
"""Add a message to the session."""
self.messages.append(ChatMessage(
role=role,
content=content,
run_id=run_id,
))
self.updated_at = datetime.now()
def get_history_for_llm(self, max_messages: int = 50) -> List[Dict[str, str]]:
"""
Get chat history in LLM-compatible format.
Returns list of {"role": "user/assistant", "content": "..."} dicts.
"""
# Get last N messages
recent = self.messages[-max_messages:] if len(self.messages) > max_messages else self.messages
return [{"role": msg.role, "content": msg.content} for msg in recent]
@property
def run_count(self) -> int:
"""Number of user messages (runs) in this session."""
return sum(1 for msg in self.messages if msg.role == "user")
class SessionStore:
"""
In-memory session store for TUI.
Maintains chat history across agent runs within a session.
"""
def __init__(self, max_sessions: int = 100, max_messages_per_session: int = 1000):
"""
Initialize session store.
Args:
max_sessions: Maximum number of sessions to keep in memory.
max_messages_per_session: Maximum messages per session.
"""
self._sessions: Dict[str, Session] = {}
self._max_sessions = max_sessions
self._max_messages = max_messages_per_session
def get_or_create_session(self, session_id: str) -> Session:
"""Get existing session or create new one."""
if session_id not in self._sessions:
self._sessions[session_id] = Session(session_id=session_id)
self._cleanup_old_sessions()
logger.debug(f"Created new session: {session_id}")
return self._sessions[session_id]
def add_user_message(self, session_id: str, content: str, run_id: Optional[str] = None) -> None:
"""Add a user message to the session."""
session = self.get_or_create_session(session_id)
session.add_message("user", content, run_id)
self._trim_session(session)
logger.debug(f"Added user message to session {session_id}: {content[:50]}...")
def add_assistant_message(self, session_id: str, content: str, run_id: Optional[str] = None) -> None:
"""Add an assistant message to the session."""
session = self.get_or_create_session(session_id)
session.add_message("assistant", content, run_id)
self._trim_session(session)
logger.debug(f"Added assistant message to session {session_id}: {content[:50]}...")
def get_chat_history(self, session_id: str, max_messages: int = 50) -> List[Dict[str, str]]:
"""
Get chat history for a session in LLM-compatible format.
Args:
session_id: The session ID.
max_messages: Maximum number of messages to return.
Returns:
List of {"role": "user/assistant", "content": "..."} dicts.
"""
if session_id not in self._sessions:
return []
return self._sessions[session_id].get_history_for_llm(max_messages)
def get_session(self, session_id: str) -> Optional[Session]:
"""Get a session by ID."""
return self._sessions.get(session_id)
def list_sessions(self, limit: int = 50) -> List[Dict]:
"""List all sessions with metadata."""
sessions = sorted(
self._sessions.values(),
key=lambda s: s.updated_at,
reverse=True
)[:limit]
return [
{
"session_id": s.session_id,
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M"),
"updated_at": s.updated_at.strftime("%Y-%m-%d %H:%M"),
"run_count": s.run_count,
}
for s in sessions
]
def delete_session(self, session_id: str) -> bool:
"""Delete a session."""
if session_id in self._sessions:
del self._sessions[session_id]
logger.debug(f"Deleted session: {session_id}")
return True
return False
def clear_session(self, session_id: str) -> None:
"""Clear all messages from a session."""
if session_id in self._sessions:
self._sessions[session_id].messages.clear()
self._sessions[session_id].updated_at = datetime.now()
logger.debug(f"Cleared session: {session_id}")
def _trim_session(self, session: Session) -> None:
"""Trim session to max messages."""
if len(session.messages) > self._max_messages:
# Keep the most recent messages
session.messages = session.messages[-self._max_messages:]
def _cleanup_old_sessions(self) -> None:
"""Remove oldest sessions if over limit."""
if len(self._sessions) > self._max_sessions:
# Sort by updated_at and remove oldest
sorted_sessions = sorted(
self._sessions.items(),
key=lambda x: x[1].updated_at
)
# Remove oldest sessions
to_remove = len(self._sessions) - self._max_sessions
for session_id, _ in sorted_sessions[:to_remove]:
del self._sessions[session_id]
logger.debug(f"Removed old session: {session_id}")
# Global session store instance for TUI
_session_store: Optional[SessionStore] = None
def get_session_store() -> SessionStore:
"""Get the global session store instance."""
global _session_store
if _session_store is None:
_session_store = SessionStore()
return _session_store
@@ -10,9 +10,8 @@ import time
try:
from textual.widget import Widget
from textual.widgets import Static, RichLog
from textual.containers import ScrollableContainer, Vertical
from textual.reactive import reactive
from textual.widgets import Static
from textual.containers import VerticalScroll
from textual.message import Message
from rich.markdown import Markdown
from rich.panel import Panel
@@ -49,23 +48,21 @@ class ChatMessage:
if TEXTUAL_AVAILABLE:
class ChatWidget(ScrollableContainer):
class ChatWidget(VerticalScroll):
"""
Widget for displaying chat history.
Widget for displaying chat history with scrollbar.
Features:
- Streaming message updates
- Markdown rendering
- Auto-scroll to bottom
- Message history management
Uses VerticalScroll for proper scrollbar support.
Messages are mounted directly to this container.
"""
DEFAULT_CSS = """
ChatWidget {
height: 100%;
height: 1fr;
border: solid $primary;
background: $surface;
padding: 0 1;
overflow-y: auto;
}
ChatWidget .message-user {
@@ -118,29 +115,30 @@ if TEXTUAL_AVAILABLE:
self._streaming_widgets: dict = {}
def compose(self):
"""Compose the widget."""
yield Vertical(id="chat-messages")
"""Compose the widget - no inner container needed."""
# Messages are mounted directly to this VerticalScroll
# Must yield from empty iterable (not return None)
yield from ()
async def add_message(self, message: ChatMessage) -> None:
"""Add a message to the chat."""
"""Add a message to the chat.
NEW BEHAVIOR: Messages render NEWEST at TOP.
This guarantees visibility without relying on scroll.
"""
self._messages.append(message)
# Trim old messages if needed
if len(self._messages) > self._max_messages:
self._messages = self._messages[-self._max_messages:]
# Create message widget
# Render message and scroll to show it
await self._render_message(message)
# Auto-scroll to bottom
self.scroll_end(animate=False)
self.post_message(self.MessageAdded(message))
async def _render_message(self, message: ChatMessage) -> None:
"""Render a single message."""
container = self.query_one("#chat-messages", Vertical)
"""Render a message and scroll to show it."""
# Create role label
role_style = {
"user": "bold cyan",
@@ -153,7 +151,7 @@ if TEXTUAL_AVAILABLE:
# Create content
try:
content = Markdown(message.content)
content = Markdown(message.content) if message.content else Text("")
except Exception:
content = Text(message.content)
@@ -170,7 +168,11 @@ if TEXTUAL_AVAILABLE:
classes=css_class,
)
await container.mount(panel)
# Mount directly to this VerticalScroll container
await self.mount(panel)
# Scroll to end to show new message
self.scroll_end(animate=False)
if message.is_streaming:
self._streaming_widgets[message.run_id] = widget_id
@@ -199,7 +201,7 @@ if TEXTUAL_AVAILABLE:
widget.update(Panel(rendered, title="Assistant", border_style="bold green"))
# Auto-scroll
# Scroll to end to keep streaming content visible
self.scroll_end(animate=False)
except Exception:
@@ -239,8 +241,8 @@ if TEXTUAL_AVAILABLE:
self._messages.clear()
self._streaming_widgets.clear()
container = self.query_one("#chat-messages", Vertical)
await container.remove_children()
# Remove all children from this container
await self.remove_children()
@property
def messages(self) -> List[ChatMessage]:
@@ -0,0 +1,263 @@
"""
Command Popup Widget for PraisonAI TUI.
Shows a searchable list of available commands when user types backslash.
Inspired by Claude Code's command discovery UX.
"""
from typing import List, Optional, Callable
from dataclasses import dataclass
try:
from textual.widget import Widget
from textual.widgets import Static, Input, OptionList
from textual.widgets.option_list import Option
from textual.containers import Vertical, Container
from textual.reactive import reactive
from textual.message import Message
from textual import events
from rich.text import Text
TEXTUAL_AVAILABLE = True
except ImportError:
TEXTUAL_AVAILABLE = False
Widget = object
Message = object
@dataclass
class CommandInfo:
"""Information about a command for display."""
name: str
description: str
aliases: List[str]
category: str = "general"
@property
def display_name(self) -> str:
"""Get display name with aliases."""
if self.aliases:
return f"{self.name} ({', '.join(self.aliases)})"
return self.name
if TEXTUAL_AVAILABLE:
class CommandPopupWidget(Container):
"""
Popup widget for command discovery and selection.
Features:
- Shows all available commands
- Searchable/filterable
- Keyboard navigation
- Category grouping
"""
DEFAULT_CSS = """
CommandPopupWidget {
layer: popup;
width: 60;
height: auto;
max-height: 20;
background: $surface;
border: solid $primary;
padding: 1;
margin: 0 0 0 2;
}
CommandPopupWidget #popup-title {
height: 1;
background: $primary;
color: $text;
text-align: center;
margin-bottom: 1;
}
CommandPopupWidget #popup-search {
height: 3;
margin-bottom: 1;
}
CommandPopupWidget #popup-list {
height: auto;
max-height: 12;
background: $surface-darken-1;
}
CommandPopupWidget .command-item {
padding: 0 1;
}
CommandPopupWidget .command-item:hover {
background: $primary-darken-1;
}
CommandPopupWidget #popup-hint {
height: 1;
color: $text-muted;
text-align: center;
margin-top: 1;
}
"""
class CommandSelected(Message):
"""Event when a command is selected."""
def __init__(self, command: str, args: str = ""):
self.command = command
self.args = args
super().__init__()
class Dismissed(Message):
"""Event when popup is dismissed."""
pass
# Reactive properties
filter_text: reactive[str] = reactive("")
def __init__(
self,
commands: Optional[List[CommandInfo]] = None,
name: Optional[str] = None,
id: Optional[str] = None,
classes: Optional[str] = None,
):
super().__init__(name=name, id=id, classes=classes)
self._commands = commands or []
self._filtered_commands: List[CommandInfo] = []
def compose(self):
"""Compose the widget."""
yield Static("Commands", id="popup-title")
yield Input(placeholder="Type to filter...", id="popup-search")
yield OptionList(id="popup-list")
yield Static("↑↓ Navigate • Enter Select • Esc Cancel", id="popup-hint")
def on_mount(self) -> None:
"""Handle mount."""
self._update_list()
# Focus the search input
search = self.query_one("#popup-search", Input)
search.focus()
def set_commands(self, commands: List[CommandInfo]) -> None:
"""Set the available commands."""
self._commands = commands
self._update_list()
def watch_filter_text(self, value: str) -> None:
"""React to filter text changes."""
self._update_list()
def _update_list(self) -> None:
"""Update the command list based on filter."""
filter_lower = self.filter_text.lower()
if filter_lower:
self._filtered_commands = [
cmd for cmd in self._commands
if (filter_lower in cmd.name.lower() or
filter_lower in cmd.description.lower() or
any(filter_lower in alias.lower() for alias in cmd.aliases))
]
else:
self._filtered_commands = self._commands.copy()
# Update the option list
try:
option_list = self.query_one("#popup-list", OptionList)
option_list.clear_options()
for cmd in self._filtered_commands:
# Create rich text for the option
text = Text()
text.append(f"/{cmd.name}", style="bold cyan")
if cmd.aliases:
text.append(f" ({', '.join(cmd.aliases)})", style="dim")
text.append(f"\n {cmd.description}", style="")
option_list.add_option(Option(text, id=cmd.name))
except Exception:
pass
def on_input_changed(self, event: Input.Changed) -> None:
"""Handle search input changes."""
if event.input.id == "popup-search":
self.filter_text = event.value
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle Enter in search input."""
if event.input.id == "popup-search":
# Select first matching command
if self._filtered_commands:
self._select_command(self._filtered_commands[0].name)
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
"""Handle command selection from list."""
if event.option.id:
self._select_command(str(event.option.id))
def on_key(self, event: events.Key) -> None:
"""Handle key events."""
if event.key == "escape":
self.post_message(self.Dismissed())
event.stop()
elif event.key == "down":
# Move focus to list if in search
try:
option_list = self.query_one("#popup-list", OptionList)
option_list.focus()
except Exception:
pass
elif event.key == "up":
# Move focus to search if at top of list
try:
search = self.query_one("#popup-search", Input)
search.focus()
except Exception:
pass
def _select_command(self, command_name: str) -> None:
"""Select a command and emit event."""
self.post_message(self.CommandSelected(command_name))
def focus_search(self) -> None:
"""Focus the search input."""
try:
search = self.query_one("#popup-search", Input)
search.focus()
except Exception:
pass
else:
class CommandPopupWidget:
"""Placeholder when Textual is not available."""
def __init__(self, *args, **kwargs):
raise ImportError(
"Textual is required for TUI. Install with: pip install praisonai[tui]"
)
# Default commands list (matches slash_commands.py registry)
DEFAULT_COMMANDS = [
CommandInfo("help", "Show help for commands", ["h", "?"]),
CommandInfo("clear", "Clear conversation history", ["reset"]),
CommandInfo("model", "Show or change the current model", ["m"]),
CommandInfo("cost", "Show session cost and token usage", ["usage", "stats"]),
CommandInfo("tokens", "Show token usage breakdown", []),
CommandInfo("queue", "Show queue status", ["q"]),
CommandInfo("cancel", "Cancel current operation", ["c"]),
CommandInfo("settings", "Show current settings", ["set"]),
CommandInfo("sessions", "Browse saved sessions", ["sess"]),
CommandInfo("tools", "Toggle tools panel", ["t"]),
CommandInfo("exit", "Exit the TUI", ["quit", "q"]),
CommandInfo("plan", "Create an execution plan", []),
CommandInfo("undo", "Undo the last change", []),
CommandInfo("diff", "Show git diff of changes", []),
CommandInfo("commit", "Commit changes with AI message", []),
CommandInfo("map", "Show repository map", ["repo"]),
]
def get_default_commands() -> List[CommandInfo]:
"""Get the default command list."""
return DEFAULT_COMMANDS.copy()
@@ -6,6 +6,7 @@ Input area for composing messages with slash command support.
from typing import Callable, List, Optional
import asyncio
import os
try:
from textual.widget import Widget
@@ -16,6 +17,31 @@ try:
from textual import events
from rich.text import Text
TEXTUAL_AVAILABLE = True
class SubmitTextArea(TextArea):
"""Custom TextArea that submits on Enter and inserts newline on Shift+Enter."""
class SubmitRequested(Message):
"""Message sent when Enter is pressed."""
pass
async def _on_key(self, event: events.Key) -> None:
"""Handle key events before TextArea processes them."""
if event.key == "enter":
# Don't let TextArea handle Enter - we want to submit
event.prevent_default()
event.stop()
self.post_message(self.SubmitRequested())
return
elif event.key == "shift+enter":
# Insert newline on Shift+Enter
event.prevent_default()
event.stop()
self.insert("\n")
return
# Let TextArea handle other keys
await super()._on_key(event)
except ImportError:
TEXTUAL_AVAILABLE = False
Widget = object
@@ -36,7 +62,7 @@ if TEXTUAL_AVAILABLE:
"""
BINDINGS = [
Binding("ctrl+enter", "submit", "Send", show=True),
Binding("enter", "submit", "Send", show=True),
Binding("escape", "cancel", "Cancel", show=False),
Binding("up", "history_prev", "Previous", show=False),
Binding("down", "history_next", "Next", show=False),
@@ -77,9 +103,26 @@ if TEXTUAL_AVAILABLE:
self.args = args
super().__init__()
class BackslashTyped(Message):
"""Event when backslash is typed - triggers command popup."""
pass
class AtTyped(Message):
"""Event when @ is typed - triggers file autocomplete."""
def __init__(self, query: str, cursor_pos: int):
self.query = query
self.cursor_pos = cursor_pos
super().__init__()
class SlashTyped(Message):
"""Event when / is typed - triggers command discovery."""
def __init__(self, query: str):
self.query = query
super().__init__()
def __init__(
self,
placeholder: str = "Type your message... (Ctrl+Enter to send, /help for commands)",
placeholder: str = "Type your message... (Enter to send, Shift+Enter for newline, /help for commands)",
multiline: bool = True,
name: Optional[str] = None,
id: Optional[str] = None,
@@ -95,7 +138,7 @@ if TEXTUAL_AVAILABLE:
def compose(self):
"""Compose the widget."""
yield TextArea(
yield SubmitTextArea(
"",
id="composer-input",
language=None,
@@ -103,36 +146,53 @@ if TEXTUAL_AVAILABLE:
def on_mount(self) -> None:
"""Handle mount event."""
text_area = self.query_one("#composer-input", TextArea)
text_area = self.query_one("#composer-input", SubmitTextArea)
text_area.focus()
def on_submit_text_area_submit_requested(self, event: SubmitTextArea.SubmitRequested) -> None:
"""Handle submit request from TextArea."""
self.action_submit()
async def on_text_area_changed(self, event: TextArea.Changed) -> None:
"""Handle text changes."""
content = event.text_area.text
# Detect slash commands
# Detect backslash - auto-trigger command popup
if content == "\\":
self.post_message(self.BackslashTyped())
return
# Detect @ for file autocomplete
if "@" in content:
# Find the @ and extract query
at_pos = content.rfind("@")
if at_pos >= 0:
query = content[at_pos + 1:]
# Only trigger if no space after @
if " " not in query or query.endswith(" ") is False:
self.post_message(self.AtTyped(query, at_pos))
# Detect slash commands - auto-trigger command discovery
if content.startswith("/"):
parts = content[1:].split(maxsplit=1)
if parts:
command = parts[0]
args = parts[1] if len(parts) > 1 else ""
self.post_message(self.CommandDetected(command, args))
# Also trigger slash popup for discovery
self.post_message(self.SlashTyped(content[1:]))
def on_key(self, event: events.Key) -> None:
"""Handle key events."""
# Submit on Enter (single line mode) or Ctrl+Enter
if event.key == "enter" and not self._multiline:
event.prevent_default()
self.action_submit()
elif event.key == "ctrl+enter":
event.prevent_default()
self.action_submit()
"""Handle key events at widget level (backup handler)."""
# These are handled by SubmitTextArea now, but keep as backup
pass
def action_submit(self) -> None:
"""Submit the current input."""
if self._is_processing:
return
"""Submit the current input.
Note: We allow submitting even while processing to enable
queueing multiple messages.
"""
text_area = self.query_one("#composer-input", TextArea)
content = text_area.text.strip()
@@ -144,8 +204,8 @@ if TEXTUAL_AVAILABLE:
self._history.append(content)
self._history_index = -1
# Check if it's a command
is_command = content.startswith("/")
# Check if it's a command (/ or : prefix)
is_command = content.startswith("/") or content.startswith(":")
# Clear input
text_area.clear()
@@ -192,10 +252,12 @@ if TEXTUAL_AVAILABLE:
text_area.insert(self._current_input)
def set_processing(self, processing: bool) -> None:
"""Set processing state."""
"""Set processing state.
Note: We no longer disable the text area during processing
to allow users to queue additional messages while waiting.
"""
self._is_processing = processing
text_area = self.query_one("#composer-input", TextArea)
text_area.disabled = processing
@property
def text(self) -> str:
@@ -214,6 +276,12 @@ if TEXTUAL_AVAILABLE:
"""Focus the input area."""
text_area = self.query_one("#composer-input", TextArea)
text_area.focus()
def set_text(self, value: str) -> None:
"""Set the text content and move cursor to end."""
text_area = self.query_one("#composer-input", SubmitTextArea)
text_area.clear()
text_area.insert(value)
else:
class ComposerWidget:
@@ -0,0 +1,207 @@
"""
File Autocomplete Popup Widget for PraisonAI TUI.
Shows file/directory suggestions when user types @.
Reuses FileSearchService from at_mentions.py.
"""
from typing import List, Optional
from dataclasses import dataclass
import os
try:
from textual.widget import Widget
from textual.widgets import Static, Input, OptionList
from textual.widgets.option_list import Option
from textual.containers import Container
from textual.reactive import reactive
from textual.message import Message
from textual import events
from rich.text import Text
TEXTUAL_AVAILABLE = True
except ImportError:
TEXTUAL_AVAILABLE = False
Widget = object
Message = object
@dataclass
class FileInfo:
"""Information about a file for display."""
path: str
file_type: str # "file" or "directory"
score: int = 100
@property
def icon(self) -> str:
"""Get icon for file type."""
return "📁 " if self.file_type == "directory" else "📄 "
@property
def display(self) -> str:
"""Get display string."""
return f"{self.icon}{self.path}"
if TEXTUAL_AVAILABLE:
# Import FileSearchService from at_mentions
try:
from ..at_mentions import FileSearchService, detect_at_mention
FILE_SEARCH_AVAILABLE = True
except ImportError:
FILE_SEARCH_AVAILABLE = False
FileSearchService = None
class FilePopupWidget(Container):
"""
Popup widget for file autocomplete.
Features:
- Shows files/directories matching query
- Fuzzy search
- Keyboard navigation
- Supports ~/ and absolute paths
"""
DEFAULT_CSS = """
FilePopupWidget {
layer: popup;
width: 60;
height: auto;
max-height: 15;
background: $surface;
border: solid $accent;
padding: 1;
margin: 0 0 0 2;
}
FilePopupWidget #file-popup-title {
height: 1;
background: $accent;
color: $text;
text-align: center;
margin-bottom: 1;
}
FilePopupWidget #file-popup-list {
height: auto;
max-height: 10;
background: $surface-darken-1;
}
FilePopupWidget #file-popup-hint {
height: 1;
color: $text-muted;
text-align: center;
margin-top: 1;
}
"""
class FileSelected(Message):
"""Event when a file is selected."""
def __init__(self, path: str, at_pos: int):
self.path = path
self.at_pos = at_pos
super().__init__()
class Dismissed(Message):
"""Event when popup is dismissed."""
pass
# Reactive properties
filter_text: reactive[str] = reactive("")
def __init__(
self,
root_dir: Optional[str] = None,
at_pos: int = 0,
name: Optional[str] = None,
id: Optional[str] = None,
classes: Optional[str] = None,
):
super().__init__(name=name, id=id, classes=classes)
self._root_dir = root_dir or os.getcwd()
self._at_pos = at_pos
self._files: List[FileInfo] = []
self._file_service = None
if FILE_SEARCH_AVAILABLE and FileSearchService:
self._file_service = FileSearchService(self._root_dir)
def compose(self):
"""Compose the widget."""
yield Static("📂 Files", id="file-popup-title")
yield OptionList(id="file-popup-list")
yield Static("↑↓ Navigate • Enter Select • Esc Cancel", id="file-popup-hint")
def on_mount(self) -> None:
"""Handle mount."""
self._update_list()
def set_query(self, query: str, at_pos: int) -> None:
"""Set the search query."""
self.filter_text = query
self._at_pos = at_pos
def watch_filter_text(self, value: str) -> None:
"""React to filter text changes."""
self._update_list()
def _update_list(self) -> None:
"""Update the file list based on filter."""
if not self._file_service:
return
# Search files
results = self._file_service.search(self.filter_text, max_results=15)
self._files = [
FileInfo(
path=r.path,
file_type=r.file_type,
score=r.score
)
for r in results
]
# Update the option list
try:
option_list = self.query_one("#file-popup-list", OptionList)
option_list.clear_options()
for file_info in self._files:
text = Text()
text.append(file_info.icon, style="bold")
text.append(file_info.path, style="cyan" if file_info.file_type == "directory" else "")
option_list.add_option(Option(text, id=file_info.path))
except Exception:
pass
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
"""Handle file selection from list."""
if event.option.id:
self._select_file(str(event.option.id))
def on_key(self, event: events.Key) -> None:
"""Handle key events."""
if event.key == "escape":
self.post_message(self.Dismissed())
event.stop()
elif event.key == "enter":
# Select first item if available
if self._files:
self._select_file(self._files[0].path)
event.stop()
def _select_file(self, path: str) -> None:
"""Select a file and emit event."""
self.post_message(self.FileSelected(path, self._at_pos))
else:
class FilePopupWidget:
"""Placeholder when Textual is not available."""
def __init__(self, *args, **kwargs):
raise ImportError(
"Textual is required for TUI. Install with: pip install praisonai[tui]"
)
@@ -93,6 +93,19 @@ if TEXTUAL_AVAILABLE:
self.run_id = run_id
super().__init__()
class DeleteRequested(Message):
"""Event when delete is requested."""
def __init__(self, run_id: str):
self.run_id = run_id
super().__init__()
class EditRequested(Message):
"""Event when edit is requested."""
def __init__(self, run_id: str, new_content: str):
self.run_id = run_id
self.new_content = new_content
super().__init__()
queued_count: reactive[int] = reactive(0)
running_count: reactive[int] = reactive(0)
@@ -186,6 +199,16 @@ if TEXTUAL_AVAILABLE:
if self._selected_run_id:
self.post_message(self.RetryRequested(self._selected_run_id))
def delete_selected(self) -> None:
"""Delete the selected run."""
if self._selected_run_id:
self.post_message(self.DeleteRequested(self._selected_run_id))
def edit_selected(self, new_content: str) -> None:
"""Edit the selected run's input content."""
if self._selected_run_id:
self.post_message(self.EditRequested(self._selected_run_id, new_content))
@property
def selected_run_id(self) -> Optional[str]:
"""Get the selected run ID."""
@@ -4,14 +4,14 @@ Tool Panel Widget for PraisonAI TUI.
Displays tool execution status and approval dialogs.
"""
from typing import List, Optional
from typing import Any, List, Optional
from dataclasses import dataclass, field
import time
try:
from textual.widget import Widget
from textual.widgets import Static, ListView, ListItem
from textual.containers import Vertical
from textual.containers import Vertical, ScrollableContainer
from textual.reactive import reactive
from textual.message import Message
from rich.text import Text
@@ -61,6 +61,17 @@ if TEXTUAL_AVAILABLE:
padding: 0 1;
}
ToolPanelWidget #available-tools-scroll {
height: 1fr;
min-height: 5;
scrollbar-gutter: stable;
}
ToolPanelWidget #tool-list-scroll {
height: auto;
max-height: 10;
}
ToolPanelWidget .tool-pending {
color: $warning;
}
@@ -82,6 +93,11 @@ if TEXTUAL_AVAILABLE:
border: solid $warning;
padding: 1;
}
ToolPanelWidget .tools-count {
color: $text-muted;
text-style: italic;
}
"""
class ApprovalResponse(Message):
@@ -102,15 +118,58 @@ if TEXTUAL_AVAILABLE:
self._calls: List[ToolCall] = []
self._max_items = max_items
self._pending_approval: Optional[ToolCall] = None
self._available_tools: List[str] = []
def compose(self):
"""Compose the widget."""
yield Static("Tools", id="tool-title", classes="tool-header")
yield Vertical(id="tool-list")
yield ScrollableContainer(Vertical(id="tool-list"), id="tool-list-scroll")
yield Static("", id="tools-count", classes="tools-count")
yield ScrollableContainer(Vertical(id="available-tools"), id="available-tools-scroll")
def on_mount(self) -> None:
"""Handle mount."""
self._update_display()
self._update_available_tools_display()
def set_available_tools(self, tools: List[Any]) -> None:
"""Set the list of available tools to display."""
self._available_tools = []
for tool in tools:
if hasattr(tool, '__name__'):
self._available_tools.append(tool.__name__)
elif hasattr(tool, 'name'):
self._available_tools.append(tool.name)
elif callable(tool):
self._available_tools.append(str(tool))
else:
self._available_tools.append(str(type(tool).__name__))
self._update_available_tools_display()
def _update_available_tools_display(self) -> None:
"""Update the available tools display."""
try:
container = self.query_one("#available-tools", Vertical)
count_widget = self.query_one("#tools-count", Static)
except Exception:
return
container.remove_children()
if not self._available_tools:
container.mount(Static(Text("No tools loaded", style="dim italic")))
count_widget.update("")
return
# Update count display
count_widget.update(f"{len(self._available_tools)} tools available")
# Show ALL tools with scrolling support
for tool_name in sorted(self._available_tools):
text = Text()
text.append("• ", style="cyan")
text.append(tool_name, style="white")
container.mount(Static(text))
def add_call(self, call: ToolCall) -> None:
"""Add a tool call."""
+7 -1
View File
@@ -25,9 +25,15 @@ LEGACY_COMMANDS = {
# Typer commands that have been implemented
TYPER_COMMANDS = {
# Core commands
'config', 'traces', 'env', 'session', 'completion', 'version',
'debug', 'lsp', 'diag', 'doctor', 'acp', 'mcp', 'serve', 'schedule', 'run',
'tui', 'queue', # TUI and queue management commands
'tui', 'queue', 'profile', 'benchmark',
# Previously legacy-only commands (now in Typer)
'chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research',
'memory', 'workflow', 'tools', 'knowledge', 'deploy', 'agents', 'skills',
'eval', 'templates', 'recipe', 'todo', 'docs', 'commit', 'hooks', 'rules',
'registry', 'package', 'endpoints',
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,13 @@
"""
Unified Session Management for PraisonAI CLI.
Provides persistent session storage shared between TUI and --interactive mode.
"""
from .unified import UnifiedSession, UnifiedSessionStore, get_session_store
__all__ = [
"UnifiedSession",
"UnifiedSessionStore",
"get_session_store",
]
@@ -0,0 +1,306 @@
"""
Unified Session for PraisonAI CLI.
Provides persistent session storage shared between TUI and --interactive mode.
Uses JSON-based persistence with file locking for multi-process safety.
"""
import json
import logging
import os
import uuid
import fcntl
from dataclasses import dataclass, field, asdict
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Default session directory
DEFAULT_SESSION_DIR = Path.home() / ".praison" / "sessions"
@dataclass
class UnifiedSession:
"""
Unified session for TUI and --interactive mode.
Stores conversation history, metadata, and token/cost stats.
Persists to disk automatically.
"""
session_id: str
workspace: str = field(default_factory=os.getcwd)
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
messages: List[Dict[str, str]] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
# Token and cost tracking
total_input_tokens: int = 0
total_output_tokens: int = 0
total_cost: float = 0.0
request_count: int = 0
# Model info
current_model: str = "gpt-4o-mini"
def add_message(self, role: str, content: str) -> None:
"""Add a message to the session."""
self.messages.append({
"role": role,
"content": content,
"timestamp": datetime.now().isoformat(),
})
self.updated_at = datetime.now().isoformat()
def add_user_message(self, content: str) -> None:
"""Add a user message."""
self.add_message("user", content)
def add_assistant_message(self, content: str) -> None:
"""Add an assistant message."""
self.add_message("assistant", content)
def get_chat_history(self, max_messages: int = 50) -> List[Dict[str, str]]:
"""
Get chat history in LLM-compatible format.
Returns list of {"role": "user/assistant", "content": "..."} dicts.
"""
recent = self.messages[-max_messages:] if len(self.messages) > max_messages else self.messages
return [{"role": msg["role"], "content": msg["content"]} for msg in recent]
def update_stats(self, input_tokens: int, output_tokens: int, cost: float = 0.0) -> None:
"""Update token and cost statistics."""
self.total_input_tokens += input_tokens
self.total_output_tokens += output_tokens
self.total_cost += cost
self.request_count += 1
self.updated_at = datetime.now().isoformat()
def clear_messages(self) -> None:
"""Clear all messages from the session."""
self.messages.clear()
self.updated_at = datetime.now().isoformat()
def to_dict(self) -> Dict[str, Any]:
"""Convert session to dictionary."""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "UnifiedSession":
"""Create session from dictionary."""
return cls(**data)
@property
def message_count(self) -> int:
"""Number of messages in the session."""
return len(self.messages)
@property
def user_message_count(self) -> int:
"""Number of user messages in the session."""
return sum(1 for msg in self.messages if msg.get("role") == "user")
class UnifiedSessionStore:
"""
Persistent session store with file locking.
Stores sessions as JSON files in ~/.praison/sessions/
"""
def __init__(self, session_dir: Optional[Path] = None):
"""
Initialize session store.
Args:
session_dir: Directory to store sessions. Defaults to ~/.praison/sessions/
"""
self.session_dir = session_dir or DEFAULT_SESSION_DIR
self.session_dir.mkdir(parents=True, exist_ok=True)
self._cache: Dict[str, UnifiedSession] = {}
self._last_session_id: Optional[str] = None
def _get_session_path(self, session_id: str) -> Path:
"""Get the file path for a session."""
return self.session_dir / f"{session_id}.json"
def _get_last_session_path(self) -> Path:
"""Get the path to the last session marker file."""
return self.session_dir / ".last_session"
def save(self, session: UnifiedSession) -> None:
"""
Save a session to disk with file locking.
Args:
session: Session to save
"""
path = self._get_session_path(session.session_id)
session.updated_at = datetime.now().isoformat()
try:
with open(path, 'w') as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
json.dump(session.to_dict(), f, indent=2)
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
# Update cache
self._cache[session.session_id] = session
# Update last session marker
self._update_last_session(session.session_id)
logger.debug(f"Saved session: {session.session_id}")
except Exception as e:
logger.error(f"Failed to save session {session.session_id}: {e}")
raise
def load(self, session_id: str) -> Optional[UnifiedSession]:
"""
Load a session from disk.
Args:
session_id: Session ID to load
Returns:
Session if found, None otherwise
"""
# Check cache first
if session_id in self._cache:
return self._cache[session_id]
path = self._get_session_path(session_id)
if not path.exists():
return None
try:
with open(path, 'r') as f:
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
try:
data = json.load(f)
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
session = UnifiedSession.from_dict(data)
self._cache[session_id] = session
logger.debug(f"Loaded session: {session_id}")
return session
except Exception as e:
logger.error(f"Failed to load session {session_id}: {e}")
return None
def get_or_create(self, session_id: Optional[str] = None) -> UnifiedSession:
"""
Get existing session or create new one.
Args:
session_id: Optional session ID. If None, creates new session.
Returns:
Session instance
"""
if session_id:
session = self.load(session_id)
if session:
return session
# Create new session
new_id = session_id or str(uuid.uuid4())[:8]
session = UnifiedSession(session_id=new_id)
self.save(session)
return session
def delete(self, session_id: str) -> bool:
"""
Delete a session.
Args:
session_id: Session ID to delete
Returns:
True if deleted, False if not found
"""
path = self._get_session_path(session_id)
if path.exists():
path.unlink()
self._cache.pop(session_id, None)
logger.debug(f"Deleted session: {session_id}")
return True
return False
def list_sessions(self, limit: int = 50) -> List[Dict[str, Any]]:
"""
List all sessions with metadata.
Args:
limit: Maximum number of sessions to return
Returns:
List of session metadata dicts
"""
sessions = []
for path in self.session_dir.glob("*.json"):
try:
with open(path, 'r') as f:
data = json.load(f)
sessions.append({
"session_id": data.get("session_id"),
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"message_count": len(data.get("messages", [])),
"workspace": data.get("workspace"),
})
except Exception as e:
logger.warning(f"Failed to read session {path}: {e}")
# Sort by updated_at descending
sessions.sort(key=lambda s: s.get("updated_at", ""), reverse=True)
return sessions[:limit]
def _update_last_session(self, session_id: str) -> None:
"""Update the last session marker."""
try:
path = self._get_last_session_path()
with open(path, 'w') as f:
f.write(session_id)
self._last_session_id = session_id
except Exception as e:
logger.warning(f"Failed to update last session marker: {e}")
def get_last_session_id(self) -> Optional[str]:
"""Get the ID of the last used session."""
if self._last_session_id:
return self._last_session_id
path = self._get_last_session_path()
if path.exists():
try:
self._last_session_id = path.read_text().strip()
return self._last_session_id
except Exception as e:
logger.warning(f"Failed to read last session marker: {e}")
return None
def get_last_session(self) -> Optional[UnifiedSession]:
"""Get the last used session."""
session_id = self.get_last_session_id()
if session_id:
return self.load(session_id)
return None
# Global session store instance
_session_store: Optional[UnifiedSessionStore] = None
def get_session_store() -> UnifiedSessionStore:
"""Get the global session store instance."""
global _session_store
if _session_store is None:
_session_store = UnifiedSessionStore()
return _session_store
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==2.9.2 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==3.1.2 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -1,24 +1,28 @@
# Only try to import autogen_tools if either CrewAI or AG2 is available
CREWAI_AVAILABLE = False
AUTOGEN_AVAILABLE = False
# Lazy availability checks using find_spec (no actual import)
# This avoids the ~3200ms crewai import at CLI startup
import importlib.util
CREWAI_AVAILABLE = importlib.util.find_spec("crewai") is not None
AUTOGEN_AVAILABLE = importlib.util.find_spec("autogen") is not None
PRAISONAI_TOOLS_AVAILABLE = False
try:
from crewai import Agent, Task, Crew
CREWAI_AVAILABLE = True
except ImportError:
pass
# Lazy import helper - only imports when actually needed
def _get_autogen_tools():
"""Lazy import autogen_tools only when needed."""
global PRAISONAI_TOOLS_AVAILABLE
if CREWAI_AVAILABLE or AUTOGEN_AVAILABLE:
try:
from . import autogen_tools
PRAISONAI_TOOLS_AVAILABLE = True
return autogen_tools
except ImportError:
pass
return None
try:
import autogen
AUTOGEN_AVAILABLE = True
except ImportError:
pass
# Only try to import tools if a framework is available
if CREWAI_AVAILABLE or AUTOGEN_AVAILABLE:
try:
from .autogen_tools import *
PRAISONAI_TOOLS_AVAILABLE = True
except ImportError:
pass
# For backward compatibility, provide __getattr__ for lazy access
def __getattr__(name):
"""Lazy load autogen_tools exports on demand."""
tools_module = _get_autogen_tools()
if tools_module and hasattr(tools_module, name):
return getattr(tools_module, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+11 -1
View File
@@ -1,2 +1,12 @@
# praisonai/inc/__init__.py
from .models import PraisonAIModel
# Lazy loading - PraisonAIModel is only imported when accessed
# This avoids the ~3500ms langchain_openai import at CLI startup
def __getattr__(name):
"""Lazy load PraisonAIModel only when accessed."""
if name == "PraisonAIModel":
from .models import PraisonAIModel
return PraisonAIModel
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = ["PraisonAIModel"]
+50 -52
View File
@@ -1,7 +1,11 @@
# praisonai/inc/models.py
# PERFORMANCE OPTIMIZED: Removed all LangChain imports
# Uses direct OpenAI SDK for fast startup (~600ms vs ~3200ms with langchain_openai)
import os
import logging
from urllib.parse import urlparse
import importlib.util
logger = logging.getLogger(__name__)
_loglevel = os.environ.get('LOGLEVEL', 'INFO').strip().upper() or 'INFO'
logging.basicConfig(level=_loglevel, format='%(asctime)s - %(levelname)s - %(message)s')
@@ -9,30 +13,33 @@ logging.basicConfig(level=_loglevel, format='%(asctime)s - %(levelname)s - %(mes
# Constants
LOCAL_SERVER_API_KEY_PLACEHOLDER = "not-needed"
# Conditionally import modules based on availability
try:
from langchain_openai import ChatOpenAI # pip install langchain-openai
OPENAI_AVAILABLE = True
except ImportError:
OPENAI_AVAILABLE = False
# Use find_spec for fast availability checks (no actual import)
# This avoids the ~3200ms langchain_openai import at module load
OPENAI_AVAILABLE = importlib.util.find_spec("openai") is not None
GOOGLE_GENAI_AVAILABLE = importlib.util.find_spec("google.generativeai") is not None
ANTHROPIC_AVAILABLE = importlib.util.find_spec("anthropic") is not None
COHERE_AVAILABLE = importlib.util.find_spec("cohere") is not None
try:
from langchain_google_genai import ChatGoogleGenerativeAI # pip install langchain-google-genai
GOOGLE_GENAI_AVAILABLE = True
except ImportError:
GOOGLE_GENAI_AVAILABLE = False
# Lazy import helpers for provider SDKs
def _get_openai_client():
"""Lazy import OpenAI client."""
from openai import OpenAI
return OpenAI
try:
from langchain_anthropic import ChatAnthropic # pip install langchain-anthropic
ANTHROPIC_AVAILABLE = True
except ImportError:
ANTHROPIC_AVAILABLE = False
def _get_google_genai():
"""Lazy import Google Generative AI."""
import google.generativeai as genai
return genai
try:
from langchain_cohere import ChatCohere # pip install langchain-cohere
COHERE_AVAILABLE = True
except ImportError:
COHERE_AVAILABLE = False
def _get_anthropic_client():
"""Lazy import Anthropic client."""
from anthropic import Anthropic
return Anthropic
def _get_cohere_client():
"""Lazy import Cohere client."""
import cohere
return cohere
class PraisonAIModel:
def __init__(self, model=None, api_key_var=None, base_url=None, api_key=None):
@@ -100,56 +107,47 @@ class PraisonAIModel:
def get_model(self):
"""
Returns an instance of the langchain Chat client with the configured parameters.
Returns an instance of the native SDK client with the configured parameters.
PERFORMANCE: Uses direct SDK clients instead of LangChain wrappers.
This saves ~2600ms on import (OpenAI SDK ~600ms vs langchain_openai ~3200ms).
Returns:
Chat: An instance of the langchain Chat client.
Client: An instance of the native SDK client (OpenAI, Anthropic, etc.)
"""
if self.model.startswith("google/"):
if GOOGLE_GENAI_AVAILABLE:
return ChatGoogleGenerativeAI(
model=self.model_name,
google_api_key=self.api_key
)
genai = _get_google_genai()
genai.configure(api_key=self.api_key)
return genai.GenerativeModel(self.model_name)
else:
raise ImportError(
"Required Langchain Integration 'langchain-google-genai' not found. "
"Please install with 'pip install langchain-google-genai'"
"Required package 'google-generativeai' not found. "
"Please install with 'pip install google-generativeai'"
)
elif self.model.startswith("cohere/"):
if COHERE_AVAILABLE:
return ChatCohere(
model=self.model_name,
cohere_api_key=self.api_key,
)
cohere = _get_cohere_client()
return cohere.Client(api_key=self.api_key)
else:
raise ImportError(
"Required Langchain Integration 'langchain-cohere' not found. "
"Please install with 'pip install langchain-cohere'"
"Required package 'cohere' not found. "
"Please install with 'pip install cohere'"
)
elif self.model.startswith("anthropic/"):
if ANTHROPIC_AVAILABLE:
return ChatAnthropic(
model=self.model_name,
anthropic_api_key=self.api_key,
)
Anthropic = _get_anthropic_client()
return Anthropic(api_key=self.api_key)
else:
raise ImportError(
"Required Langchain Integration 'langchain-anthropic' not found. "
"Please install with 'pip install langchain-anthropic'"
"Required package 'anthropic' not found. "
"Please install with 'pip install anthropic'"
)
elif OPENAI_AVAILABLE:
chat_kwargs = {
"model": self.model_name,
"api_key": self.api_key,
"base_url": self.base_url,
}
# Certain OpenAI models (e.g., gpt-5-* family) only allow temperature=1
if isinstance(self.model_name, str) and self.model_name.startswith("gpt-5"):
chat_kwargs["temperature"] = 1
return ChatOpenAI(**chat_kwargs)
OpenAI = _get_openai_client()
return OpenAI(api_key=self.api_key, base_url=self.base_url)
else:
raise ImportError(
"Required Langchain Integration 'langchain-openai' not found. "
"Please install with 'pip install langchain-openai'"
"Required package 'openai' not found. "
"Please install with 'pip install openai'"
)
+462 -160
View File
@@ -1,34 +1,24 @@
# Standard library imports
"""PraisonAI Chat - Optimized Chainlit Application
This is the optimized chat application with:
- Lazy imports for fast startup
- Agent reuse between messages (persistent agent per session)
- Default ACP/LSP tools with trust mode
- Session management
- Profiling hooks (optional via PRAISON_CHAT_PROFILE=1)
Performance improvements over original chat.py:
- Deferred database initialization
- Lazy loading of PIL, Tavily, crawl4ai, litellm
- Agent reuse (no re-creation per message)
- Interactive tools loaded once per session
"""
# Standard library imports (minimal at top level)
import os
from datetime import datetime
from typing import Dict, Optional
import logging
import json
import asyncio
import io
import base64
import importlib.util
import inspect
# Third-party imports
from dotenv import load_dotenv
from PIL import Image
from tavily import TavilyClient
from crawl4ai import AsyncWebCrawler
# Local application/library imports
import chainlit as cl
from chainlit.input_widget import TextInput
from chainlit.types import ThreadDict
import chainlit.data as cl_data
from litellm import acompletion
from literalai.helper import utc_now
from db import DatabaseManager
# Load environment variables
load_dotenv()
# Logging configuration
# Set up minimal logging first
logger = logging.getLogger(__name__)
log_level = os.getenv("LOGLEVEL", "INFO").upper() or "INFO"
logger.handlers = []
@@ -39,52 +29,204 @@ console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
logger.setLevel(log_level)
# Chainlit must be imported early (required by decorators)
import chainlit as cl
from chainlit.input_widget import TextInput, Switch
from chainlit.types import ThreadDict
import chainlit.data as cl_data
# Profiling support (optional)
PROFILING_ENABLED = os.getenv("PRAISON_CHAT_PROFILE", "").lower() in ("1", "true", "yes")
_profile_data = {}
def _profile_start(name: str):
"""Start profiling a section."""
if PROFILING_ENABLED:
import time
_profile_data[name] = {"start": time.perf_counter()}
def _profile_end(name: str):
"""End profiling a section and log."""
if PROFILING_ENABLED and name in _profile_data:
import time
elapsed = time.perf_counter() - _profile_data[name]["start"]
_profile_data[name]["elapsed"] = elapsed
logger.info(f"[PROFILE] {name}: {elapsed*1000:.2f}ms")
# Lazy import cache
_cached_modules = {}
def _get_json():
if 'json' not in _cached_modules:
import json
_cached_modules['json'] = json
return _cached_modules['json']
def _get_asyncio():
if 'asyncio' not in _cached_modules:
import asyncio
_cached_modules['asyncio'] = asyncio
return _cached_modules['asyncio']
def _get_datetime():
if 'datetime' not in _cached_modules:
from datetime import datetime
_cached_modules['datetime'] = datetime
return _cached_modules['datetime']
def _get_io():
if 'io' not in _cached_modules:
import io
_cached_modules['io'] = io
return _cached_modules['io']
def _get_base64():
if 'base64' not in _cached_modules:
import base64
_cached_modules['base64'] = base64
return _cached_modules['base64']
def _get_importlib():
if 'importlib' not in _cached_modules:
import importlib.util
_cached_modules['importlib'] = importlib
return _cached_modules['importlib']
def _get_inspect():
if 'inspect' not in _cached_modules:
import inspect
_cached_modules['inspect'] = inspect
return _cached_modules['inspect']
def _get_pil_image():
if 'PIL.Image' not in _cached_modules:
_profile_start("import_pil")
from PIL import Image
_cached_modules['PIL.Image'] = Image
_profile_end("import_pil")
return _cached_modules['PIL.Image']
def _get_acompletion():
if 'acompletion' not in _cached_modules:
_profile_start("import_litellm")
from litellm import acompletion
_cached_modules['acompletion'] = acompletion
_profile_end("import_litellm")
return _cached_modules['acompletion']
def _get_tavily_client():
if 'TavilyClient' not in _cached_modules:
try:
_profile_start("import_tavily")
from tavily import TavilyClient
_cached_modules['TavilyClient'] = TavilyClient
_profile_end("import_tavily")
except ImportError:
_cached_modules['TavilyClient'] = None
return _cached_modules['TavilyClient']
def _get_async_web_crawler():
if 'AsyncWebCrawler' not in _cached_modules:
try:
_profile_start("import_crawl4ai")
from crawl4ai import AsyncWebCrawler
_cached_modules['AsyncWebCrawler'] = AsyncWebCrawler
_profile_end("import_crawl4ai")
except ImportError:
_cached_modules['AsyncWebCrawler'] = None
return _cached_modules['AsyncWebCrawler']
def _get_praisonai_agent():
"""Lazy load PraisonAI Agent for reuse."""
if 'Agent' not in _cached_modules:
try:
_profile_start("import_praisonai_agent")
from praisonaiagents import Agent
_cached_modules['Agent'] = Agent
_profile_end("import_praisonai_agent")
except ImportError:
_cached_modules['Agent'] = None
return _cached_modules['Agent']
def _get_interactive_tools():
"""Lazy load interactive tools (ACP, LSP, basic) with trust mode."""
if 'interactive_tools' not in _cached_modules:
try:
_profile_start("load_interactive_tools")
from praisonai.cli.features.interactive_tools import get_interactive_tools, ToolConfig
config = ToolConfig.from_env()
config.workspace = os.getcwd()
config.approval_mode = "auto" # Trust mode - auto-approve all tool executions
tools = get_interactive_tools(config=config)
_cached_modules['interactive_tools'] = tools
_profile_end("load_interactive_tools")
logger.info(f"Loaded {len(tools)} interactive tools (ACP, LSP, basic) with trust mode")
except ImportError as e:
logger.debug(f"Interactive tools not available: {e}")
_cached_modules['interactive_tools'] = []
return _cached_modules['interactive_tools']
# Deferred database initialization
_db_manager = None
def _get_db_manager():
"""Lazy initialize database manager."""
global _db_manager
if _db_manager is None:
_profile_start("init_database")
# Import from the same directory as this module
import sys
ui_dir = os.path.dirname(os.path.abspath(__file__))
if ui_dir not in sys.path:
sys.path.insert(0, ui_dir)
from db import DatabaseManager
_db_manager = DatabaseManager()
_db_manager.initialize()
cl_data._data_layer = _db_manager
_profile_end("init_database")
return _db_manager
# Load environment variables lazily
def _ensure_env_loaded():
if 'env_loaded' not in _cached_modules:
from dotenv import load_dotenv
load_dotenv()
_cached_modules['env_loaded'] = True
# Auth secret setup (required early)
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
if not CHAINLIT_AUTH_SECRET:
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
now = utc_now()
create_step_counter = 0
# Initialize database
db_manager = DatabaseManager()
db_manager.initialize()
def save_setting(key: str, value: str):
"""Save a setting to the database"""
asyncio = _get_asyncio()
db_manager = _get_db_manager()
asyncio.run(db_manager.save_setting(key, value))
def load_setting(key: str) -> str:
"""Load a setting from the database"""
asyncio = _get_asyncio()
db_manager = _get_db_manager()
return asyncio.run(db_manager.load_setting(key))
cl_data._data_layer = db_manager
def load_custom_tools():
"""
Load custom tools from tools.py if it exists.
Resolution order:
1. PRAISONAI_TOOLS_PATH environment variable (file or directory)
2. ./tools.py in current working directory
3. No tools (silent, no warning)
"""
"""Load custom tools from tools.py if it exists."""
custom_tools = {}
importlib = _get_importlib()
inspect = _get_inspect()
# Determine tools path
tools_path = os.getenv("PRAISONAI_TOOLS_PATH")
if tools_path:
if not os.path.exists(tools_path):
logger.warning(f"PRAISONAI_TOOLS_PATH set but path does not exist: {tools_path}")
return custom_tools
else:
# Check current working directory for tools.py
cwd_tools = os.path.join(os.getcwd(), "tools.py")
if os.path.exists(cwd_tools):
tools_path = cwd_tools
else:
# No tools configured - this is fine, return silently
logger.debug("No tools.py found in current directory (this is normal)")
return custom_tools
@@ -97,27 +239,23 @@ def load_custom_tools():
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Load all functions from tools.py
for name, obj in inspect.getmembers(module):
if not name.startswith('_') and callable(obj) and not inspect.isclass(obj):
# Store function in globals for access
globals()[name] = obj
# Get function signature to build parameters
sig = inspect.signature(obj)
params_properties = {}
required_params = []
for param_name, param in sig.parameters.items():
if param_name != 'self': # Skip self parameter
# Get type annotation if available
param_type = "string" # Default type
if param.annotation != inspect.Parameter.empty:
if param.annotation == int:
if param_name != 'self':
param_type = "string"
if param.annotation is not inspect.Parameter.empty:
if param.annotation is int:
param_type = "integer"
elif param.annotation == float:
elif param.annotation is float:
param_type = "number"
elif param.annotation == bool:
elif param.annotation is bool:
param_type = "boolean"
params_properties[param_name] = {
@@ -125,11 +263,9 @@ def load_custom_tools():
"description": f"Parameter {param_name}"
}
# Add to required if no default value
if param.default == inspect.Parameter.empty:
if param.default is inspect.Parameter.empty:
required_params.append(param_name)
# Build tool definition
tool_def = {
"type": "function",
"function": {
@@ -149,20 +285,63 @@ def load_custom_tools():
if custom_tools:
logger.info(f"Loaded {len(custom_tools)} custom tools from {tools_path}")
except FileNotFoundError:
# File doesn't exist - this is fine, no warning needed
logger.debug(f"Tools file not found: {tools_path}")
except Exception as e:
logger.warning(f"Error loading custom tools from {tools_path}: {e}")
return custom_tools
# Load custom tools
custom_tools_dict = load_custom_tools()
# Deferred tool loading
_custom_tools_dict = None
_tavily_client = None
_tools_list = None
tavily_api_key = os.getenv("TAVILY_API_KEY")
tavily_client = TavilyClient(api_key=tavily_api_key) if tavily_api_key else None
def _get_custom_tools():
global _custom_tools_dict
if _custom_tools_dict is None:
_custom_tools_dict = load_custom_tools()
return _custom_tools_dict
def _get_tavily():
global _tavily_client
if _tavily_client is None:
tavily_api_key = os.getenv("TAVILY_API_KEY")
if tavily_api_key:
TavilyClient = _get_tavily_client()
if TavilyClient:
_tavily_client = TavilyClient(api_key=tavily_api_key)
return _tavily_client
def _get_tools_list():
"""Get the combined tools list (Tavily + custom tools)."""
global _tools_list
if _tools_list is None:
_tools_list = []
tavily_api_key = os.getenv("TAVILY_API_KEY")
if tavily_api_key:
_tools_list.append({
"type": "function",
"function": {
"name": "tavily_web_search",
"description": "Search the web using Tavily API and crawl the resulting URLs",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"]
}
}
})
custom_tools = _get_custom_tools()
_tools_list.extend(list(custom_tools.values()))
return _tools_list
async def tavily_web_search(query):
"""Search the web using Tavily API."""
json = _get_json()
tavily_client = _get_tavily()
if not tavily_client:
return json.dumps({
"query": query,
@@ -172,62 +351,39 @@ async def tavily_web_search(query):
response = tavily_client.search(query)
logger.debug(f"Tavily search response: {response}")
async with AsyncWebCrawler() as crawler:
results = []
for result in response.get('results', []):
url = result.get('url')
if url:
try:
crawl_result = await crawler.arun(url=url)
results.append({
"content": result.get('content'),
"url": url,
"full_content": crawl_result.markdown
})
except Exception as e:
logger.error(f"Error crawling {url}: {str(e)}")
results.append({
"content": result.get('content'),
"url": url,
"full_content": "Error: Unable to crawl this URL"
})
AsyncWebCrawler = _get_async_web_crawler()
if AsyncWebCrawler:
async with AsyncWebCrawler() as crawler:
results = []
for result in response.get('results', []):
url = result.get('url')
if url:
try:
crawl_result = await crawler.arun(url=url)
results.append({
"content": result.get('content'),
"url": url,
"full_content": crawl_result.markdown
})
except Exception as e:
logger.error(f"Error crawling {url}: {str(e)}")
results.append({
"content": result.get('content'),
"url": url,
"full_content": "Error: Unable to crawl this URL"
})
else:
results = [{"content": r.get('content'), "url": r.get('url')} for r in response.get('results', [])]
return json.dumps({
"query": query,
"results": results
})
# Build tools list with Tavily and custom tools
tools = []
# Add Tavily tool if API key is available
if tavily_api_key:
tools.append({
"type": "function",
"function": {
"name": "tavily_web_search",
"description": "Search the web using Tavily API and crawl the resulting URLs",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"]
}
}
})
# Add custom tools from tools.py
tools.extend(list(custom_tools_dict.values()))
# Authentication configuration
AUTH_PASSWORD_ENABLED = os.getenv("AUTH_PASSWORD_ENABLED", "true").lower() == "true" # Password authentication enabled by default
AUTH_OAUTH_ENABLED = os.getenv("AUTH_OAUTH_ENABLED", "false").lower() == "true" # OAuth authentication disabled by default
expected_username = os.getenv("CHAINLIT_USERNAME", "admin")
expected_password = os.getenv("CHAINLIT_PASSWORD", "admin")
# Warn if using default credentials
if expected_username == "admin" and expected_password == "admin":
logger.warning("⚠️ Using default admin credentials. Set CHAINLIT_USERNAME and CHAINLIT_PASSWORD environment variables for production.")
@@ -241,38 +397,122 @@ def auth_callback(username: str, password: str):
logger.warning(f"Login failed for user: {username}")
return None
def _get_or_create_agent(model_name: str, tools_enabled: bool = True):
"""Get or create a reusable agent for the session."""
Agent = _get_praisonai_agent()
if Agent is None:
return None
# Get cached agent from session
cached_agent = cl.user_session.get("_cached_agent")
cached_model = cl.user_session.get("_cached_agent_model")
# Reuse if model matches
if cached_agent is not None and cached_model == model_name:
return cached_agent
# Create new agent with interactive tools
_profile_start("create_agent")
tools = []
if tools_enabled:
tools = _get_interactive_tools()
# Add Tavily if available
if os.getenv("TAVILY_API_KEY"):
tools.append(tavily_web_search)
agent = Agent(
name="PraisonAI Assistant",
instructions="""You are a helpful AI assistant with access to powerful tools.
async def send_count():
await cl.Message(
f"Create step counter: {create_step_counter}", disable_feedback=True
).send()
Available capabilities:
- File operations (read, write, create, edit, delete files)
- Code intelligence (find symbols, definitions, references)
- Command execution (run shell commands)
- Web search (if Tavily API key is set)
Use tools when needed to help the user. For file modifications, use the ACP tools which provide safe, reviewable changes.
Always be helpful, accurate, and concise.""",
llm=model_name,
tools=tools if tools else None,
verbose=False,
)
# Cache the agent
cl.user_session.set("_cached_agent", agent)
cl.user_session.set("_cached_agent_model", model_name)
_profile_end("create_agent")
return agent
@cl.on_chat_start
async def start():
model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-5-nano")
_profile_start("on_chat_start")
_ensure_env_loaded()
model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini")
tools_enabled = (load_setting("tools_enabled") or "true").lower() == "true"
cl.user_session.set("model_name", model_name)
logger.debug(f"Model name: {model_name}")
cl.user_session.set("tools_enabled", tools_enabled)
logger.debug(f"Model name: {model_name}, Tools enabled: {tools_enabled}")
# Pre-create agent for faster first response
_get_or_create_agent(model_name, tools_enabled)
settings = cl.ChatSettings(
[
TextInput(
id="model_name",
label="Enter the Model Name",
placeholder="e.g., gpt-5-nano",
placeholder="e.g., gpt-4o-mini",
initial=model_name
),
Switch(
id="tools_enabled",
label="Enable Tools (ACP, LSP, Web Search)",
initial=tools_enabled
)
]
)
cl.user_session.set("settings", settings)
await settings.send()
# Show loaded tools info
tools = _get_interactive_tools()
tool_names = [t.__name__ for t in tools[:5]]
if len(tools) > 5:
tool_names.append(f"... and {len(tools) - 5} more")
await cl.Message(
content=f"🚀 **PraisonAI Chat Ready**\n\n"
f"**Model:** {model_name}\n"
f"**Tools:** {len(tools)} loaded ({', '.join(tool_names)})\n"
f"**Trust Mode:** Enabled (auto-approve tool executions)\n\n"
f"Type your message to get started!"
).send()
_profile_end("on_chat_start")
@cl.on_settings_update
async def setup_agent(settings):
json = _get_json()
logger.debug(settings)
cl.user_session.set("settings", settings)
model_name = settings["model_name"]
tools_enabled = settings.get("tools_enabled", True)
cl.user_session.set("model_name", model_name)
cl.user_session.set("tools_enabled", tools_enabled)
# Invalidate cached agent if model changed
cached_model = cl.user_session.get("_cached_agent_model")
if cached_model != model_name:
cl.user_session.set("_cached_agent", None)
cl.user_session.set("_cached_agent_model", None)
save_setting("model_name", model_name)
save_setting("tools_enabled", str(tools_enabled).lower())
thread_id = cl.user_session.get("thread_id")
if thread_id:
@@ -285,17 +525,26 @@ async def setup_agent(settings):
except json.JSONDecodeError:
metadata = {}
metadata["model_name"] = model_name
metadata["tools_enabled"] = tools_enabled
await cl_data.update_thread(thread_id, metadata=metadata)
cl.user_session.set("metadata", metadata)
@cl.on_message
async def main(message: cl.Message):
model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-5-nano")
_profile_start("on_message_total")
json = _get_json()
asyncio = _get_asyncio()
datetime = _get_datetime()
model_name = cl.user_session.get("model_name") or load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini")
tools_enabled = cl.user_session.get("tools_enabled", True)
message_history = cl.user_session.get("message_history", [])
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Handle image uploads
image = None
if message.elements and isinstance(message.elements[0], cl.Image):
Image = _get_pil_image()
image_element = message.elements[0]
try:
image = Image.open(image_element.path)
@@ -306,8 +555,7 @@ async def main(message: cl.Message):
await cl.Message(content="Error processing the image. Please try again.").send()
return
user_message = f"""
Answer the question and use tools if needed:
user_message = f"""Answer the question and use tools if needed:
Current Date and Time: {now}
@@ -320,6 +568,58 @@ User Question: {message.content}
message_history.append({"role": "user", "content": user_message})
msg = cl.Message(content="")
# Try PraisonAI Agent first (faster, with tool reuse)
agent = _get_or_create_agent(model_name, tools_enabled) if tools_enabled else None
if agent is not None:
_profile_start("agent_response")
await msg.send()
try:
# Use async chat for streaming
result = await agent.achat(message.content)
# Get response text
if hasattr(result, 'raw'):
response_text = result.raw
else:
response_text = str(result)
# Stream in word chunks for better UX
words = response_text.split(' ')
for i, word in enumerate(words):
token = word + (' ' if i < len(words) - 1 else '')
await msg.stream_token(token)
msg.content = response_text
await msg.update()
message_history.append({"role": "assistant", "content": response_text})
cl.user_session.set("message_history", message_history)
except Exception as e:
logger.error(f"Agent error: {e}")
# Fallback to litellm
await _handle_with_litellm(message, user_message, model_name, message_history, msg, image)
_profile_end("agent_response")
else:
# Fallback to litellm
await _handle_with_litellm(message, user_message, model_name, message_history, msg, image)
_profile_end("on_message_total")
async def _handle_with_litellm(message, user_message, model_name, message_history, msg, image):
"""Fallback handler using litellm for backward compatibility."""
json = _get_json()
asyncio = _get_asyncio()
io = _get_io()
base64 = _get_base64()
acompletion = _get_acompletion()
tools = _get_tools_list()
_profile_start("litellm_response")
completion_params = {
"model": model_name,
"messages": message_history,
@@ -338,7 +638,6 @@ User Question: {message.content}
]
}
# Pass tools if we have any (Tavily or custom)
if tools:
completion_params["tools"] = tools
completion_params["tool_choice"] = "auto"
@@ -356,7 +655,6 @@ User Question: {message.content}
if 'content' in delta and delta['content'] is not None:
token = delta['content']
# Send message on first token to avoid delay
if not msg_sent:
await msg.send()
msg_sent = True
@@ -386,28 +684,25 @@ User Question: {message.content}
if current_tool_call:
tool_calls.append(current_tool_call)
# Ensure message is sent even if no content (e.g., tool calls only)
if not msg_sent:
await msg.send()
logger.debug(f"Full response: {full_response}")
logger.debug(f"Tool calls: {tool_calls}")
message_history.append({"role": "assistant", "content": full_response})
logger.debug(f"Message history: {message_history}")
cl.user_session.set("message_history", message_history)
await msg.update()
if tool_calls and tools: # Check if we have any tools and tool calls
# Handle tool calls
if tool_calls and tools:
custom_tools = _get_custom_tools()
available_functions = {}
# Add Tavily function if available
if tavily_api_key:
if os.getenv("TAVILY_API_KEY"):
available_functions["tavily_web_search"] = tavily_web_search
# Add all custom tool functions from globals
for tool_name in custom_tools_dict:
for tool_name in custom_tools:
if tool_name in globals():
available_functions[tool_name] = globals()[tool_name]
messages = message_history + [{"role": "assistant", "content": None, "function_call": {
"name": tool_calls[0]['function']['name'],
"arguments": tool_calls[0]['function']['arguments']
@@ -422,49 +717,37 @@ User Question: {message.content}
try:
function_args = json.loads(function_args)
# Call function based on whether it's async or sync
if asyncio.iscoroutinefunction(function_to_call):
# For async functions like tavily_web_search
if function_name == "tavily_web_search":
function_response = await function_to_call(
query=function_args.get("query"),
)
function_response = await function_to_call(query=function_args.get("query"))
else:
# For custom async functions, pass all arguments
function_response = await function_to_call(**function_args)
else:
# For sync functions (most custom tools)
function_response = function_to_call(**function_args)
# Convert response to string if needed
if not isinstance(function_response, str):
function_response = json.dumps(function_response)
messages.append(
{
"role": "function",
"name": function_name,
"content": function_response,
}
)
messages.append({
"role": "function",
"name": function_name,
"content": function_response,
})
except json.JSONDecodeError:
logger.error(f"Failed to parse function arguments: {function_args}")
except Exception as e:
logger.error(f"Error calling function {function_name}: {str(e)}")
messages.append(
{
"role": "function",
"name": function_name,
"content": f"Error: {str(e)}",
}
)
messages.append({
"role": "function",
"name": function_name,
"content": f"Error: {str(e)}",
})
second_response = await acompletion(
model=model_name,
stream=True,
messages=messages,
)
logger.debug(f"Second LLM response: {second_response}")
full_response = ""
async for part in second_response:
@@ -480,25 +763,41 @@ User Question: {message.content}
else:
msg.content = full_response
await msg.update()
_profile_end("litellm_response")
@cl.on_chat_resume
async def on_chat_resume(thread: ThreadDict):
json = _get_json()
io = _get_io()
base64 = _get_base64()
Image = _get_pil_image()
logger.info(f"Resuming chat: {thread['id']}")
model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-5-nano")
model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini")
tools_enabled = (load_setting("tools_enabled") or "true").lower() == "true"
logger.debug(f"Model name: {model_name}")
settings = cl.ChatSettings(
[
TextInput(
id="model_name",
label="Enter the Model Name",
placeholder="e.g., gpt-5-nano",
placeholder="e.g., gpt-4o-mini",
initial=model_name
),
Switch(
id="tools_enabled",
label="Enable Tools (ACP, LSP, Web Search)",
initial=tools_enabled
)
]
)
await settings.send()
thread_id = thread["id"]
cl.user_session.set("thread_id", thread_id)
cl.user_session.set("thread_id", thread["id"])
cl.user_session.set("model_name", model_name)
cl.user_session.set("tools_enabled", tools_enabled)
metadata = thread.get("metadata", {})
if isinstance(metadata, str):
@@ -524,6 +823,9 @@ async def on_chat_resume(thread: ThreadDict):
logger.warning(f"Message without recognized type: {m}")
cl.user_session.set("message_history", message_history)
# Pre-create agent for faster first response
_get_or_create_agent(model_name, tools_enabled)
image_data = metadata.get("image")
if image_data:
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.9.2"
__version__ = "3.1.2"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=0.5.3",
"praisonaiagents>=0.6.2",
"python-dotenv>=0.19.0",
"instructor>=1.3.3",
"PyYAML>=6.0",
@@ -0,0 +1,376 @@
"""
Unit tests for ACP Safe Edit Pipeline.
"""
import tempfile
import pytest
from pathlib import Path
from praisonai.acp.safe_edit import (
EditProposal,
EditStatus,
SafeEditPipeline,
get_safe_edit_pipeline,
)
class TestEditProposal:
"""Tests for EditProposal class."""
def test_create_proposal(self):
"""Test creating an edit proposal."""
proposal = EditProposal(
proposal_id="test-123",
file_path=Path("/tmp/test.txt"),
original_content="Hello",
proposed_content="Hello, World!",
description="Add greeting",
)
assert proposal.proposal_id == "test-123"
assert proposal.status == EditStatus.PENDING
assert len(proposal.diff_lines) > 0
def test_diff_generation(self):
"""Test diff is generated correctly."""
proposal = EditProposal(
proposal_id="test-123",
file_path=Path("/tmp/test.txt"),
original_content="line1\nline2\nline3",
proposed_content="line1\nmodified\nline3",
description="Modify line 2",
)
diff_text = proposal.diff_text
assert "-line2" in diff_text
assert "+modified" in diff_text
def test_is_new_file(self):
"""Test new file detection."""
proposal = EditProposal(
proposal_id="test-123",
file_path=Path("/tmp/new.txt"),
original_content="",
proposed_content="New content",
description="Create new file",
)
assert proposal.is_new_file is True
assert proposal.is_deletion is False
def test_is_deletion(self):
"""Test deletion detection."""
proposal = EditProposal(
proposal_id="test-123",
file_path=Path("/tmp/delete.txt"),
original_content="Content to delete",
proposed_content="",
description="Delete file",
)
assert proposal.is_deletion is True
assert proposal.is_new_file is False
def test_to_dict(self):
"""Test converting proposal to dictionary."""
proposal = EditProposal(
proposal_id="test-123",
file_path=Path("/tmp/test.txt"),
original_content="Hello",
proposed_content="Hello, World!",
description="Add greeting",
)
data = proposal.to_dict()
assert data["proposal_id"] == "test-123"
assert data["description"] == "Add greeting"
assert data["status"] == "pending"
assert "diff" in data
class TestSafeEditPipeline:
"""Tests for SafeEditPipeline class."""
@pytest.fixture
def temp_workspace(self):
"""Create a temporary workspace."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
@pytest.fixture
def pipeline(self, temp_workspace):
"""Create a pipeline with temp workspace."""
return SafeEditPipeline(workspace=temp_workspace)
def test_create_pipeline(self, temp_workspace):
"""Test creating a pipeline."""
pipeline = SafeEditPipeline(workspace=temp_workspace)
assert pipeline.workspace == temp_workspace
assert pipeline.auto_approve is False
def test_propose_edit(self, pipeline, temp_workspace):
"""Test proposing an edit."""
# Create a file to edit
test_file = temp_workspace / "test.txt"
test_file.write_text("Original content")
proposal = pipeline.propose_edit(
file_path=test_file,
new_content="Modified content",
description="Test edit",
)
assert proposal.status == EditStatus.PENDING
assert proposal.original_content == "Original content"
assert proposal.proposed_content == "Modified content"
def test_propose_create(self, pipeline, temp_workspace):
"""Test proposing a new file."""
new_file = temp_workspace / "new.txt"
proposal = pipeline.propose_create(
file_path=new_file,
content="New file content",
description="Create new file",
)
assert proposal.is_new_file is True
assert proposal.status == EditStatus.PENDING
def test_propose_delete(self, pipeline, temp_workspace):
"""Test proposing a deletion."""
# Create a file to delete
test_file = temp_workspace / "delete.txt"
test_file.write_text("Content to delete")
proposal = pipeline.propose_delete(
file_path=test_file,
description="Delete file",
)
assert proposal.is_deletion is True
assert proposal.status == EditStatus.PENDING
def test_approve_proposal(self, pipeline, temp_workspace):
"""Test approving a proposal."""
test_file = temp_workspace / "test.txt"
test_file.write_text("Original")
proposal = pipeline.propose_edit(
file_path=test_file,
new_content="Modified",
description="Test",
)
result = pipeline.approve(proposal.proposal_id)
assert result is True
assert proposal.status == EditStatus.APPROVED
assert proposal.approved_at is not None
def test_reject_proposal(self, pipeline, temp_workspace):
"""Test rejecting a proposal."""
test_file = temp_workspace / "test.txt"
test_file.write_text("Original")
proposal = pipeline.propose_edit(
file_path=test_file,
new_content="Modified",
description="Test",
)
result = pipeline.reject(proposal.proposal_id, "Not needed")
assert result is True
assert proposal.status == EditStatus.REJECTED
assert proposal.error == "Not needed"
def test_apply_approved_edit(self, pipeline, temp_workspace):
"""Test applying an approved edit."""
test_file = temp_workspace / "test.txt"
test_file.write_text("Original content")
proposal = pipeline.propose_edit(
file_path=test_file,
new_content="Modified content",
description="Test",
)
pipeline.approve(proposal.proposal_id)
result = pipeline.apply_edit(proposal.proposal_id)
assert result is True
assert proposal.status == EditStatus.APPLIED
assert test_file.read_text() == "Modified content"
def test_apply_unapproved_fails(self, pipeline, temp_workspace):
"""Test that applying unapproved edit fails."""
test_file = temp_workspace / "test.txt"
test_file.write_text("Original")
proposal = pipeline.propose_edit(
file_path=test_file,
new_content="Modified",
description="Test",
)
# Don't approve
result = pipeline.apply_edit(proposal.proposal_id)
assert result is False
assert test_file.read_text() == "Original" # Unchanged
def test_apply_creates_new_file(self, pipeline, temp_workspace):
"""Test applying a new file creation."""
new_file = temp_workspace / "subdir" / "new.txt"
proposal = pipeline.propose_create(
file_path=new_file,
content="New content",
description="Create file",
)
pipeline.approve(proposal.proposal_id)
result = pipeline.apply_edit(proposal.proposal_id)
assert result is True
assert new_file.exists()
assert new_file.read_text() == "New content"
def test_apply_deletes_file(self, pipeline, temp_workspace):
"""Test applying a file deletion."""
test_file = temp_workspace / "delete.txt"
test_file.write_text("To delete")
proposal = pipeline.propose_delete(
file_path=test_file,
description="Delete",
)
pipeline.approve(proposal.proposal_id)
result = pipeline.apply_edit(proposal.proposal_id)
assert result is True
assert not test_file.exists()
def test_path_outside_workspace_rejected(self, pipeline, temp_workspace):
"""Test that paths outside workspace are rejected."""
with pytest.raises(ValueError, match="outside workspace"):
pipeline.propose_edit(
file_path=Path("/etc/passwd"),
new_content="hacked",
description="Bad edit",
)
def test_list_proposals(self, pipeline, temp_workspace):
"""Test listing proposals."""
test_file = temp_workspace / "test.txt"
test_file.write_text("Original")
pipeline.propose_edit(test_file, "Edit 1", "First")
pipeline.propose_edit(test_file, "Edit 2", "Second")
proposals = pipeline.list_proposals()
assert len(proposals) == 2
def test_list_proposals_by_status(self, pipeline, temp_workspace):
"""Test listing proposals filtered by status."""
test_file = temp_workspace / "test.txt"
test_file.write_text("Original")
p1 = pipeline.propose_edit(test_file, "Edit 1", "First")
pipeline.propose_edit(test_file, "Edit 2", "Second")
pipeline.approve(p1.proposal_id)
pending = pipeline.list_proposals(status=EditStatus.PENDING)
approved = pipeline.list_proposals(status=EditStatus.APPROVED)
assert len(pending) == 1
assert len(approved) == 1
def test_auto_approve_mode(self, temp_workspace):
"""Test auto-approve mode."""
pipeline = SafeEditPipeline(workspace=temp_workspace, auto_approve=True)
test_file = temp_workspace / "test.txt"
test_file.write_text("Original")
proposal = pipeline.propose_and_apply(
file_path=test_file,
new_content="Auto-modified",
description="Auto edit",
)
assert proposal.status == EditStatus.APPLIED
assert test_file.read_text() == "Auto-modified"
def test_approval_callback(self, temp_workspace):
"""Test approval callback."""
approved_proposals = []
def callback(proposal):
approved_proposals.append(proposal.proposal_id)
return True
pipeline = SafeEditPipeline(
workspace=temp_workspace,
approval_callback=callback,
)
test_file = temp_workspace / "test.txt"
test_file.write_text("Original")
proposal = pipeline.propose_and_apply(
file_path=test_file,
new_content="Callback-modified",
description="Callback edit",
)
assert proposal.proposal_id in approved_proposals
assert proposal.status == EditStatus.APPLIED
def test_clear_proposals(self, pipeline, temp_workspace):
"""Test clearing proposals."""
test_file = temp_workspace / "test.txt"
test_file.write_text("Original")
pipeline.propose_edit(test_file, "Edit 1", "First")
pipeline.propose_edit(test_file, "Edit 2", "Second")
count = pipeline.clear_proposals()
assert count == 2
assert len(pipeline.list_proposals()) == 0
def test_format_diff_for_display(self, pipeline, temp_workspace):
"""Test formatting diff for display."""
test_file = temp_workspace / "test.txt"
test_file.write_text("line1\nline2\nline3")
proposal = pipeline.propose_edit(
file_path=test_file,
new_content="line1\nmodified\nline3",
description="Modify line 2",
)
display = pipeline.format_diff_for_display(proposal)
assert "Edit Proposal" in display
assert "Modify line 2" in display
assert "Diff" in display
class TestGlobalPipeline:
"""Tests for global pipeline instance."""
def test_get_safe_edit_pipeline(self):
"""Test getting global pipeline."""
pipeline1 = get_safe_edit_pipeline()
pipeline2 = get_safe_edit_pipeline()
# Should return same instance
assert pipeline1 is pipeline2
@@ -0,0 +1,287 @@
"""
Unit tests for the interactive tools provider.
Tests the canonical source of truth for default tools in interactive modes.
"""
import os
import pytest
from unittest.mock import patch, MagicMock
class TestToolGroups:
"""Tests for tool group definitions."""
def test_tool_groups_defined(self):
"""Test that all expected tool groups are defined."""
from praisonai.cli.features.interactive_tools import TOOL_GROUPS
assert "acp" in TOOL_GROUPS
assert "lsp" in TOOL_GROUPS
assert "basic" in TOOL_GROUPS
assert "interactive" in TOOL_GROUPS
def test_acp_tools_list(self):
"""Test ACP tool group contains expected tools."""
from praisonai.cli.features.interactive_tools import TOOL_GROUPS
expected = {
"acp_create_file",
"acp_edit_file",
"acp_delete_file",
"acp_execute_command",
}
assert set(TOOL_GROUPS["acp"]) == expected
def test_lsp_tools_list(self):
"""Test LSP tool group contains expected tools."""
from praisonai.cli.features.interactive_tools import TOOL_GROUPS
expected = {
"lsp_list_symbols",
"lsp_find_definition",
"lsp_find_references",
"lsp_get_diagnostics",
}
assert set(TOOL_GROUPS["lsp"]) == expected
def test_basic_tools_list(self):
"""Test basic tool group contains expected tools."""
from praisonai.cli.features.interactive_tools import TOOL_GROUPS
expected = {
"read_file",
"write_file",
"list_files",
"execute_command",
"internet_search",
}
assert set(TOOL_GROUPS["basic"]) == expected
def test_interactive_group_is_union(self):
"""Test interactive group is union of all groups."""
from praisonai.cli.features.interactive_tools import TOOL_GROUPS
expected = set(TOOL_GROUPS["acp"]) | set(TOOL_GROUPS["lsp"]) | set(TOOL_GROUPS["basic"])
assert set(TOOL_GROUPS["interactive"]) == expected
class TestToolConfig:
"""Tests for ToolConfig dataclass."""
def test_default_config(self):
"""Test default configuration values."""
from praisonai.cli.features.interactive_tools import ToolConfig
config = ToolConfig()
assert config.enable_acp is True
assert config.enable_lsp is True
assert config.enable_basic is True
assert config.approval_mode == "auto"
def test_config_from_env_disable_acp(self):
"""Test config from env with ACP disabled."""
from praisonai.cli.features.interactive_tools import ToolConfig
with patch.dict(os.environ, {"PRAISON_TOOLS_DISABLE": "acp"}):
config = ToolConfig.from_env()
assert config.enable_acp is False
assert config.enable_lsp is True
def test_config_from_env_disable_lsp(self):
"""Test config from env with LSP disabled."""
from praisonai.cli.features.interactive_tools import ToolConfig
with patch.dict(os.environ, {"PRAISON_TOOLS_DISABLE": "lsp"}):
config = ToolConfig.from_env()
assert config.enable_acp is True
assert config.enable_lsp is False
def test_config_from_env_disable_multiple(self):
"""Test config from env with multiple groups disabled."""
from praisonai.cli.features.interactive_tools import ToolConfig
with patch.dict(os.environ, {"PRAISON_TOOLS_DISABLE": "acp,lsp"}):
config = ToolConfig.from_env()
assert config.enable_acp is False
assert config.enable_lsp is False
assert config.enable_basic is True
def test_config_from_env_workspace(self):
"""Test config from env with workspace override."""
from praisonai.cli.features.interactive_tools import ToolConfig
with patch.dict(os.environ, {"PRAISON_WORKSPACE": "/custom/path"}):
config = ToolConfig.from_env()
assert config.workspace == "/custom/path"
class TestResolveToolGroups:
"""Tests for resolve_tool_groups function."""
def test_resolve_all_enabled(self):
"""Test resolving with all groups enabled."""
from praisonai.cli.features.interactive_tools import resolve_tool_groups, ToolConfig, TOOL_GROUPS
config = ToolConfig()
tool_names = resolve_tool_groups(config=config)
expected = set(TOOL_GROUPS["interactive"])
assert tool_names == expected
def test_resolve_disable_acp(self):
"""Test resolving with ACP disabled."""
from praisonai.cli.features.interactive_tools import resolve_tool_groups, ToolConfig, TOOL_GROUPS
config = ToolConfig(enable_acp=False)
tool_names = resolve_tool_groups(disable=["acp"], config=config)
# Should not contain ACP tools
for acp_tool in TOOL_GROUPS["acp"]:
assert acp_tool not in tool_names
# Should contain LSP and basic tools
for lsp_tool in TOOL_GROUPS["lsp"]:
assert lsp_tool in tool_names
def test_resolve_disable_lsp(self):
"""Test resolving with LSP disabled."""
from praisonai.cli.features.interactive_tools import resolve_tool_groups, ToolConfig, TOOL_GROUPS
config = ToolConfig(enable_lsp=False)
tool_names = resolve_tool_groups(disable=["lsp"], config=config)
# Should not contain LSP tools
for lsp_tool in TOOL_GROUPS["lsp"]:
assert lsp_tool not in tool_names
# Should contain ACP and basic tools
for acp_tool in TOOL_GROUPS["acp"]:
assert acp_tool in tool_names
def test_resolve_specific_groups(self):
"""Test resolving specific groups only."""
from praisonai.cli.features.interactive_tools import resolve_tool_groups, TOOL_GROUPS
tool_names = resolve_tool_groups(groups=["basic"])
assert tool_names == set(TOOL_GROUPS["basic"])
class TestGetInteractiveTools:
"""Tests for get_interactive_tools function."""
def test_get_tools_returns_list(self):
"""Test that get_interactive_tools returns a list."""
from praisonai.cli.features.interactive_tools import get_interactive_tools, ToolConfig
config = ToolConfig(workspace="/tmp")
tools = get_interactive_tools(config=config)
assert isinstance(tools, list)
def test_get_tools_deterministic_order(self):
"""Test that tool order is deterministic."""
from praisonai.cli.features.interactive_tools import get_interactive_tools, ToolConfig
config = ToolConfig(workspace="/tmp")
tools1 = get_interactive_tools(config=config)
tools2 = get_interactive_tools(config=config)
names1 = [t.__name__ for t in tools1]
names2 = [t.__name__ for t in tools2]
assert names1 == names2
def test_get_tools_with_disable_acp(self):
"""Test get_interactive_tools with ACP disabled."""
from praisonai.cli.features.interactive_tools import get_interactive_tools, ToolConfig
config = ToolConfig(workspace="/tmp", enable_acp=False)
tools = get_interactive_tools(config=config, disable=["acp"])
tool_names = [t.__name__ for t in tools]
acp_tools = [n for n in tool_names if n.startswith("acp_")]
assert len(acp_tools) == 0
def test_get_tools_with_disable_lsp(self):
"""Test get_interactive_tools with LSP disabled."""
from praisonai.cli.features.interactive_tools import get_interactive_tools, ToolConfig
config = ToolConfig(workspace="/tmp", enable_lsp=False)
tools = get_interactive_tools(config=config, disable=["lsp"])
tool_names = [t.__name__ for t in tools]
lsp_tools = [n for n in tool_names if n.startswith("lsp_")]
assert len(lsp_tools) == 0
class TestMergeToolOverrides:
"""Tests for merge_tool_overrides function."""
def test_merge_add_tools(self):
"""Test adding tools to default list."""
from praisonai.cli.features.interactive_tools import merge_tool_overrides
def tool_a():
pass
def tool_b():
pass
def tool_c():
pass
default = [tool_a, tool_b]
result = merge_tool_overrides(default, add_tools=[tool_c])
names = [t.__name__ for t in result]
assert "tool_c" in names
def test_merge_remove_tools(self):
"""Test removing tools from default list."""
from praisonai.cli.features.interactive_tools import merge_tool_overrides
def tool_a():
pass
def tool_b():
pass
default = [tool_a, tool_b]
result = merge_tool_overrides(default, remove_names=["tool_a"])
names = [t.__name__ for t in result]
assert "tool_a" not in names
assert "tool_b" in names
class TestLazyImports:
"""Tests for lazy import behavior."""
def test_import_time_fast(self):
"""Test that importing the module is fast (no heavy deps)."""
import time
start = time.time()
# Force reimport
import importlib
import praisonai.cli.features.interactive_tools as module
importlib.reload(module)
duration = time.time() - start
# Should be under 100ms
assert duration < 0.1, f"Import took {duration:.3f}s, expected < 0.1s"
def test_tool_groups_available_without_runtime(self):
"""Test that TOOL_GROUPS is available without runtime initialization."""
from praisonai.cli.features.interactive_tools import TOOL_GROUPS
# Should be accessible immediately
assert len(TOOL_GROUPS) > 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,366 @@
"""
Unit tests for CLI profiler module.
"""
class TestProfilerConfig:
"""Tests for ProfilerConfig dataclass."""
def test_default_config(self):
"""Test default configuration values."""
from praisonai.cli.features.profiler import ProfilerConfig
config = ProfilerConfig()
assert config.deep is False
assert config.limit == 30
assert config.sort_by == "cumulative"
assert config.show_files is False
assert config.show_callers is False
assert config.show_callees is False
assert config.importtime is False
assert config.first_token is False
assert config.save_path is None
assert config.output_format == "text"
assert config.stream is False
def test_custom_config(self):
"""Test custom configuration."""
from praisonai.cli.features.profiler import ProfilerConfig
config = ProfilerConfig(
deep=True,
limit=50,
sort_by="tottime",
show_files=True,
)
assert config.deep is True
assert config.limit == 50
assert config.sort_by == "tottime"
assert config.show_files is True
class TestTimingBreakdown:
"""Tests for TimingBreakdown dataclass."""
def test_default_timing(self):
"""Test default timing values."""
from praisonai.cli.features.profiler.core import TimingBreakdown
timing = TimingBreakdown()
assert timing.cli_parse_ms == 0.0
assert timing.imports_ms == 0.0
assert timing.agent_construction_ms == 0.0
assert timing.model_init_ms == 0.0
assert timing.first_token_ms == 0.0
assert timing.total_run_ms == 0.0
class TestFunctionStats:
"""Tests for FunctionStats dataclass."""
def test_to_dict(self):
"""Test conversion to dictionary."""
from praisonai.cli.features.profiler.core import FunctionStats
stats = FunctionStats(
name="test_func",
filename="/path/to/file.py",
lineno=42,
calls=10,
tottime=0.5,
cumtime=1.0,
)
result = stats.to_dict()
assert result["name"] == "test_func"
assert result["filename"] == "/path/to/file.py"
assert result["lineno"] == 42
assert result["calls"] == 10
assert result["tottime_ms"] == 500.0
assert result["cumtime_ms"] == 1000.0
class TestFileStats:
"""Tests for FileStats dataclass."""
def test_to_dict(self):
"""Test conversion to dictionary."""
from praisonai.cli.features.profiler.core import FileStats, FunctionStats
func_stats = FunctionStats(
name="func1",
filename="/path/to/file.py",
lineno=10,
calls=5,
tottime=0.1,
cumtime=0.2,
)
file_stats = FileStats(
filename="/path/to/file.py",
total_time=0.5,
functions=[func_stats],
)
result = file_stats.to_dict()
assert result["filename"] == "/path/to/file.py"
assert result["total_time_ms"] == 500.0
assert result["function_count"] == 1
class TestProfilerResult:
"""Tests for ProfilerResult dataclass."""
def test_to_dict(self):
"""Test conversion to dictionary."""
from praisonai.cli.features.profiler.core import (
ProfilerResult,
TimingBreakdown,
FunctionStats,
FileStats,
)
timing = TimingBreakdown(
cli_parse_ms=1.0,
imports_ms=100.0,
total_run_ms=500.0,
)
func_stats = [FunctionStats(
name="test",
filename="test.py",
lineno=1,
calls=1,
tottime=0.1,
cumtime=0.2,
)]
file_stats = [FileStats(
filename="test.py",
total_time=0.2,
)]
result = ProfilerResult(
prompt="test prompt",
response="test response",
timing=timing,
function_stats=func_stats,
file_stats=file_stats,
metadata={"model": "test"},
)
data = result.to_dict()
assert "timestamp" in data
assert data["prompt"] == "test prompt"
assert data["response_preview"] == "test response"
assert data["timing"]["cli_parse_ms"] == 1.0
assert data["timing"]["imports_ms"] == 100.0
assert len(data["top_functions"]) == 1
assert len(data["top_files"]) == 1
class TestQueryProfiler:
"""Tests for QueryProfiler class."""
def test_init_default_config(self):
"""Test initialization with default config."""
from praisonai.cli.features.profiler import QueryProfiler, ProfilerConfig
profiler = QueryProfiler()
assert profiler.config is not None
assert isinstance(profiler.config, ProfilerConfig)
def test_init_custom_config(self):
"""Test initialization with custom config."""
from praisonai.cli.features.profiler import QueryProfiler, ProfilerConfig
config = ProfilerConfig(deep=True, limit=50)
profiler = QueryProfiler(config)
assert profiler.config.deep is True
assert profiler.config.limit == 50
def test_collect_metadata(self):
"""Test metadata collection."""
from praisonai.cli.features.profiler import QueryProfiler
profiler = QueryProfiler()
metadata = profiler._collect_metadata("gpt-4")
assert "python_version" in metadata
assert "platform" in metadata
assert "praisonai_version" in metadata
assert metadata["model"] == "gpt-4"
assert "timestamp" in metadata
class TestFormatProfileReport:
"""Tests for format_profile_report function."""
def test_basic_report(self):
"""Test basic report formatting."""
from praisonai.cli.features.profiler import (
format_profile_report,
ProfilerConfig,
)
from praisonai.cli.features.profiler.core import (
ProfilerResult,
TimingBreakdown,
FunctionStats,
FileStats,
)
timing = TimingBreakdown(
cli_parse_ms=1.0,
imports_ms=100.0,
total_run_ms=500.0,
)
func_stats = [FunctionStats(
name="test_func",
filename="test.py",
lineno=1,
calls=10,
tottime=0.1,
cumtime=0.2,
)]
file_stats = [FileStats(
filename="test.py",
total_time=0.2,
)]
result = ProfilerResult(
prompt="test",
response="response",
timing=timing,
function_stats=func_stats,
file_stats=file_stats,
metadata={"python_version": "3.12", "platform": "test", "praisonai_version": "1.0", "model": "test"},
)
report = format_profile_report(result)
assert "PraisonAI Profile Report" in report
assert "System Information" in report
assert "Timing Breakdown" in report
assert "Per-Function Timing" in report
assert "test_func" in report
def test_report_with_files(self):
"""Test report with file grouping."""
from praisonai.cli.features.profiler import (
format_profile_report,
ProfilerConfig,
)
from praisonai.cli.features.profiler.core import (
ProfilerResult,
TimingBreakdown,
FunctionStats,
FileStats,
)
config = ProfilerConfig(show_files=True)
timing = TimingBreakdown()
func_stats = []
file_stats = [FileStats(filename="test.py", total_time=0.5)]
result = ProfilerResult(
prompt="test",
response="response",
timing=timing,
function_stats=func_stats,
file_stats=file_stats,
metadata={},
)
report = format_profile_report(result, config)
assert "Per-File Timing" in report
class TestRedactSecrets:
"""Tests for secret redaction."""
def test_redact_openai_key(self):
"""Test redacting OpenAI API keys."""
from praisonai.cli.features.profiler.core import redact_secrets
text = "API key: sk-1234567890abcdefghijklmnopqrstuvwxyz"
result = redact_secrets(text)
assert "sk-1234567890" not in result
assert "[REDACTED]" in result
def test_redact_anthropic_key(self):
"""Test redacting Anthropic API keys."""
from praisonai.cli.features.profiler.core import redact_secrets
text = "Key: sk-ant-api03-abcdefghijklmnopqrstuvwxyz"
result = redact_secrets(text)
assert "sk-ant-api03" not in result
assert "[REDACTED]" in result
def test_preserve_normal_text(self):
"""Test that normal text is preserved."""
from praisonai.cli.features.profiler.core import redact_secrets
text = "This is normal text without secrets"
result = redact_secrets(text)
assert result == text
class TestCLIImports:
"""Tests for CLI import functionality."""
def test_cli_app_imports(self):
"""Test that CLI app imports without errors."""
# This tests the fix for ModuleNotFoundError
from praisonai.cli.app import app
assert app is not None
def test_output_module_imports(self):
"""Test that output module imports correctly."""
from praisonai.cli.output import OutputController, OutputMode
assert OutputController is not None
assert OutputMode is not None
def test_profiler_module_imports(self):
"""Test that profiler module imports correctly."""
from praisonai.cli.features.profiler import (
ProfilerConfig,
ProfilerResult,
QueryProfiler,
run_profiled_query,
format_profile_report,
)
assert ProfilerConfig is not None
assert ProfilerResult is not None
assert QueryProfiler is not None
assert run_profiled_query is not None
assert format_profile_report is not None
class TestOutputController:
"""Tests for OutputController."""
def test_default_mode(self):
"""Test default output mode."""
from praisonai.cli.output import OutputController, OutputMode
controller = OutputController()
assert controller.mode == OutputMode.TEXT
assert controller.is_json_mode is False
assert controller.is_quiet is False
def test_json_mode(self):
"""Test JSON output mode."""
from praisonai.cli.output import OutputController, OutputMode
controller = OutputController(mode=OutputMode.JSON)
assert controller.is_json_mode is True
def test_quiet_mode(self):
"""Test quiet output mode."""
from praisonai.cli.output import OutputController, OutputMode
controller = OutputController(mode=OutputMode.QUIET)
assert controller.is_quiet is True
@@ -0,0 +1,584 @@
"""
Unit tests for profiler optimizations module.
Tests Tier 0/1/2 optimization features:
- Provider caching
- Lazy imports
- Client pooling
- Prewarm manager
- Lite mode
- Performance snapshots
"""
import os
import tempfile
import threading
from pathlib import Path
from unittest.mock import patch
class TestProviderCache:
"""Tests for ProviderCache (Tier 0)."""
def test_singleton_pattern(self):
"""Test that ProviderCache is a singleton."""
from praisonai.cli.features.profiler import ProviderCache
cache1 = ProviderCache()
cache2 = ProviderCache()
assert cache1 is cache2
def test_get_set(self):
"""Test basic get/set operations."""
from praisonai.cli.features.profiler import get_provider_cache
cache = get_provider_cache()
cache.invalidate() # Clear cache
# Miss
result = cache.get('test_key')
assert result is None
# Set
cache.set('test_key', {'value': 'test'})
# Hit - cache stores as {'value': ..., 'expires': ...}
result = cache.get('test_key')
assert result is not None
assert result['value']['value'] == 'test'
def test_invalidate(self):
"""Test cache invalidation."""
from praisonai.cli.features.profiler import get_provider_cache
cache = get_provider_cache()
cache.set('key1', 'value1')
cache.set('key2', 'value2')
# Invalidate single key
cache.invalidate('key1')
assert cache.get('key1') is None
assert cache.get('key2') is not None
# Invalidate all
cache.invalidate()
assert cache.get('key2') is None
def test_stats(self):
"""Test cache statistics."""
from praisonai.cli.features.profiler import get_provider_cache
cache = get_provider_cache()
cache.invalidate()
# Generate some hits and misses
cache.get('miss1') # miss
cache.set('hit1', 'value')
cache.get('hit1') # hit
cache.get('miss2') # miss
stats = cache.get_stats()
assert 'hits' in stats
assert 'misses' in stats
assert 'size' in stats
def test_thread_safety(self):
"""Test thread-safe operations."""
from praisonai.cli.features.profiler import get_provider_cache
cache = get_provider_cache()
cache.invalidate()
errors = []
def worker(thread_id):
try:
for i in range(100):
key = f"thread_{thread_id}_key_{i}"
cache.set(key, f"value_{i}")
cache.get(key)
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=worker, args=(i,)) for i in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(errors) == 0
class TestResolveModelProvider:
"""Tests for resolve_model_provider (Tier 0)."""
def test_openai_models(self):
"""Test OpenAI model resolution."""
from praisonai.cli.features.profiler import resolve_model_provider
assert resolve_model_provider('gpt-4o') == ('openai', 'gpt-4o')
assert resolve_model_provider('gpt-4o-mini') == ('openai', 'gpt-4o-mini')
assert resolve_model_provider('o1-preview') == ('openai', 'o1-preview')
def test_anthropic_models(self):
"""Test Anthropic model resolution."""
from praisonai.cli.features.profiler import resolve_model_provider
assert resolve_model_provider('claude-3-opus') == ('anthropic', 'claude-3-opus')
assert resolve_model_provider('claude-3-sonnet') == ('anthropic', 'claude-3-sonnet')
def test_google_models(self):
"""Test Google model resolution."""
from praisonai.cli.features.profiler import resolve_model_provider
assert resolve_model_provider('gemini-pro') == ('google', 'gemini-pro')
assert resolve_model_provider('gemini-1.5-flash') == ('google', 'gemini-1.5-flash')
def test_explicit_provider(self):
"""Test explicit provider/model format."""
from praisonai.cli.features.profiler import resolve_model_provider
assert resolve_model_provider('openai/gpt-4') == ('openai', 'gpt-4')
assert resolve_model_provider('anthropic/claude-3') == ('anthropic', 'claude-3')
def test_caching(self):
"""Test that results are cached."""
from praisonai.cli.features.profiler import resolve_model_provider
# Clear cache
resolve_model_provider.cache_clear()
# First call
result1 = resolve_model_provider('gpt-4o')
# Second call (should be cached)
result2 = resolve_model_provider('gpt-4o')
assert result1 == result2
# Check cache info
info = resolve_model_provider.cache_info()
assert info.hits >= 1
class TestLazyImporter:
"""Tests for LazyImporter (Tier 0)."""
def test_import_module(self):
"""Test lazy module import."""
from praisonai.cli.features.profiler import LazyImporter
LazyImporter.clear()
# Import a standard library module
json_module = LazyImporter.get('json')
assert json_module is not None
assert hasattr(json_module, 'dumps')
def test_import_attribute(self):
"""Test lazy attribute import."""
from praisonai.cli.features.profiler import LazyImporter
LazyImporter.clear()
# Import a specific attribute
Path = LazyImporter.get('pathlib', 'Path')
assert Path is not None
assert callable(Path)
def test_caching(self):
"""Test that imports are cached."""
from praisonai.cli.features.profiler import LazyImporter
LazyImporter.clear()
# First import
json1 = LazyImporter.get('json')
# Second import (should be cached)
json2 = LazyImporter.get('json')
assert json1 is json2
class TestClientPool:
"""Tests for ClientPool (Tier 1)."""
def test_get_or_create(self):
"""Test client creation and reuse."""
from praisonai.cli.features.profiler import ClientPool
pool = ClientPool(max_size=5)
# Create client
client1 = pool.get_or_create('key1', lambda: {'id': 1})
assert client1 == {'id': 1}
# Reuse client
client2 = pool.get_or_create('key1', lambda: {'id': 2})
assert client2 == {'id': 1} # Same as first
def test_eviction(self):
"""Test LRU eviction when pool is full."""
from praisonai.cli.features.profiler import ClientPool
pool = ClientPool(max_size=2)
pool.get_or_create('key1', lambda: 'client1')
pool.get_or_create('key2', lambda: 'client2')
assert pool.size() == 2
# This should evict key1 (oldest)
pool.get_or_create('key3', lambda: 'client3')
assert pool.size() == 2
def test_remove(self):
"""Test explicit client removal."""
from praisonai.cli.features.profiler import ClientPool
pool = ClientPool()
pool.get_or_create('key1', lambda: 'client1')
assert pool.size() == 1
pool.remove('key1')
assert pool.size() == 0
def test_clear(self):
"""Test clearing all clients."""
from praisonai.cli.features.profiler import ClientPool
pool = ClientPool()
pool.get_or_create('key1', lambda: 'client1')
pool.get_or_create('key2', lambda: 'client2')
pool.clear()
assert pool.size() == 0
class TestPrewarmManager:
"""Tests for PrewarmManager (Tier 1)."""
def test_enable_disable(self):
"""Test enabling and disabling prewarm."""
from praisonai.cli.features.profiler import PrewarmManager
PrewarmManager.reset()
assert not PrewarmManager.is_enabled()
PrewarmManager.enable()
assert PrewarmManager.is_enabled()
PrewarmManager.disable()
assert not PrewarmManager.is_enabled()
def test_prewarm_disabled(self):
"""Test that prewarm does nothing when disabled."""
from praisonai.cli.features.profiler import PrewarmManager
PrewarmManager.reset()
# Should not raise even without API key
PrewarmManager.prewarm_provider('openai')
assert not PrewarmManager.is_prewarmed('openai')
def test_reset(self):
"""Test reset clears state."""
from praisonai.cli.features.profiler import PrewarmManager
PrewarmManager.enable()
PrewarmManager.reset()
assert not PrewarmManager.is_enabled()
class TestLiteModeConfig:
"""Tests for LiteModeConfig (Tier 2)."""
def test_default_disabled(self):
"""Test that lite mode is disabled by default."""
from praisonai.cli.features.profiler import LiteModeConfig
config = LiteModeConfig()
assert not config.enabled
assert not config.skip_type_validation
assert not config.skip_model_validation
assert not config.minimal_imports
def test_from_env(self):
"""Test loading config from environment."""
from praisonai.cli.features.profiler import LiteModeConfig
with patch.dict(os.environ, {
'PRAISONAI_LITE_MODE': '1',
'PRAISONAI_SKIP_TYPE_VALIDATION': 'true',
}):
config = LiteModeConfig.from_env()
assert config.enabled
assert config.skip_type_validation
assert not config.skip_model_validation
def test_is_lite_mode(self):
"""Test is_lite_mode helper."""
from praisonai.cli.features.profiler import is_lite_mode
# Reset global config
import praisonai.cli.features.profiler.optimizations as opt
opt._lite_mode_config = None
with patch.dict(os.environ, {'PRAISONAI_LITE_MODE': ''}, clear=False):
# Force reload
opt._lite_mode_config = None
assert not is_lite_mode()
class TestPerfSnapshot:
"""Tests for PerfSnapshot (Tier 2)."""
def test_to_dict(self):
"""Test snapshot serialization."""
from praisonai.cli.features.profiler import PerfSnapshot
snapshot = PerfSnapshot(
timestamp='2025-01-01T00:00:00Z',
name='test',
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,
)
data = snapshot.to_dict()
assert data['name'] == 'test'
assert data['startup_cold_ms'] == 100.0
assert data['query_time_ms'] == 2000.0
def test_from_dict(self):
"""Test snapshot deserialization."""
from praisonai.cli.features.profiler import PerfSnapshot
data = {
'timestamp': '2025-01-01T00:00:00Z',
'name': 'test',
'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,
}
snapshot = PerfSnapshot.from_dict(data)
assert snapshot.name == 'test'
assert snapshot.startup_cold_ms == 100.0
class TestPerfComparison:
"""Tests for PerfComparison (Tier 2)."""
def test_diff_calculation(self):
"""Test difference calculations."""
from praisonai.cli.features.profiler import PerfSnapshot, PerfComparison
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,
)
current = PerfSnapshot(
timestamp='2025-01-02T00:00:00Z',
name='current',
startup_cold_ms=110.0, # 10% slower
startup_warm_ms=85.0,
import_time_ms=550.0, # 10% slower
query_time_ms=2200.0, # 10% slower
first_token_ms=330.0,
)
comparison = PerfComparison(baseline=baseline, current=current)
assert comparison.startup_cold_diff_ms == 10.0
assert comparison.startup_cold_diff_pct == 10.0
assert comparison.import_time_diff_ms == 50.0
assert comparison.query_time_diff_ms == 200.0
def test_regression_detection(self):
"""Test regression detection."""
from praisonai.cli.features.profiler import PerfSnapshot, PerfComparison
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,
)
# No regression (within 10%)
current_ok = PerfSnapshot(
timestamp='2025-01-02T00:00:00Z',
name='current',
startup_cold_ms=105.0,
startup_warm_ms=82.0,
import_time_ms=520.0,
query_time_ms=2100.0,
first_token_ms=310.0,
)
comparison_ok = PerfComparison(baseline=baseline, current=current_ok)
assert not comparison_ok.is_regression()
# Regression (>10%)
current_bad = PerfSnapshot(
timestamp='2025-01-02T00:00:00Z',
name='current',
startup_cold_ms=150.0, # 50% slower
startup_warm_ms=82.0,
import_time_ms=520.0,
query_time_ms=2100.0,
first_token_ms=310.0,
)
comparison_bad = PerfComparison(baseline=baseline, current=current_bad)
assert comparison_bad.is_regression()
class TestPerfSnapshotManager:
"""Tests for PerfSnapshotManager (Tier 2)."""
def test_save_and_load(self):
"""Test saving and loading snapshots."""
from praisonai.cli.features.profiler import PerfSnapshot, PerfSnapshotManager
with tempfile.TemporaryDirectory() as tmpdir:
manager = PerfSnapshotManager(snapshot_dir=Path(tmpdir))
snapshot = PerfSnapshot(
timestamp='2025-01-01T00:00:00Z',
name='test',
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,
)
path = manager.save(snapshot)
assert path.exists()
loaded = manager.load('test')
assert loaded is not None
assert loaded.name == 'test'
assert loaded.startup_cold_ms == 100.0
def test_baseline(self):
"""Test baseline save and load."""
from praisonai.cli.features.profiler import PerfSnapshot, PerfSnapshotManager
with tempfile.TemporaryDirectory() as tmpdir:
manager = PerfSnapshotManager(snapshot_dir=Path(tmpdir))
snapshot = PerfSnapshot(
timestamp='2025-01-01T00:00:00Z',
name='original',
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(snapshot)
baseline = manager.load_baseline()
assert baseline is not None
assert baseline.name == 'baseline'
def test_list_snapshots(self):
"""Test listing snapshots."""
from praisonai.cli.features.profiler import PerfSnapshot, PerfSnapshotManager
with tempfile.TemporaryDirectory() as tmpdir:
manager = PerfSnapshotManager(snapshot_dir=Path(tmpdir))
for name in ['snap1', 'snap2', 'snap3']:
snapshot = PerfSnapshot(
timestamp='2025-01-01T00:00:00Z',
name=name,
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(snapshot)
names = manager.list_snapshots()
assert 'snap1' in names
assert 'snap2' in names
assert 'snap3' in names
class TestFormatComparisonReport:
"""Tests for format_comparison_report."""
def test_format_report(self):
"""Test report formatting."""
from praisonai.cli.features.profiler import (
PerfSnapshot, PerfComparison, format_comparison_report
)
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,
)
current = PerfSnapshot(
timestamp='2025-01-02T00:00:00Z',
name='current',
startup_cold_ms=105.0,
startup_warm_ms=82.0,
import_time_ms=520.0,
query_time_ms=2100.0,
first_token_ms=310.0,
)
comparison = PerfComparison(baseline=baseline, current=current)
report = format_comparison_report(comparison)
assert 'Performance Comparison Report' in report
assert 'baseline' in report
assert 'current' in report
assert 'No significant regression' in report
class TestModuleImports:
"""Test that all exports are available."""
def test_all_exports_available(self):
"""Test that all __all__ exports are importable."""
from praisonai.cli.features.profiler import __all__
import praisonai.cli.features.profiler as profiler
for name in __all__:
assert hasattr(profiler, name), f"Missing export: {name}"
@@ -0,0 +1,228 @@
"""
Unit tests for CLI profiler suite module.
"""
class TestScenarioConfig:
"""Tests for ScenarioConfig dataclass."""
def test_default_config(self):
"""Test default configuration values."""
from praisonai.cli.features.profiler import ScenarioConfig
config = ScenarioConfig(name="test", prompt="hi")
assert config.name == "test"
assert config.prompt == "hi"
assert config.model is None
assert config.stream is False
assert config.iterations == 3
assert config.warmup == 1
assert config.show_files is True
assert config.limit == 20
def test_custom_config(self):
"""Test custom configuration."""
from praisonai.cli.features.profiler import ScenarioConfig
config = ScenarioConfig(
name="custom",
prompt="test prompt",
model="gpt-4o",
stream=True,
iterations=5,
)
assert config.name == "custom"
assert config.model == "gpt-4o"
assert config.stream is True
assert config.iterations == 5
class TestScenarioResult:
"""Tests for ScenarioResult dataclass."""
def test_empty_result(self):
"""Test empty result."""
from praisonai.cli.features.profiler import ScenarioConfig, ScenarioResult
config = ScenarioConfig(name="test", prompt="hi")
result = ScenarioResult(name="test", config=config)
assert result.name == "test"
assert len(result.results) == 0
assert result.import_times == []
assert result.total_times == []
def test_get_stats_empty(self):
"""Test stats with empty values."""
from praisonai.cli.features.profiler import ScenarioConfig, ScenarioResult
config = ScenarioConfig(name="test", prompt="hi")
result = ScenarioResult(name="test", config=config)
stats = result.get_stats([])
assert stats["mean"] == 0
assert stats["min"] == 0
assert stats["max"] == 0
def test_get_stats_single_value(self):
"""Test stats with single value."""
from praisonai.cli.features.profiler import ScenarioConfig, ScenarioResult
config = ScenarioConfig(name="test", prompt="hi")
result = ScenarioResult(name="test", config=config)
stats = result.get_stats([100.0])
assert stats["mean"] == 100.0
assert stats["min"] == 100.0
assert stats["max"] == 100.0
assert stats["stdev"] == 0
def test_get_stats_multiple_values(self):
"""Test stats with multiple values."""
from praisonai.cli.features.profiler import ScenarioConfig, ScenarioResult
config = ScenarioConfig(name="test", prompt="hi")
result = ScenarioResult(name="test", config=config)
stats = result.get_stats([100.0, 200.0, 300.0])
assert stats["mean"] == 200.0
assert stats["min"] == 100.0
assert stats["max"] == 300.0
assert stats["median"] == 200.0
def test_to_dict(self):
"""Test conversion to dictionary."""
from praisonai.cli.features.profiler import ScenarioConfig, ScenarioResult
config = ScenarioConfig(name="test", prompt="hi", iterations=2)
result = ScenarioResult(name="test", config=config)
data = result.to_dict()
assert data["name"] == "test"
assert "config" in data
assert data["config"]["prompt"] == "hi"
assert "import_time_stats" in data
assert "total_time_stats" in data
class TestSuiteResult:
"""Tests for SuiteResult dataclass."""
def test_default_result(self):
"""Test default result."""
from praisonai.cli.features.profiler import SuiteResult
result = SuiteResult()
assert len(result.scenarios) == 0
assert result.startup_cold_ms == 0.0
assert result.startup_warm_ms == 0.0
assert len(result.import_analysis) == 0
assert result.timestamp.endswith("Z") # ISO format with Z suffix
def test_to_dict(self):
"""Test conversion to dictionary."""
from praisonai.cli.features.profiler import SuiteResult
result = SuiteResult(
startup_cold_ms=100.0,
startup_warm_ms=50.0,
)
data = result.to_dict()
assert "timestamp" in data
assert "metadata" in data
assert data["startup"]["cold_ms"] == 100.0
assert data["startup"]["warm_ms"] == 50.0
class TestProfileSuiteRunner:
"""Tests for ProfileSuiteRunner class."""
def test_init_default(self):
"""Test initialization with defaults."""
from praisonai.cli.features.profiler import ProfileSuiteRunner
runner = ProfileSuiteRunner()
assert len(runner.scenarios) == 4 # DEFAULT_SCENARIOS
assert runner.verbose is False
def test_init_custom_scenarios(self):
"""Test initialization with custom scenarios."""
from praisonai.cli.features.profiler import ProfileSuiteRunner, ScenarioConfig
scenarios = [
ScenarioConfig(name="custom1", prompt="test1"),
ScenarioConfig(name="custom2", prompt="test2"),
]
runner = ProfileSuiteRunner(scenarios=scenarios)
assert len(runner.scenarios) == 2
assert runner.scenarios[0].name == "custom1"
def test_collect_metadata(self):
"""Test metadata collection."""
from praisonai.cli.features.profiler import ProfileSuiteRunner
runner = ProfileSuiteRunner()
metadata = runner._collect_metadata()
assert "python_version" in metadata
assert "platform" in metadata
assert "praisonai_version" in metadata
assert "timestamp" in metadata
def test_measure_startup(self):
"""Test startup measurement."""
from praisonai.cli.features.profiler import ProfileSuiteRunner
runner = ProfileSuiteRunner()
cold, warm = runner._measure_startup()
# Both should be positive numbers
assert cold > 0
assert warm > 0
def test_analyze_imports(self):
"""Test import analysis."""
from praisonai.cli.features.profiler import ProfileSuiteRunner
runner = ProfileSuiteRunner()
imports = runner._analyze_imports()
# Should return a list (may be empty if subprocess fails)
assert isinstance(imports, list)
# If we got results, check structure
if imports:
assert "module" in imports[0]
assert "cumulative_ms" in imports[0]
class TestProfilerModuleImports:
"""Tests for profiler module imports."""
def test_all_exports_available(self):
"""Test that all exports are available."""
from praisonai.cli.features.profiler import (
ProfilerConfig,
ProfilerResult,
QueryProfiler,
run_profiled_query,
format_profile_report,
ScenarioConfig,
ScenarioResult,
SuiteResult,
ProfileSuiteRunner,
run_profile_suite,
)
assert ProfilerConfig is not None
assert ProfilerResult is not None
assert QueryProfiler is not None
assert run_profiled_query is not None
assert format_profile_report is not None
assert ScenarioConfig is not None
assert ScenarioResult is not None
assert SuiteResult is not None
assert ProfileSuiteRunner is not None
assert run_profile_suite is not None
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more