mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 17:29:39 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3386d25022 | ||
|
|
6dca2ace8a | ||
|
|
63548739e8 | ||
|
|
3e046537f4 | ||
|
|
4a34677567 | ||
|
|
19e91cb669 | ||
|
|
5a6125021a | ||
|
|
da26887f24 | ||
|
|
1b2f7139f9 | ||
|
|
aafbc86180 | ||
|
|
3d4ca1b7c0 | ||
|
|
1180aa38bd | ||
|
|
65f65e5c7b | ||
|
|
f81a12d5e8 | ||
|
|
cf6f5fbefe | ||
|
|
908d7710db | ||
|
|
7d5b41e31f | ||
|
|
e46a0bc9f3 |
No files matched your search
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=3.0.0" \
|
||||
"praisonai>=3.0.2" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=3.0.0" \
|
||||
"praisonai>=3.0.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=3.0.0" \
|
||||
"praisonai>=3.0.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Basic Profiling Example for PraisonAI
|
||||
|
||||
This example demonstrates how to use the profiler programmatically
|
||||
This example demonstrates how to use the unified profiler programmatically
|
||||
to measure agent performance.
|
||||
|
||||
Run with:
|
||||
@@ -15,51 +15,56 @@ if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("Please set OPENAI_API_KEY environment variable")
|
||||
exit(1)
|
||||
|
||||
from praisonai.cli.features.profiler import (
|
||||
from praisonai.cli.execution import (
|
||||
ExecutionRequest,
|
||||
Profiler,
|
||||
ProfilerConfig,
|
||||
QueryProfiler,
|
||||
format_profile_report,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run a profiled query and display results."""
|
||||
|
||||
# Configure profiler
|
||||
# Configure profiler (Layer 1 = basic profiling with function stats)
|
||||
config = ProfilerConfig(
|
||||
deep=False, # Set True for detailed call tracing (slower)
|
||||
layer=1, # 0=minimal, 1=basic, 2=deep
|
||||
limit=15, # Show top 15 functions
|
||||
show_files=True, # Group by file
|
||||
first_token=False, # Track time to first token (for streaming)
|
||||
show_callers=False, # Set True for caller info (layer 2)
|
||||
show_callees=False, # Set True for callee info (layer 2)
|
||||
)
|
||||
|
||||
# Create profiler
|
||||
profiler = QueryProfiler(config)
|
||||
# Create execution request
|
||||
request = ExecutionRequest(
|
||||
prompt="What is 2+2?",
|
||||
agent_name="ProfiledAgent",
|
||||
)
|
||||
|
||||
# Run profiled query
|
||||
print("🔬 Running profiled query...")
|
||||
result = profiler.profile_query(
|
||||
prompt="What is 2+2?",
|
||||
model=None, # Use default model
|
||||
stream=False,
|
||||
)
|
||||
profiler = Profiler(config)
|
||||
result, report = profiler.profile_sync(request)
|
||||
|
||||
# Print formatted report
|
||||
print("\n" + format_profile_report(result, config))
|
||||
print("\n" + report.to_text())
|
||||
|
||||
# Access timing data programmatically
|
||||
print("\n📊 Timing Summary:")
|
||||
print(f" Imports: {result.timing.imports_ms:>10.2f} ms")
|
||||
print(f" Agent Construct:{result.timing.agent_construction_ms:>10.2f} ms")
|
||||
print(f" Total Run: {result.timing.total_run_ms:>10.2f} ms")
|
||||
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
|
||||
print("\n🔥 Top 5 Functions by Cumulative Time:")
|
||||
for i, func in enumerate(result.top_functions[:5], 1):
|
||||
print(f" {i}. {func.name}: {func.cumtime_ms:.2f}ms")
|
||||
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 preview
|
||||
print(f"\n💬 Response: {result.response[:100]}...")
|
||||
# Show response
|
||||
print(f"\n💬 Response: {result.output}")
|
||||
|
||||
# Export to JSON
|
||||
print("\n📄 JSON Export available via: report.to_json()")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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/v3.0.0.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.0.0.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.0.2.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.0.2.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -20,62 +20,61 @@ def profile_query(
|
||||
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_files: bool = typer.Option(False, "--show-files", help="Group timing by file/module"),
|
||||
show_callers: bool = typer.Option(False, "--show-callers", help="Show caller functions"),
|
||||
show_callees: bool = typer.Option(False, "--show-callees", help="Show callee functions"),
|
||||
importtime: bool = typer.Option(False, "--importtime", help="Show module import times"),
|
||||
first_token: bool = typer.Option(False, "--first-token", help="Track time to first token (streaming)"),
|
||||
save: Optional[str] = typer.Option(None, "--save", help="Save artifacts to path (creates .prof, .txt)"),
|
||||
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.
|
||||
|
||||
Shows per-function and per-file timing, call graphs, and latency metrics.
|
||||
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" --show-files --limit 20
|
||||
praisonai profile query "Test" --stream --first-token
|
||||
praisonai profile query "Hello" --limit 20
|
||||
praisonai profile query "Test" --deep --show-callers --show-callees
|
||||
praisonai profile query "Test" --save ./profile_results
|
||||
"""
|
||||
# Lazy import to avoid startup overhead
|
||||
# Use unified profiler for consistent results
|
||||
try:
|
||||
from ..features.profiler import (
|
||||
ProfilerConfig,
|
||||
QueryProfiler,
|
||||
format_profile_report,
|
||||
)
|
||||
from ..execution import ExecutionRequest, Profiler, ProfilerConfig as UnifiedProfilerConfig
|
||||
except ImportError as e:
|
||||
typer.echo(f"Error: Profiler module not available: {e}", err=True)
|
||||
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 config
|
||||
config = ProfilerConfig(
|
||||
deep=deep,
|
||||
# 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_files=show_files,
|
||||
show_callers=show_callers,
|
||||
show_callees=show_callees,
|
||||
importtime=importtime,
|
||||
first_token=first_token,
|
||||
save_path=save,
|
||||
output_format=output_format,
|
||||
stream=stream,
|
||||
save_path=save,
|
||||
)
|
||||
|
||||
# Run profiler
|
||||
typer.echo("🔬 Starting profiled execution...", err=True)
|
||||
|
||||
try:
|
||||
profiler = QueryProfiler(config)
|
||||
result = profiler.profile_query(prompt, model=model, stream=stream)
|
||||
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)
|
||||
@@ -86,18 +85,28 @@ def profile_query(
|
||||
|
||||
# Output results
|
||||
if output_format == "json":
|
||||
import json
|
||||
typer.echo(json.dumps(result.to_dict(), indent=2, default=str))
|
||||
typer.echo(report.to_json())
|
||||
else:
|
||||
report = format_profile_report(result, config)
|
||||
typer.echo(report)
|
||||
typer.echo(report.to_text())
|
||||
|
||||
# Save artifacts if requested
|
||||
if save:
|
||||
try:
|
||||
prof_path, txt_path = profiler.save_artifacts(result, save)
|
||||
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" {prof_path}", 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)
|
||||
|
||||
@@ -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,814 @@
|
||||
"""
|
||||
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(),
|
||||
"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_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 SECTION (new - 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,
|
||||
)
|
||||
@@ -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,31 @@ 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
|
||||
# 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
|
||||
)
|
||||
|
||||
# Inject chat history from session store for context continuity
|
||||
if run.chat_history:
|
||||
agent.chat_history = list(run.chat_history)
|
||||
logger.debug(f"Injected {len(run.chat_history)} messages from session history")
|
||||
|
||||
# Check for cancellation before starting
|
||||
if self.scheduler.is_cancelled(run.run_id):
|
||||
logger.debug(f"Run {run.run_id} was cancelled before execution")
|
||||
|
||||
@@ -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,12 @@ 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, get_session_store
|
||||
|
||||
class TUIApp(App):
|
||||
"""
|
||||
@@ -54,9 +56,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 +69,7 @@ if TEXTUAL_AVAILABLE:
|
||||
"queue": QueueScreen,
|
||||
"settings": SettingsScreen,
|
||||
"sessions": SessionScreen,
|
||||
"help": HelpScreen,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -93,6 +99,9 @@ if TEXTUAL_AVAILABLE:
|
||||
self.queue_config = queue_config or QueueConfig()
|
||||
self.queue_manager: Optional[QueueManager] = None
|
||||
|
||||
# Session store for chat history persistence
|
||||
self.session_store = get_session_store()
|
||||
|
||||
# State
|
||||
self._current_run_id: Optional[str] = None
|
||||
self._streaming_content: str = ""
|
||||
@@ -154,16 +163,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)
|
||||
@@ -172,6 +190,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()
|
||||
|
||||
@@ -186,23 +207,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"),
|
||||
@@ -211,8 +254,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
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -220,6 +263,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(
|
||||
@@ -239,6 +285,7 @@ if TEXTUAL_AVAILABLE:
|
||||
)
|
||||
|
||||
self._update_status()
|
||||
self._update_queue_panel_live()
|
||||
|
||||
async def on_main_screen_command_executed(
|
||||
self, event: MainScreen.CommandExecuted
|
||||
@@ -323,6 +370,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:
|
||||
@@ -338,14 +401,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)
|
||||
@@ -356,6 +422,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."""
|
||||
@@ -369,6 +436,7 @@ if TEXTUAL_AVAILABLE:
|
||||
self._streaming_content = ""
|
||||
|
||||
self._update_status()
|
||||
self._update_queue_panel_live()
|
||||
|
||||
# Command handlers
|
||||
|
||||
@@ -388,18 +456,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):
|
||||
@@ -476,6 +549,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."""
|
||||
@@ -494,6 +569,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:
|
||||
@@ -510,6 +694,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):
|
||||
@@ -527,6 +724,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()
|
||||
|
||||
@@ -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,181 @@
|
||||
"""
|
||||
Session Store for PraisonAI TUI.
|
||||
|
||||
Maintains chat history in memory for session persistence.
|
||||
This allows the AI to remember previous messages within a session.
|
||||
"""
|
||||
|
||||
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."""
|
||||
|
||||
@@ -785,6 +785,11 @@ class PraisonAI:
|
||||
# Telemetry - usage monitoring
|
||||
parser.add_argument("--telemetry", action="store_true", help="Enable usage monitoring and analytics")
|
||||
|
||||
# Profiling - unified execution profiling
|
||||
parser.add_argument("--profile", action="store_true", help="Enable profiling with timing breakdown")
|
||||
parser.add_argument("--profile-deep", action="store_true", dest="profile_deep", help="Enable deep profiling with call graph (higher overhead)")
|
||||
parser.add_argument("--profile-format", type=str, choices=["text", "json"], default="text", dest="profile_format", help="Profile output format")
|
||||
|
||||
# MCP - Model Context Protocol
|
||||
parser.add_argument("--mcp", type=str, help="MCP server command (e.g., 'npx -y @modelcontextprotocol/server-filesystem .')")
|
||||
parser.add_argument("--mcp-env", type=str, help="MCP environment variables (KEY=value,KEY2=value2)")
|
||||
@@ -3459,6 +3464,10 @@ Provide ONLY the commit message, no explanations."""
|
||||
- @rule:name - Include specific rule
|
||||
- @url:https://... - Fetch URL content
|
||||
"""
|
||||
# Check for profiling mode - use unified profiler
|
||||
if hasattr(self, 'args') and getattr(self.args, 'profile', False):
|
||||
return self._handle_profiled_prompt(prompt)
|
||||
|
||||
# Check for inline workflow mode
|
||||
if hasattr(self, 'args') and getattr(self.args, 'workflow', None):
|
||||
return self._run_inline_workflow(prompt)
|
||||
@@ -3934,6 +3943,55 @@ Now, {final_instruction.lower()}:"""
|
||||
print("pip install praisonaiagents # For PraisonAIAgents\n")
|
||||
sys.exit(1)
|
||||
|
||||
def _handle_profiled_prompt(self, prompt):
|
||||
"""
|
||||
Handle direct prompt with unified profiling enabled.
|
||||
|
||||
Uses the unified execution module for consistent profiling across
|
||||
CLI direct invocation and profile command.
|
||||
"""
|
||||
from .execution import ExecutionRequest, Profiler, ProfilerConfig
|
||||
|
||||
# Build execution request
|
||||
model = None
|
||||
if hasattr(self, 'args') and self.args.llm:
|
||||
model = self.args.llm
|
||||
|
||||
request = ExecutionRequest(
|
||||
prompt=prompt,
|
||||
agent_name="DirectAgent",
|
||||
model=model,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
# Create profiler config from flags
|
||||
deep = getattr(self.args, 'profile_deep', False)
|
||||
output_format = getattr(self.args, 'profile_format', 'text')
|
||||
|
||||
config = ProfilerConfig(
|
||||
layer=2 if deep else 1,
|
||||
show_callers=deep,
|
||||
show_callees=deep,
|
||||
output_format=output_format,
|
||||
)
|
||||
|
||||
# Run with profiling
|
||||
print("[bold cyan]🔬 Starting profiled execution...[/bold cyan]")
|
||||
if deep:
|
||||
print("[yellow]⚠️ Deep profiling enabled - higher overhead[/yellow]")
|
||||
|
||||
profiler = Profiler(config)
|
||||
result, report = profiler.profile_sync(request, invocation_method="cli_direct")
|
||||
|
||||
# Output profile report
|
||||
if output_format == "json":
|
||||
print(report.to_json())
|
||||
else:
|
||||
print(report.to_text())
|
||||
|
||||
# Return the actual result for any downstream processing
|
||||
return result.output
|
||||
|
||||
def _run_with_status_display(self, agent, prompt):
|
||||
"""
|
||||
Run agent with minimal status display (spinner + tool/handoff updates).
|
||||
|
||||
@@ -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==3.0.0 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==3.0.2 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.0.0"
|
||||
__version__ = "3.0.2"
|
||||
@@ -0,0 +1,630 @@
|
||||
"""
|
||||
Unit tests for the Unified Profiling Architecture.
|
||||
|
||||
Tests the core invariants:
|
||||
1. Profiled execution produces identical output to non-profiled execution
|
||||
2. Schema equivalence across invocation methods
|
||||
3. Overhead bounds
|
||||
4. Profile data bounds
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestExecutionRequest:
|
||||
"""Tests for ExecutionRequest dataclass."""
|
||||
|
||||
def test_create_basic_request(self):
|
||||
"""Test creating a basic execution request."""
|
||||
from praisonai.cli.execution import ExecutionRequest
|
||||
|
||||
req = ExecutionRequest(prompt="What is 2+2?")
|
||||
|
||||
assert req.prompt == "What is 2+2?"
|
||||
assert req.agent_name == "Assistant"
|
||||
assert req.model is None
|
||||
assert req.stream is False
|
||||
assert req.tools == ()
|
||||
|
||||
def test_request_immutability(self):
|
||||
"""Test that ExecutionRequest is immutable (frozen)."""
|
||||
from praisonai.cli.execution import ExecutionRequest
|
||||
|
||||
req = ExecutionRequest(prompt="test")
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
req.prompt = "modified"
|
||||
|
||||
def test_request_with_model(self):
|
||||
"""Test creating request with model."""
|
||||
from praisonai.cli.execution import ExecutionRequest
|
||||
|
||||
req = ExecutionRequest(prompt="test", model="gpt-4")
|
||||
new_req = req.with_model("gpt-3.5-turbo")
|
||||
|
||||
assert req.model == "gpt-4"
|
||||
assert new_req.model == "gpt-3.5-turbo"
|
||||
assert req.prompt == new_req.prompt
|
||||
|
||||
def test_request_validation(self):
|
||||
"""Test that empty prompt raises error."""
|
||||
from praisonai.cli.execution import ExecutionRequest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ExecutionRequest(prompt="")
|
||||
|
||||
def test_request_to_dict(self):
|
||||
"""Test serialization to dict."""
|
||||
from praisonai.cli.execution import ExecutionRequest
|
||||
|
||||
req = ExecutionRequest(
|
||||
prompt="test",
|
||||
agent_name="TestAgent",
|
||||
model="gpt-4",
|
||||
)
|
||||
|
||||
d = req.to_dict()
|
||||
|
||||
assert d["prompt"] == "test"
|
||||
assert d["agent_name"] == "TestAgent"
|
||||
assert d["model"] == "gpt-4"
|
||||
assert d["tools_count"] == 0
|
||||
|
||||
|
||||
class TestExecutionResult:
|
||||
"""Tests for ExecutionResult dataclass."""
|
||||
|
||||
def test_create_basic_result(self):
|
||||
"""Test creating a basic execution result."""
|
||||
from praisonai.cli.execution.result import ExecutionResult
|
||||
|
||||
result = ExecutionResult(output="four")
|
||||
|
||||
assert result.output == "four"
|
||||
assert result.success is True
|
||||
assert result.error is None
|
||||
|
||||
def test_result_from_error(self):
|
||||
"""Test creating result from error."""
|
||||
from praisonai.cli.execution.result import ExecutionResult
|
||||
|
||||
result = ExecutionResult.from_error("Something went wrong")
|
||||
|
||||
assert result.output == ""
|
||||
assert result.success is False
|
||||
assert result.error == "Something went wrong"
|
||||
|
||||
def test_result_duration(self):
|
||||
"""Test duration calculation."""
|
||||
from praisonai.cli.execution.result import ExecutionResult
|
||||
|
||||
result = ExecutionResult(
|
||||
output="test",
|
||||
start_time=1000.0,
|
||||
end_time=1002.5,
|
||||
)
|
||||
|
||||
assert result.duration_ms == 2500.0
|
||||
|
||||
|
||||
class TestProfilerConfig:
|
||||
"""Tests for ProfilerConfig."""
|
||||
|
||||
def test_default_config(self):
|
||||
"""Test default profiler configuration."""
|
||||
from praisonai.cli.execution.profiler import ProfilerConfig
|
||||
|
||||
config = ProfilerConfig()
|
||||
|
||||
assert config.layer == 1
|
||||
assert config.limit == 30
|
||||
assert config.track_network is False
|
||||
|
||||
def test_from_flags_basic(self):
|
||||
"""Test creating config from basic --profile flag."""
|
||||
from praisonai.cli.execution.profiler import ProfilerConfig
|
||||
|
||||
config = ProfilerConfig.from_flags(profile=True)
|
||||
|
||||
assert config.layer == 1
|
||||
|
||||
def test_from_flags_deep(self):
|
||||
"""Test creating config from --profile --deep flags."""
|
||||
from praisonai.cli.execution.profiler import ProfilerConfig
|
||||
|
||||
config = ProfilerConfig.from_flags(profile=True, deep=True)
|
||||
|
||||
assert config.layer == 2
|
||||
assert config.show_callers is True
|
||||
assert config.show_callees is True
|
||||
|
||||
def test_from_flags_network(self):
|
||||
"""Test creating config with network tracking."""
|
||||
from praisonai.cli.execution.profiler import ProfilerConfig
|
||||
|
||||
config = ProfilerConfig.from_flags(profile=True, network=True)
|
||||
|
||||
assert config.track_network is True
|
||||
|
||||
|
||||
class TestTimingBreakdown:
|
||||
"""Tests for TimingBreakdown."""
|
||||
|
||||
def test_timing_to_dict(self):
|
||||
"""Test timing serialization."""
|
||||
from praisonai.cli.execution.profiler import TimingBreakdown
|
||||
|
||||
timing = TimingBreakdown(
|
||||
total_ms=1000.0,
|
||||
imports_ms=500.0,
|
||||
agent_init_ms=10.0,
|
||||
execution_ms=490.0,
|
||||
)
|
||||
|
||||
d = timing.to_dict()
|
||||
|
||||
assert d["total_ms"] == 1000.0
|
||||
assert d["imports_ms"] == 500.0
|
||||
assert d["agent_init_ms"] == 10.0
|
||||
assert d["execution_ms"] == 490.0
|
||||
|
||||
|
||||
class TestProfileReport:
|
||||
"""Tests for ProfileReport."""
|
||||
|
||||
def test_report_schema_version(self):
|
||||
"""Test that schema version is set."""
|
||||
from praisonai.cli.execution.profiler import ProfileReport, SCHEMA_VERSION
|
||||
|
||||
report = ProfileReport(run_id="test123")
|
||||
|
||||
assert report.schema_version == SCHEMA_VERSION
|
||||
assert report.schema_version == "1.0"
|
||||
|
||||
def test_report_timestamp(self):
|
||||
"""Test that timestamp is auto-generated."""
|
||||
from praisonai.cli.execution.profiler import ProfileReport
|
||||
|
||||
report = ProfileReport(run_id="test123")
|
||||
|
||||
assert report.timestamp is not None
|
||||
assert "T" in report.timestamp # ISO 8601 format
|
||||
|
||||
def test_report_to_dict(self):
|
||||
"""Test report serialization."""
|
||||
from praisonai.cli.execution.profiler import (
|
||||
ProfileReport, TimingBreakdown, InvocationInfo
|
||||
)
|
||||
|
||||
report = ProfileReport(
|
||||
run_id="test123",
|
||||
timing=TimingBreakdown(total_ms=100),
|
||||
invocation=InvocationInfo(method="test"),
|
||||
)
|
||||
|
||||
d = report.to_dict()
|
||||
|
||||
assert d["schema_version"] == "1.0"
|
||||
assert d["run_id"] == "test123"
|
||||
assert "timing" in d
|
||||
assert "invocation" in d
|
||||
|
||||
def test_report_to_json(self):
|
||||
"""Test JSON serialization."""
|
||||
from praisonai.cli.execution.profiler import ProfileReport
|
||||
import json
|
||||
|
||||
report = ProfileReport(run_id="test123")
|
||||
|
||||
json_str = report.to_json()
|
||||
parsed = json.loads(json_str)
|
||||
|
||||
assert parsed["run_id"] == "test123"
|
||||
|
||||
def test_report_to_text(self):
|
||||
"""Test text formatting."""
|
||||
from praisonai.cli.execution.profiler import ProfileReport
|
||||
|
||||
report = ProfileReport(run_id="test123")
|
||||
|
||||
text = report.to_text()
|
||||
|
||||
assert "test123" in text
|
||||
assert "Timing Breakdown" in text
|
||||
|
||||
|
||||
class TestProfilerBounds:
|
||||
"""Tests for profile data bounds."""
|
||||
|
||||
def test_max_function_stats(self):
|
||||
"""Test that function stats are bounded."""
|
||||
from praisonai.cli.execution.profiler import MAX_FUNCTION_STATS
|
||||
|
||||
assert MAX_FUNCTION_STATS == 1000
|
||||
|
||||
def test_max_call_graph_edges(self):
|
||||
"""Test that call graph edges are bounded."""
|
||||
from praisonai.cli.execution.profiler import MAX_CALL_GRAPH_EDGES
|
||||
|
||||
assert MAX_CALL_GRAPH_EDGES == 5000
|
||||
|
||||
def test_max_network_requests(self):
|
||||
"""Test that network requests are bounded."""
|
||||
from praisonai.cli.execution.profiler import MAX_NETWORK_REQUESTS
|
||||
|
||||
assert MAX_NETWORK_REQUESTS == 100
|
||||
|
||||
|
||||
class TestSchemaStability:
|
||||
"""Tests for schema stability guarantees."""
|
||||
|
||||
def test_mandatory_fields_present(self):
|
||||
"""Test that all mandatory fields are present in report."""
|
||||
from praisonai.cli.execution.profiler import ProfileReport
|
||||
|
||||
report = ProfileReport(run_id="test")
|
||||
d = report.to_dict()
|
||||
|
||||
# Mandatory fields
|
||||
assert "schema_version" in d
|
||||
assert "run_id" in d
|
||||
assert "timestamp" in d
|
||||
assert "invocation" in d
|
||||
assert "timing" in d
|
||||
assert "response_preview" in d
|
||||
|
||||
def test_optional_fields_nullable(self):
|
||||
"""Test that optional fields can be None."""
|
||||
from praisonai.cli.execution.profiler import ProfileReport
|
||||
|
||||
report = ProfileReport(run_id="test")
|
||||
d = report.to_dict()
|
||||
|
||||
# Optional fields should not be present if None
|
||||
assert "functions" not in d or d["functions"] is None
|
||||
assert "call_graph" not in d or d["call_graph"] is None
|
||||
assert "network" not in d or d["network"] is None
|
||||
|
||||
|
||||
class TestInvocationInfo:
|
||||
"""Tests for InvocationInfo."""
|
||||
|
||||
def test_version_auto_populated(self):
|
||||
"""Test that versions are auto-populated."""
|
||||
from praisonai.cli.execution.profiler import InvocationInfo
|
||||
|
||||
info = InvocationInfo(method="test")
|
||||
|
||||
assert info.python_version is not None
|
||||
assert len(info.python_version) > 0
|
||||
|
||||
|
||||
class TestCallGraph:
|
||||
"""Tests for CallGraph."""
|
||||
|
||||
def test_edges_property(self):
|
||||
"""Test edges property."""
|
||||
from praisonai.cli.execution.profiler import CallGraph
|
||||
|
||||
graph = CallGraph(
|
||||
callers={"func_b": ["func_a"]},
|
||||
callees={"func_a": ["func_b"]},
|
||||
)
|
||||
|
||||
edges = graph.edges
|
||||
|
||||
assert len(edges) == 1
|
||||
assert ("func_a", "func_b") in edges
|
||||
|
||||
|
||||
class TestRequestTiming:
|
||||
"""Tests for RequestTiming."""
|
||||
|
||||
def test_ttfb_calculation(self):
|
||||
"""Test time to first byte calculation."""
|
||||
from praisonai.cli.execution.profiler import RequestTiming
|
||||
|
||||
timing = RequestTiming(
|
||||
url="https://api.openai.com",
|
||||
method="POST",
|
||||
start_time=1000.0,
|
||||
first_byte_time=1001.5,
|
||||
end_time=1002.0,
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
assert timing.ttfb_ms == 1500.0
|
||||
assert timing.total_ms == 2000.0
|
||||
|
||||
|
||||
# Integration tests (require mocking)
|
||||
class TestProfilerIntegration:
|
||||
"""Integration tests for Profiler class."""
|
||||
|
||||
def test_profiler_creates_report(self):
|
||||
"""Test that profiler creates a valid report."""
|
||||
from praisonai.cli.execution.profiler import Profiler, ProfilerConfig
|
||||
from praisonai.cli.execution.request import ExecutionRequest
|
||||
from praisonai.cli.execution.result import ExecutionResult
|
||||
|
||||
# Mock at the profiler level by patching the imported _execute_core
|
||||
with patch('praisonai.cli.execution.profiler._execute_core') as mock_exec:
|
||||
mock_exec.return_value = ExecutionResult(
|
||||
output="four",
|
||||
run_id="test123",
|
||||
)
|
||||
|
||||
config = ProfilerConfig(layer=0) # Minimal profiling
|
||||
profiler = Profiler(config)
|
||||
|
||||
req = ExecutionRequest(prompt="What is 2+2?")
|
||||
result, report = profiler.profile_sync(req)
|
||||
|
||||
assert result.output == "four"
|
||||
assert report.schema_version == "1.0"
|
||||
assert report.timing.total_ms > 0
|
||||
|
||||
def test_output_equivalence_profiled_vs_non_profiled(self):
|
||||
"""INVARIANT 1: Profiled execution produces identical output."""
|
||||
from praisonai.cli.execution.profiler import Profiler, ProfilerConfig
|
||||
from praisonai.cli.execution.request import ExecutionRequest
|
||||
from praisonai.cli.execution.result import ExecutionResult
|
||||
|
||||
expected_output = "test_output_value"
|
||||
|
||||
with patch('praisonai.cli.execution.profiler._execute_core') as mock_exec:
|
||||
mock_exec.return_value = ExecutionResult(
|
||||
output=expected_output,
|
||||
run_id="test123",
|
||||
)
|
||||
|
||||
req = ExecutionRequest(prompt="test")
|
||||
|
||||
# Layer 0 (minimal)
|
||||
config0 = ProfilerConfig(layer=0)
|
||||
result0, _ = Profiler(config0).profile_sync(req)
|
||||
|
||||
# Layer 1 (basic)
|
||||
config1 = ProfilerConfig(layer=1)
|
||||
result1, _ = Profiler(config1).profile_sync(req)
|
||||
|
||||
# Layer 2 (deep)
|
||||
config2 = ProfilerConfig(layer=2)
|
||||
result2, _ = Profiler(config2).profile_sync(req)
|
||||
|
||||
# All outputs must be identical
|
||||
assert result0.output == expected_output
|
||||
assert result1.output == expected_output
|
||||
assert result2.output == expected_output
|
||||
|
||||
def test_schema_equivalence_across_invocation_methods(self):
|
||||
"""INVARIANT 2: Schema equivalence across invocation methods."""
|
||||
from praisonai.cli.execution.profiler import Profiler, ProfilerConfig
|
||||
from praisonai.cli.execution.request import ExecutionRequest
|
||||
from praisonai.cli.execution.result import ExecutionResult
|
||||
|
||||
with patch('praisonai.cli.execution.profiler._execute_core') as mock_exec:
|
||||
mock_exec.return_value = ExecutionResult(
|
||||
output="test",
|
||||
run_id="test123",
|
||||
)
|
||||
|
||||
req = ExecutionRequest(prompt="test")
|
||||
config = ProfilerConfig(layer=1)
|
||||
|
||||
# CLI direct
|
||||
_, report1 = Profiler(config).profile_sync(req, invocation_method="cli_direct")
|
||||
|
||||
# Profile command
|
||||
_, report2 = Profiler(config).profile_sync(req, invocation_method="profile_command")
|
||||
|
||||
# Schema must be identical
|
||||
dict1 = report1.to_dict()
|
||||
dict2 = report2.to_dict()
|
||||
|
||||
assert dict1["schema_version"] == dict2["schema_version"]
|
||||
assert set(dict1["timing"].keys()) == set(dict2["timing"].keys())
|
||||
assert set(dict1["invocation"].keys()) == set(dict2["invocation"].keys())
|
||||
|
||||
def test_data_bounds_function_stats(self):
|
||||
"""INVARIANT 4: Function stats are bounded."""
|
||||
from praisonai.cli.execution.profiler import (
|
||||
Profiler, ProfilerConfig, MAX_FUNCTION_STATS
|
||||
)
|
||||
from praisonai.cli.execution.request import ExecutionRequest
|
||||
from praisonai.cli.execution.result import ExecutionResult
|
||||
|
||||
with patch('praisonai.cli.execution.profiler._execute_core') as mock_exec:
|
||||
mock_exec.return_value = ExecutionResult(
|
||||
output="test",
|
||||
run_id="test123",
|
||||
)
|
||||
|
||||
req = ExecutionRequest(prompt="test")
|
||||
config = ProfilerConfig(layer=1, limit=50)
|
||||
|
||||
_, report = Profiler(config).profile_sync(req)
|
||||
|
||||
# Functions should be bounded by limit
|
||||
if report.functions:
|
||||
assert len(report.functions) <= config.limit
|
||||
assert len(report.functions) <= MAX_FUNCTION_STATS
|
||||
|
||||
def test_layer_0_minimal_overhead(self):
|
||||
"""INVARIANT 3: Layer 0 has minimal overhead (<1ms)."""
|
||||
from praisonai.cli.execution.profiler import Profiler, ProfilerConfig
|
||||
from praisonai.cli.execution.request import ExecutionRequest
|
||||
from praisonai.cli.execution.result import ExecutionResult
|
||||
import time
|
||||
|
||||
with patch('praisonai.cli.execution.profiler._execute_core') as mock_exec:
|
||||
# Fast mock execution
|
||||
mock_exec.return_value = ExecutionResult(
|
||||
output="test",
|
||||
run_id="test123",
|
||||
)
|
||||
|
||||
req = ExecutionRequest(prompt="test")
|
||||
config = ProfilerConfig(layer=0)
|
||||
|
||||
start = time.perf_counter()
|
||||
Profiler(config).profile_sync(req)
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
|
||||
# Layer 0 overhead should be minimal (allow some margin for test environment)
|
||||
# The actual profiling overhead (excluding execution) should be < 1ms
|
||||
# We can't measure this precisely with mocks, but we verify it runs fast
|
||||
assert elapsed < 100 # Very generous bound for test environment
|
||||
|
||||
def test_ttfr_is_populated(self):
|
||||
"""Test that Time to First Response (TTFR) is populated and > 0."""
|
||||
from praisonai.cli.execution.profiler import Profiler, ProfilerConfig
|
||||
from praisonai.cli.execution.request import ExecutionRequest
|
||||
from praisonai.cli.execution.result import ExecutionResult
|
||||
|
||||
with patch('praisonai.cli.execution.profiler._execute_core') as mock_exec:
|
||||
mock_exec.return_value = ExecutionResult(
|
||||
output="test",
|
||||
run_id="test123",
|
||||
)
|
||||
|
||||
req = ExecutionRequest(prompt="test")
|
||||
config = ProfilerConfig(layer=1)
|
||||
|
||||
_, report = Profiler(config).profile_sync(req)
|
||||
|
||||
# TTFR must be populated
|
||||
assert report.timing.time_to_first_response_ms > 0
|
||||
assert report.timing.first_output_ms >= 0
|
||||
|
||||
def test_first_output_less_than_total(self):
|
||||
"""Test that first_output_ms <= total_ms."""
|
||||
from praisonai.cli.execution.profiler import Profiler, ProfilerConfig
|
||||
from praisonai.cli.execution.request import ExecutionRequest
|
||||
from praisonai.cli.execution.result import ExecutionResult
|
||||
|
||||
with patch('praisonai.cli.execution.profiler._execute_core') as mock_exec:
|
||||
mock_exec.return_value = ExecutionResult(
|
||||
output="test",
|
||||
run_id="test123",
|
||||
)
|
||||
|
||||
req = ExecutionRequest(prompt="test")
|
||||
config = ProfilerConfig(layer=1)
|
||||
|
||||
_, report = Profiler(config).profile_sync(req)
|
||||
|
||||
# first_output must be <= total
|
||||
assert report.timing.first_output_ms <= report.timing.total_ms
|
||||
|
||||
def test_timeline_phases_ordered(self):
|
||||
"""Test that timeline phases are correctly ordered."""
|
||||
from praisonai.cli.execution.profiler import TimingBreakdown
|
||||
|
||||
timing = TimingBreakdown(
|
||||
total_ms=1000.0,
|
||||
imports_ms=200.0,
|
||||
agent_init_ms=50.0,
|
||||
execution_ms=700.0,
|
||||
)
|
||||
|
||||
timeline = timing.to_timeline()
|
||||
|
||||
# Timeline should have entries
|
||||
assert len(timeline) > 0
|
||||
|
||||
# Each entry should have (name, start, duration)
|
||||
for entry in timeline:
|
||||
assert len(entry) == 3
|
||||
name, start, duration = entry
|
||||
assert isinstance(name, str)
|
||||
assert isinstance(start, float)
|
||||
assert isinstance(duration, float)
|
||||
|
||||
def test_decision_trace_in_deep_profile(self):
|
||||
"""Test that decision trace is present in deep profile (layer 2)."""
|
||||
from praisonai.cli.execution.profiler import Profiler, ProfilerConfig
|
||||
from praisonai.cli.execution.request import ExecutionRequest
|
||||
from praisonai.cli.execution.result import ExecutionResult
|
||||
|
||||
with patch('praisonai.cli.execution.profiler._execute_core') as mock_exec:
|
||||
mock_exec.return_value = ExecutionResult(
|
||||
output="test",
|
||||
run_id="test123",
|
||||
)
|
||||
|
||||
req = ExecutionRequest(prompt="test", model="gpt-4")
|
||||
config = ProfilerConfig(layer=2) # Deep profile
|
||||
|
||||
_, report = Profiler(config).profile_sync(req)
|
||||
|
||||
# Decision trace must be present in deep profile
|
||||
assert report.decision_trace is not None
|
||||
assert report.decision_trace.model_selected == "gpt-4"
|
||||
assert report.decision_trace.profile_layer == 2
|
||||
|
||||
def test_module_breakdown_in_deep_profile(self):
|
||||
"""Test that module breakdown is present in deep profile (layer 2)."""
|
||||
from praisonai.cli.execution.profiler import Profiler, ProfilerConfig
|
||||
from praisonai.cli.execution.request import ExecutionRequest
|
||||
from praisonai.cli.execution.result import ExecutionResult
|
||||
|
||||
with patch('praisonai.cli.execution.profiler._execute_core') as mock_exec:
|
||||
mock_exec.return_value = ExecutionResult(
|
||||
output="test",
|
||||
run_id="test123",
|
||||
)
|
||||
|
||||
req = ExecutionRequest(prompt="test")
|
||||
config = ProfilerConfig(layer=2) # Deep profile
|
||||
|
||||
_, report = Profiler(config).profile_sync(req)
|
||||
|
||||
# Module breakdown must be present in deep profile
|
||||
assert report.module_breakdown is not None
|
||||
|
||||
def test_text_output_includes_timeline(self):
|
||||
"""Test that text output includes timeline section."""
|
||||
from praisonai.cli.execution.profiler import Profiler, ProfilerConfig
|
||||
from praisonai.cli.execution.request import ExecutionRequest
|
||||
from praisonai.cli.execution.result import ExecutionResult
|
||||
|
||||
with patch('praisonai.cli.execution.profiler._execute_core') as mock_exec:
|
||||
mock_exec.return_value = ExecutionResult(
|
||||
output="test",
|
||||
run_id="test123",
|
||||
)
|
||||
|
||||
req = ExecutionRequest(prompt="test")
|
||||
config = ProfilerConfig(layer=1)
|
||||
|
||||
_, report = Profiler(config).profile_sync(req)
|
||||
text = report.to_text()
|
||||
|
||||
# Text output must include timeline and TTFR
|
||||
assert "Execution Timeline" in text
|
||||
assert "Time to First Response" in text
|
||||
|
||||
def test_json_output_includes_ttfr(self):
|
||||
"""Test that JSON output includes time_to_first_response_ms."""
|
||||
from praisonai.cli.execution.profiler import Profiler, ProfilerConfig
|
||||
from praisonai.cli.execution.request import ExecutionRequest
|
||||
from praisonai.cli.execution.result import ExecutionResult
|
||||
import json
|
||||
|
||||
with patch('praisonai.cli.execution.profiler._execute_core') as mock_exec:
|
||||
mock_exec.return_value = ExecutionResult(
|
||||
output="test",
|
||||
run_id="test123",
|
||||
)
|
||||
|
||||
req = ExecutionRequest(prompt="test")
|
||||
config = ProfilerConfig(layer=1)
|
||||
|
||||
_, report = Profiler(config).profile_sync(req)
|
||||
data = json.loads(report.to_json())
|
||||
|
||||
# JSON must include TTFR
|
||||
assert "time_to_first_response_ms" in data["timing"]
|
||||
assert data["timing"]["time_to_first_response_ms"] > 0
|
||||
Whitespace-only changes.
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
TDD Test Suite for PraisonAI TUI Widgets.
|
||||
|
||||
Tests for:
|
||||
- ComposerWidget (Enter to send, Shift+Enter for newline)
|
||||
- HelpScreen (F1 key)
|
||||
- Keyboard shortcuts (Ctrl+Q, Ctrl+C, Ctrl+L)
|
||||
- Queue panel (edit/delete)
|
||||
- Tool panel (display tools)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
|
||||
# Check if textual is available
|
||||
try:
|
||||
from textual.pilot import Pilot
|
||||
from textual.app import App
|
||||
TEXTUAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
TEXTUAL_AVAILABLE = False
|
||||
pytest.skip("Textual not available", allow_module_level=True)
|
||||
|
||||
|
||||
class TestComposerWidget:
|
||||
"""Tests for ComposerWidget."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_text_area_enter_triggers_submit(self):
|
||||
"""Test that Enter key triggers submit."""
|
||||
from praisonai.cli.features.tui.widgets.composer import SubmitTextArea
|
||||
|
||||
# Create a mock app context
|
||||
text_area = SubmitTextArea("")
|
||||
|
||||
# Verify the class exists and has SubmitRequested message
|
||||
assert hasattr(SubmitTextArea, 'SubmitRequested')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_composer_widget_has_correct_bindings(self):
|
||||
"""Test ComposerWidget has correct key bindings."""
|
||||
from praisonai.cli.features.tui.widgets.composer import ComposerWidget
|
||||
|
||||
# Check bindings include enter for submit
|
||||
binding_keys = [b.key for b in ComposerWidget.BINDINGS]
|
||||
assert "enter" in binding_keys
|
||||
assert "escape" in binding_keys
|
||||
|
||||
|
||||
class TestHelpScreen:
|
||||
"""Tests for HelpScreen."""
|
||||
|
||||
def test_help_screen_exists(self):
|
||||
"""Test HelpScreen can be imported."""
|
||||
from praisonai.cli.features.tui.screens.help import HelpScreen
|
||||
assert HelpScreen is not None
|
||||
|
||||
def test_help_screen_has_dismiss_binding(self):
|
||||
"""Test HelpScreen has escape binding to dismiss."""
|
||||
from praisonai.cli.features.tui.screens.help import HelpScreen
|
||||
|
||||
binding_keys = [b.key for b in HelpScreen.BINDINGS]
|
||||
assert "escape" in binding_keys
|
||||
assert "q" in binding_keys
|
||||
|
||||
|
||||
class TestTUIApp:
|
||||
"""Tests for TUIApp."""
|
||||
|
||||
def test_tui_app_has_help_screen_registered(self):
|
||||
"""Test TUIApp has help screen in SCREENS."""
|
||||
from praisonai.cli.features.tui.app import TUIApp
|
||||
|
||||
assert "help" in TUIApp.SCREENS
|
||||
assert "main" in TUIApp.SCREENS
|
||||
assert "settings" in TUIApp.SCREENS
|
||||
assert "queue" in TUIApp.SCREENS
|
||||
|
||||
def test_tui_app_has_ctrl_bindings(self):
|
||||
"""Test TUIApp has Ctrl key bindings."""
|
||||
from praisonai.cli.features.tui.app import TUIApp
|
||||
|
||||
binding_keys = [b.key for b in TUIApp.BINDINGS]
|
||||
assert "ctrl+q" in binding_keys
|
||||
assert "ctrl+c" in binding_keys
|
||||
|
||||
|
||||
class TestMainScreen:
|
||||
"""Tests for MainScreen."""
|
||||
|
||||
def test_main_screen_has_safe_default_bindings(self):
|
||||
"""Test MainScreen has safe default bindings (no Ctrl/Fn by default)."""
|
||||
from praisonai.cli.features.tui.screens.main import MainScreen
|
||||
|
||||
binding_keys = [b.key for b in MainScreen.BINDINGS]
|
||||
# Safe defaults - single char shortcuts
|
||||
assert "q" in binding_keys # quit
|
||||
assert "colon" in binding_keys # command mode
|
||||
assert "escape" in binding_keys # cancel
|
||||
# Ctrl keys should NOT be in default bindings
|
||||
assert "ctrl+q" not in binding_keys
|
||||
assert "ctrl+c" not in binding_keys
|
||||
# Function keys should NOT be in default bindings
|
||||
assert "f1" not in binding_keys
|
||||
|
||||
|
||||
class TestTUIConfig:
|
||||
"""Tests for TUIConfig."""
|
||||
|
||||
def test_default_config_disables_ctrl_keys(self):
|
||||
"""Test default config has Ctrl keys disabled."""
|
||||
from praisonai.cli.features.tui.config import TUIConfig
|
||||
|
||||
config = TUIConfig()
|
||||
assert config.enable_ctrl_keys is False
|
||||
assert config.enable_fn_keys is False
|
||||
assert config.enable_single_char is True
|
||||
assert config.leader_key == ":"
|
||||
|
||||
def test_command_parsing(self):
|
||||
"""Test command mode parsing."""
|
||||
from praisonai.cli.features.tui.config import get_command
|
||||
|
||||
assert get_command("quit") == "quit"
|
||||
assert get_command("q") == "quit"
|
||||
assert get_command("exit") == "quit"
|
||||
assert get_command("clear") == "clear"
|
||||
assert get_command("cl") == "clear"
|
||||
assert get_command("invalid") is None
|
||||
|
||||
|
||||
class TestQueuePanel:
|
||||
"""Tests for QueuePanelWidget."""
|
||||
|
||||
def test_queue_panel_has_delete_message(self):
|
||||
"""Test QueuePanelWidget has DeleteRequested message."""
|
||||
from praisonai.cli.features.tui.widgets.queue_panel import QueuePanelWidget
|
||||
|
||||
assert hasattr(QueuePanelWidget, 'DeleteRequested')
|
||||
assert hasattr(QueuePanelWidget, 'EditRequested')
|
||||
|
||||
def test_queue_panel_has_delete_method(self):
|
||||
"""Test QueuePanelWidget has delete_selected method."""
|
||||
from praisonai.cli.features.tui.widgets.queue_panel import QueuePanelWidget
|
||||
|
||||
assert hasattr(QueuePanelWidget, 'delete_selected')
|
||||
assert hasattr(QueuePanelWidget, 'edit_selected')
|
||||
|
||||
|
||||
class TestToolPanel:
|
||||
"""Tests for ToolPanelWidget."""
|
||||
|
||||
def test_tool_panel_has_set_available_tools(self):
|
||||
"""Test ToolPanelWidget has set_available_tools method."""
|
||||
from praisonai.cli.features.tui.widgets.tool_panel import ToolPanelWidget
|
||||
|
||||
assert hasattr(ToolPanelWidget, 'set_available_tools')
|
||||
|
||||
|
||||
class TestInteractiveTools:
|
||||
"""Tests for interactive tools loading."""
|
||||
|
||||
def test_get_interactive_tools_exists(self):
|
||||
"""Test get_interactive_tools function exists."""
|
||||
from praisonai.cli.features.interactive_tools import get_interactive_tools
|
||||
|
||||
assert callable(get_interactive_tools)
|
||||
|
||||
def test_tool_config_exists(self):
|
||||
"""Test ToolConfig class exists."""
|
||||
from praisonai.cli.features.interactive_tools import ToolConfig
|
||||
|
||||
assert ToolConfig is not None
|
||||
|
||||
|
||||
class TestCommandPopup:
|
||||
"""Tests for CommandPopupWidget."""
|
||||
|
||||
def test_command_popup_exists(self):
|
||||
"""Test CommandPopupWidget can be imported."""
|
||||
from praisonai.cli.features.tui.widgets.command_popup import CommandPopupWidget
|
||||
assert CommandPopupWidget is not None
|
||||
|
||||
def test_command_info_dataclass(self):
|
||||
"""Test CommandInfo dataclass works correctly."""
|
||||
from praisonai.cli.features.tui.widgets.command_popup import CommandInfo
|
||||
|
||||
cmd = CommandInfo(
|
||||
name="test",
|
||||
description="Test command",
|
||||
aliases=["t", "tst"]
|
||||
)
|
||||
assert cmd.name == "test"
|
||||
assert cmd.description == "Test command"
|
||||
assert cmd.aliases == ["t", "tst"]
|
||||
assert cmd.display_name == "test (t, tst)"
|
||||
|
||||
def test_get_default_commands(self):
|
||||
"""Test get_default_commands returns command list."""
|
||||
from praisonai.cli.features.tui.widgets.command_popup import get_default_commands
|
||||
|
||||
commands = get_default_commands()
|
||||
assert len(commands) > 0
|
||||
|
||||
# Check for essential commands
|
||||
command_names = [c.name for c in commands]
|
||||
assert "help" in command_names
|
||||
assert "exit" in command_names
|
||||
assert "clear" in command_names
|
||||
assert "model" in command_names
|
||||
|
||||
def test_command_popup_has_messages(self):
|
||||
"""Test CommandPopupWidget has required message classes."""
|
||||
from praisonai.cli.features.tui.widgets.command_popup import CommandPopupWidget
|
||||
|
||||
assert hasattr(CommandPopupWidget, 'CommandSelected')
|
||||
assert hasattr(CommandPopupWidget, 'Dismissed')
|
||||
|
||||
|
||||
class TestMainScreenBackslash:
|
||||
"""Tests for MainScreen backslash command support."""
|
||||
|
||||
def test_main_screen_has_backslash_binding(self):
|
||||
"""Test MainScreen has backslash binding for commands."""
|
||||
from praisonai.cli.features.tui.screens.main import MainScreen
|
||||
|
||||
binding_keys = [b.key for b in MainScreen.BINDINGS]
|
||||
assert "backslash" in binding_keys
|
||||
|
||||
def test_main_screen_has_show_commands_action(self):
|
||||
"""Test MainScreen has action_show_commands method."""
|
||||
from praisonai.cli.features.tui.screens.main import MainScreen
|
||||
|
||||
assert hasattr(MainScreen, 'action_show_commands')
|
||||
|
||||
def test_main_screen_has_command_popup_handlers(self):
|
||||
"""Test MainScreen has command popup event handlers."""
|
||||
from praisonai.cli.features.tui.screens.main import MainScreen
|
||||
|
||||
assert hasattr(MainScreen, 'on_command_popup_widget_command_selected')
|
||||
assert hasattr(MainScreen, 'on_command_popup_widget_dismissed')
|
||||
|
||||
|
||||
class TestAutoTriggerEvents:
|
||||
"""Tests for auto-trigger events (backslash, @, /)."""
|
||||
|
||||
def test_composer_has_backslash_typed_message(self):
|
||||
"""Test ComposerWidget has BackslashTyped message."""
|
||||
from praisonai.cli.features.tui.widgets.composer import ComposerWidget
|
||||
|
||||
assert hasattr(ComposerWidget, 'BackslashTyped')
|
||||
|
||||
def test_composer_has_at_typed_message(self):
|
||||
"""Test ComposerWidget has AtTyped message for @ file autocomplete."""
|
||||
from praisonai.cli.features.tui.widgets.composer import ComposerWidget
|
||||
|
||||
assert hasattr(ComposerWidget, 'AtTyped')
|
||||
|
||||
def test_composer_has_slash_typed_message(self):
|
||||
"""Test ComposerWidget has SlashTyped message for / commands."""
|
||||
from praisonai.cli.features.tui.widgets.composer import ComposerWidget
|
||||
|
||||
assert hasattr(ComposerWidget, 'SlashTyped')
|
||||
|
||||
def test_main_screen_has_auto_trigger_handlers(self):
|
||||
"""Test MainScreen has auto-trigger event handlers."""
|
||||
from praisonai.cli.features.tui.screens.main import MainScreen
|
||||
|
||||
assert hasattr(MainScreen, 'on_composer_widget_backslash_typed')
|
||||
assert hasattr(MainScreen, 'on_composer_widget_at_typed')
|
||||
assert hasattr(MainScreen, 'on_composer_widget_slash_typed')
|
||||
|
||||
|
||||
class TestFilePopup:
|
||||
"""Tests for FilePopupWidget."""
|
||||
|
||||
def test_file_popup_exists(self):
|
||||
"""Test FilePopupWidget can be imported."""
|
||||
from praisonai.cli.features.tui.widgets.file_popup import FilePopupWidget
|
||||
assert FilePopupWidget is not None
|
||||
|
||||
def test_file_popup_has_messages(self):
|
||||
"""Test FilePopupWidget has required message classes."""
|
||||
from praisonai.cli.features.tui.widgets.file_popup import FilePopupWidget
|
||||
|
||||
assert hasattr(FilePopupWidget, 'FileSelected')
|
||||
assert hasattr(FilePopupWidget, 'Dismissed')
|
||||
|
||||
def test_file_info_dataclass(self):
|
||||
"""Test FileInfo dataclass works correctly."""
|
||||
from praisonai.cli.features.tui.widgets.file_popup import FileInfo
|
||||
|
||||
file_info = FileInfo(path="test.py", file_type="file")
|
||||
assert file_info.path == "test.py"
|
||||
assert file_info.file_type == "file"
|
||||
assert file_info.icon == "📄 "
|
||||
|
||||
dir_info = FileInfo(path="src/", file_type="directory")
|
||||
assert dir_info.icon == "📁 "
|
||||
|
||||
def test_main_screen_has_file_popup_handlers(self):
|
||||
"""Test MainScreen has file popup event handlers."""
|
||||
from praisonai.cli.features.tui.screens.main import MainScreen
|
||||
|
||||
assert hasattr(MainScreen, 'on_file_popup_widget_file_selected')
|
||||
assert hasattr(MainScreen, 'on_file_popup_widget_dismissed')
|
||||
assert hasattr(MainScreen, '_show_file_popup')
|
||||
assert hasattr(MainScreen, '_dismiss_file_popup')
|
||||
|
||||
|
||||
class TestQueueLiveUpdates:
|
||||
"""Tests for queue panel live updates."""
|
||||
|
||||
def test_app_has_update_queue_panel_live(self):
|
||||
"""Test TUIApp has _update_queue_panel_live method."""
|
||||
from praisonai.cli.features.tui.app import TUIApp
|
||||
|
||||
assert hasattr(TUIApp, '_update_queue_panel_live')
|
||||
|
||||
def test_queue_panel_has_reactive_counts(self):
|
||||
"""Test QueuePanelWidget has reactive count properties."""
|
||||
from praisonai.cli.features.tui.widgets.queue_panel import QueuePanelWidget
|
||||
|
||||
# Check reactive properties exist
|
||||
assert hasattr(QueuePanelWidget, 'queued_count')
|
||||
assert hasattr(QueuePanelWidget, 'running_count')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in new issue
Block a user