Compare commits

...
11 Commits
50 changed files with 10285 additions and 127 deletions

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
name: Update API Reference
on:
push:
branches: [ "main" ]
paths:
- 'src/praisonai-agents/praisonaiagents/**/*.py'
- 'src/praisonai/praisonai/**/*.py'
- 'src/praisonai-ts/src/**/*.ts'
- '.github/workflows/update-api-md.yml'
permissions:
contents: write
jobs:
update-api-md:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: main
persist-credentials: true
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Generate api.md
run: |
cd $GITHUB_WORKSPACE
python3 src/praisonai/scripts/generate_api_md.py
- name: Check for changes
id: check_changes
run: |
if git diff --quiet api.md; then
echo "changes=false" >> $GITHUB_OUTPUT
echo "No changes to api.md"
else
echo "changes=true" >> $GITHUB_OUTPUT
echo "api.md has been updated"
git diff api.md | head -50
fi
- name: Commit and push changes
if: steps.check_changes.outputs.changes == 'true'
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "docs: auto-update api.md [skip ci]"
file_pattern: 'api.md'
commit_user_name: "github-actions[bot]"
commit_user_email: "41898282+github-actions[bot]@users.noreply.github.com"
commit_author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
+6 -1
View File
@@ -552,7 +552,7 @@ Methods:
Types:
```python
from praisonaiagents import AutoRagConfig, Citation, CodeExecutionStep, DeepResearchResponse, ExpandResult, ExpandStrategy, FileSearchCall, MCPCall, Provider, RagRetrievalPolicy, ReasoningStep, RewriteResult, RewriteStrategy, Task, WebSearchCall
from praisonaiagents import AutoRagConfig, Citation, CodeExecutionStep, DeepResearchResponse, ExpandResult, ExpandStrategy, FileSearchCall, MCPCall, Provider, RagRetrievalPolicy, ReasoningStep, RewriteResult, RewriteStrategy, Task, WebSearchCall, warmup
```
Methods:
@@ -569,6 +569,7 @@ Methods:
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">execute_callback_sync</a>(task_output: TaskOutput) -> None</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">initialize_memory</a>()</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">store_in_memory</a>(content: str, agent_name: str = None, task_id: str = None)</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/__init__.py">warmup</a>(include_litellm: bool = False, include_openai: bool = True) -> dict</code>
# Wrapper (praisonai)
@@ -731,6 +732,10 @@ Methods:
* <code title="cli">praisonai skills install <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills list <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills validate <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai standardise check <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai standardise fix <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai standardise init <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai standardise report <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai templates create <a href="./src/praisonai/praisonai/cli/commands/templates.py">--help</a></code>
* <code title="cli">praisonai templates list <a href="./src/praisonai/praisonai/cli/commands/templates.py">--help</a></code>
* <code title="cli">praisonai test info <a href="./src/praisonai/praisonai/cli/commands/test.py">--help</a></code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.7.2" \
"praisonai>=3.7.7" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.7.2" \
"praisonai>=3.7.7" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.7.2" \
"praisonai>=3.7.7" \
"praisonai[ui]" \
"praisonai[crewai]"
+63
View File
@@ -0,0 +1,63 @@
"""
Streaming Example with Profiling
Key Points:
1. output="stream" preset enables streaming by default
2. stream=True in start() is redundant when using output="stream" (but doesn't hurt)
3. Profiler.enable() MUST be called before profiling works
4. Use Profiler.streaming() context manager for TTFT metrics
"""
from praisonai.profiler import Profiler, profile, profile_imports
# IMPORTANT: Enable profiler BEFORE any profiling operations
Profiler.enable()
# Profile imports
with profile_imports():
from praisonaiagents import Agent
@profile
def main():
agent = Agent(
instructions="You are a helpful assistant",
output="stream" # This already enables streaming
)
print("\nStreaming response:")
print("-" * 40)
# Use Profiler.streaming() to track TTFT (Time To First Token)
with Profiler.streaming("llm_stream") as tracker:
first_chunk = True
for chunk in agent.start("Write a short paragraph about the history of computing"):
if first_chunk:
tracker.first_token() # Mark TTFT
first_chunk = False
tracker.chunk() # Count chunks
print(chunk, end="", flush=True)
print("\n" + "-" * 40)
# Run with profiling blocks
with Profiler.block("full_workflow"):
with Profiler.block("setup"):
pass
with Profiler.block("execution"):
result = main()
with Profiler.block("cleanup"):
pass
# Generate report
Profiler.report()
# Show streaming-specific stats
streaming_records = Profiler.get_streaming_records()
if streaming_records:
print("\nStreaming Metrics:")
for record in streaming_records:
print(f" {record.name}: TTFT={record.ttft_ms:.2f}ms, Total={record.total_ms:.2f}ms, Chunks={record.chunk_count}")
stats = Profiler.get_statistics()
print(f"\nMedian execution time: {stats['p50']:.2f}ms")
@@ -476,6 +476,62 @@ def _init_telemetry():
_init_telemetry()
def warmup(include_litellm: bool = False, include_openai: bool = True) -> dict:
"""
Pre-import heavy dependencies to reduce first-call latency.
NOTE: For default OpenAI usage (llm="gpt-4o-mini"), warmup is NOT needed.
The default path uses the native OpenAI SDK which is fast (~100ms import).
Warmup is only beneficial when using LiteLLM backend, which is triggered by:
- Using "/" in model name (e.g., llm="openai/gpt-4o-mini")
- Passing a dict config (e.g., llm={"model": "gpt-4o-mini"})
- Using base_url parameter
Args:
include_litellm: Pre-import LiteLLM (~2-3s). Only needed for multi-provider support.
include_openai: Pre-import OpenAI SDK (~100ms). Default path, usually fast.
Returns:
dict: Timing information for each component warmed up
Example:
# For LiteLLM multi-provider usage:
from praisonaiagents import warmup
warmup(include_litellm=True) # Pre-load LiteLLM
agent = Agent(llm="anthropic/claude-3-sonnet") # Now faster
# For default OpenAI usage, no warmup needed:
agent = Agent(llm="gpt-4o-mini") # Already fast!
"""
import time
timings = {}
if include_openai:
start = time.perf_counter()
try:
import openai
timings['openai'] = (time.perf_counter() - start) * 1000
except ImportError:
timings['openai'] = -1 # Not available
if include_litellm:
start = time.perf_counter()
try:
import litellm
# Also configure litellm to avoid first-call overhead
litellm.telemetry = False
litellm.set_verbose = False
litellm.drop_params = True
litellm.modify_params = True
timings['litellm'] = (time.perf_counter() - start) * 1000
except ImportError:
timings['litellm'] = -1 # Not available
return timings
__all__ = [
# Core classes (lazy loaded)
'Agent',
@@ -646,4 +702,6 @@ __all__ = [
# Parse Utilities
'is_policy_string',
'parse_policy_string',
# Performance utilities
'warmup',
]
@@ -1430,6 +1430,8 @@ Now provide your final answer using this result."""
task_id: Optional[str] = None,
execute_tool_fn: Optional[Callable] = None,
stream: bool = True,
stream_callback: Optional[Callable] = None,
emit_events: bool = False,
**kwargs
) -> str:
"""Enhanced get_response with all OpenAI-like features"""
@@ -1489,6 +1491,19 @@ Now provide your final answer using this result."""
# Disable litellm debug messages
litellm.set_verbose = False
# Setup StreamEvent emission if enabled
_emit = emit_events and stream_callback is not None
if _emit:
from ..streaming.events import StreamEvent, StreamEventType
_first_token_emitted = False
_last_content_time = None
_request_start_perf = time.perf_counter()
stream_callback(StreamEvent(
type=StreamEventType.REQUEST_START,
timestamp=_request_start_perf,
metadata={"model": self.model, "provider": "litellm"}
))
# Format tools if provided
formatted_tools = self._format_tools_for_litellm(tools)
@@ -582,6 +582,8 @@ class OpenAIClient:
console: Optional[Console] = None,
display_fn: Optional[Callable] = None,
reasoning_steps: bool = False,
stream_callback: Optional[Callable] = None,
emit_events: bool = False,
**kwargs
) -> Optional[ChatCompletion]:
"""
@@ -596,11 +598,18 @@ class OpenAIClient:
console: Console for output
display_fn: Display function for live updates
reasoning_steps: Whether to show reasoning steps
stream_callback: Optional callback for StreamEvent emission
emit_events: Whether to emit StreamEvents (requires stream_callback)
**kwargs: Additional parameters for the API
Returns:
ChatCompletion object or None if error
"""
# Lazy import StreamEvent types only when needed
_emit = emit_events and stream_callback is not None
if _emit:
from ..streaming.events import StreamEvent, StreamEventType
try:
# Default start time and console if not provided
if start_time is None:
@@ -608,6 +617,15 @@ class OpenAIClient:
if console is None:
console = self.console
# Emit REQUEST_START event
request_start_perf = time.perf_counter()
if _emit:
stream_callback(StreamEvent(
type=StreamEventType.REQUEST_START,
timestamp=request_start_perf,
metadata={"model": model, "message_count": len(messages)}
))
# Create the response stream
response_stream = self.sync_client.chat.completions.create(
model=model,
@@ -615,29 +633,81 @@ class OpenAIClient:
temperature=temperature,
tools=tools if tools else None,
stream=True,
stream_options={"include_usage": True} if _emit else None,
**kwargs
)
# Emit HEADERS_RECEIVED event (stream object created means headers received)
if _emit:
stream_callback(StreamEvent(
type=StreamEventType.HEADERS_RECEIVED,
timestamp=time.perf_counter()
))
full_response_text = ""
reasoning_content = ""
chunks = []
first_token_emitted = False
last_content_time = None
ttft_logged = False
# If display function provided, use Live display
if display_fn:
with Live(
display_fn("", start_time),
console=console,
refresh_per_second=4,
refresh_per_second=10, # Increased for more responsive streaming
transient=True,
vertical_overflow="ellipsis",
auto_refresh=True
) as live:
for chunk in response_stream:
chunks.append(chunk)
if chunk.choices[0].delta.content:
full_response_text += chunk.choices[0].delta.content
# Check for content delta
if chunk.choices and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
full_response_text += content
last_content_time = time.perf_counter()
# Log TTFT on first token
if not ttft_logged:
ttft_ms = (last_content_time - request_start_perf) * 1000
logging.debug(f"TTFT (Time To First Token): {ttft_ms:.0f}ms")
ttft_logged = True
# Emit FIRST_TOKEN on first content
if _emit and not first_token_emitted:
stream_callback(StreamEvent(
type=StreamEventType.FIRST_TOKEN,
timestamp=last_content_time,
content=content
))
first_token_emitted = True
elif _emit:
# Emit DELTA_TEXT for subsequent tokens
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=last_content_time,
content=content
))
live.update(display_fn(full_response_text, start_time))
# Handle tool calls streaming
if _emit and chunk.choices and hasattr(chunk.choices[0].delta, 'tool_calls') and chunk.choices[0].delta.tool_calls:
for tc in chunk.choices[0].delta.tool_calls:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TOOL_CALL,
timestamp=time.perf_counter(),
tool_call={
"index": tc.index,
"id": getattr(tc, 'id', None),
"name": getattr(tc.function, 'name', None) if tc.function else None,
"arguments": getattr(tc.function, 'arguments', None) if tc.function else None
}
))
# Update live display with reasoning content if enabled
if reasoning_steps and hasattr(chunk.choices[0].delta, "reasoning_content"):
rc = chunk.choices[0].delta.reasoning_content
@@ -648,9 +718,60 @@ class OpenAIClient:
# Clear the last generating display with a blank line
console.print()
else:
# Just collect chunks without display
# Just collect chunks without display, but still emit events
for chunk in response_stream:
chunks.append(chunk)
if chunk.choices and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
last_content_time = time.perf_counter()
# Log TTFT on first token
if not ttft_logged:
ttft_ms = (last_content_time - request_start_perf) * 1000
logging.debug(f"TTFT (Time To First Token): {ttft_ms:.0f}ms")
ttft_logged = True
if _emit and not first_token_emitted:
stream_callback(StreamEvent(
type=StreamEventType.FIRST_TOKEN,
timestamp=last_content_time,
content=content
))
first_token_emitted = True
else:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=last_content_time,
content=content
))
# Handle tool calls streaming
if _emit and chunk.choices and hasattr(chunk.choices[0].delta, 'tool_calls') and chunk.choices[0].delta.tool_calls:
for tc in chunk.choices[0].delta.tool_calls:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TOOL_CALL,
timestamp=time.perf_counter(),
tool_call={
"index": tc.index,
"id": getattr(tc, 'id', None),
"name": getattr(tc.function, 'name', None) if tc.function else None,
"arguments": getattr(tc.function, 'arguments', None) if tc.function else None
}
))
# Emit LAST_TOKEN and STREAM_END events
if _emit:
if last_content_time:
stream_callback(StreamEvent(
type=StreamEventType.LAST_TOKEN,
timestamp=last_content_time
))
stream_callback(StreamEvent(
type=StreamEventType.STREAM_END,
timestamp=time.perf_counter(),
metadata={"chunk_count": len(chunks)}
))
final_response = process_stream_chunks(chunks)
return final_response
@@ -669,6 +790,8 @@ class OpenAIClient:
console: Optional[Console] = None,
display_fn: Optional[Callable] = None,
reasoning_steps: bool = False,
stream_callback: Optional[Callable] = None,
emit_events: bool = False,
**kwargs
) -> Optional[ChatCompletion]:
"""
@@ -683,11 +806,18 @@ class OpenAIClient:
console: Console for output
display_fn: Display function for live updates
reasoning_steps: Whether to show reasoning steps
stream_callback: Optional callback for StreamEvent emission (can be sync or async)
emit_events: Whether to emit StreamEvents (requires stream_callback)
**kwargs: Additional parameters for the API
Returns:
ChatCompletion object or None if error
"""
# Lazy import StreamEvent types only when needed
_emit = emit_events and stream_callback is not None
if _emit:
from ..streaming.events import StreamEvent, StreamEventType
try:
# Default start time and console if not provided
if start_time is None:
@@ -695,6 +825,19 @@ class OpenAIClient:
if console is None:
console = self.console
# Emit REQUEST_START event
request_start_perf = time.perf_counter()
if _emit:
event = StreamEvent(
type=StreamEventType.REQUEST_START,
timestamp=request_start_perf,
metadata={"model": model, "message_count": len(messages)}
)
if asyncio.iscoroutinefunction(stream_callback):
await stream_callback(event)
else:
stream_callback(event)
# Create the response stream
response_stream = await self.async_client.chat.completions.create(
model=model,
@@ -702,29 +845,81 @@ class OpenAIClient:
temperature=temperature,
tools=tools if tools else None,
stream=True,
stream_options={"include_usage": True} if _emit else None,
**kwargs
)
# Emit HEADERS_RECEIVED event
if _emit:
event = StreamEvent(
type=StreamEventType.HEADERS_RECEIVED,
timestamp=time.perf_counter()
)
if asyncio.iscoroutinefunction(stream_callback):
await stream_callback(event)
else:
stream_callback(event)
full_response_text = ""
reasoning_content = ""
chunks = []
first_token_emitted = False
last_content_time = None
async def emit_event(event: 'StreamEvent'):
if asyncio.iscoroutinefunction(stream_callback):
await stream_callback(event)
else:
stream_callback(event)
# If display function provided, use Live display
if display_fn:
with Live(
display_fn("", start_time),
console=console,
refresh_per_second=4,
refresh_per_second=10,
transient=True,
vertical_overflow="ellipsis",
auto_refresh=True
) as live:
async for chunk in response_stream:
chunks.append(chunk)
if chunk.choices[0].delta.content:
full_response_text += chunk.choices[0].delta.content
if chunk.choices and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
full_response_text += content
last_content_time = time.perf_counter()
if _emit and not first_token_emitted:
await emit_event(StreamEvent(
type=StreamEventType.FIRST_TOKEN,
timestamp=last_content_time,
content=content
))
first_token_emitted = True
elif _emit:
await emit_event(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=last_content_time,
content=content
))
live.update(display_fn(full_response_text, start_time))
# Handle tool calls streaming
if _emit and chunk.choices and hasattr(chunk.choices[0].delta, 'tool_calls') and chunk.choices[0].delta.tool_calls:
for tc in chunk.choices[0].delta.tool_calls:
await emit_event(StreamEvent(
type=StreamEventType.DELTA_TOOL_CALL,
timestamp=time.perf_counter(),
tool_call={
"index": tc.index,
"id": getattr(tc, 'id', None),
"name": getattr(tc.function, 'name', None) if tc.function else None,
"arguments": getattr(tc.function, 'arguments', None) if tc.function else None
}
))
# Update live display with reasoning content if enabled
if reasoning_steps and hasattr(chunk.choices[0].delta, "reasoning_content"):
rc = chunk.choices[0].delta.reasoning_content
@@ -738,6 +933,50 @@ class OpenAIClient:
# Just collect chunks without display
async for chunk in response_stream:
chunks.append(chunk)
if _emit and chunk.choices and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
last_content_time = time.perf_counter()
if not first_token_emitted:
await emit_event(StreamEvent(
type=StreamEventType.FIRST_TOKEN,
timestamp=last_content_time,
content=content
))
first_token_emitted = True
else:
await emit_event(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=last_content_time,
content=content
))
if _emit and chunk.choices and hasattr(chunk.choices[0].delta, 'tool_calls') and chunk.choices[0].delta.tool_calls:
for tc in chunk.choices[0].delta.tool_calls:
await emit_event(StreamEvent(
type=StreamEventType.DELTA_TOOL_CALL,
timestamp=time.perf_counter(),
tool_call={
"index": tc.index,
"id": getattr(tc, 'id', None),
"name": getattr(tc.function, 'name', None) if tc.function else None,
"arguments": getattr(tc.function, 'arguments', None) if tc.function else None
}
))
# Emit LAST_TOKEN and STREAM_END events
if _emit:
if last_content_time:
await emit_event(StreamEvent(
type=StreamEventType.LAST_TOKEN,
timestamp=last_content_time
))
await emit_event(StreamEvent(
type=StreamEventType.STREAM_END,
timestamp=time.perf_counter(),
metadata={"chunk_count": len(chunks)}
))
final_response = process_stream_chunks(chunks)
return final_response
@@ -904,7 +1143,9 @@ class OpenAIClient:
if display_fn and console:
# When verbose (display_fn provided), use streaming for better UX
try:
with Live(display_fn("", start_time), console=console, refresh_per_second=4, transient=True) as live:
request_start_perf = time.perf_counter()
ttft_logged = False
with Live(display_fn("", start_time), console=console, refresh_per_second=10, transient=True) as live:
# Use streaming when display_fn is provided for progressive display
response_stream = self.create_completion(
messages=messages,
@@ -922,6 +1163,11 @@ class OpenAIClient:
for chunk in response_stream:
chunks.append(chunk)
if chunk.choices[0].delta.content:
# Log TTFT on first token
if not ttft_logged:
ttft_ms = (time.perf_counter() - request_start_perf) * 1000
logging.debug(f"TTFT (Time To First Token): {ttft_ms:.0f}ms")
ttft_logged = True
full_response_text += chunk.choices[0].delta.content
live.update(display_fn(full_response_text, start_time))
@@ -0,0 +1,479 @@
"""
PraisonAI Agents Core Profiling Module
Lightweight, protocol-based profiling for the core SDK.
Zero overhead when disabled. Opt-in only.
Features:
- Pre-LLM latency breakdown
- LLM client initialization timing
- Tool resolution timing
- Message serialization timing
- Streaming TTFT tracking
- Import timing
- Async-safe
Usage:
from praisonaiagents.profiling import Profiler, profile_block
# Enable profiling
Profiler.enable()
# Profile a block
with profile_block("my_operation"):
do_something()
# Get report
Profiler.report()
"""
import time
import os
import threading
from dataclasses import dataclass, field, asdict
from typing import Dict, List, Optional, Any, Callable
from contextlib import contextmanager
import functools
import json
# ============================================================================
# Data Classes
# ============================================================================
@dataclass
class TimingRecord:
"""Record of a single timing measurement."""
name: str
duration_ms: float
category: str = "block"
parent: str = ""
timestamp: float = field(default_factory=time.time)
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class StreamingRecord:
"""Record of streaming operation."""
name: str
ttft_ms: float # Time to first token
total_ms: float
chunk_count: int = 0
timestamp: float = field(default_factory=time.time)
@dataclass
class PreLLMBreakdown:
"""Breakdown of pre-LLM latency phases."""
agent_init_ms: float = 0.0
llm_client_init_ms: float = 0.0
tool_resolution_ms: float = 0.0
message_build_ms: float = 0.0
context_management_ms: float = 0.0
knowledge_retrieval_ms: float = 0.0
guardrail_check_ms: float = 0.0
total_pre_llm_ms: float = 0.0
def to_dict(self) -> Dict[str, float]:
return asdict(self)
# ============================================================================
# Streaming Tracker
# ============================================================================
class StreamingTracker:
"""Track streaming operations for TTFT metrics."""
def __init__(self, name: str):
self.name = name
self._start_time: Optional[float] = None
self._first_token_time: Optional[float] = None
self._end_time: Optional[float] = None
self._chunk_count: int = 0
def start(self) -> None:
self._start_time = time.perf_counter()
def first_token(self) -> None:
if self._first_token_time is None:
self._first_token_time = time.perf_counter()
def chunk(self) -> None:
self._chunk_count += 1
def end(self) -> None:
self._end_time = time.perf_counter()
if self._start_time is not None:
ttft_ms = 0.0
if self._first_token_time is not None:
ttft_ms = (self._first_token_time - self._start_time) * 1000
total_ms = (self._end_time - self._start_time) * 1000
Profiler.record_streaming(
name=self.name,
ttft_ms=ttft_ms,
total_ms=total_ms,
chunk_count=self._chunk_count
)
@property
def ttft_ms(self) -> float:
if self._start_time and self._first_token_time:
return (self._first_token_time - self._start_time) * 1000
return 0.0
@property
def elapsed_ms(self) -> float:
if self._start_time:
end = self._end_time or time.perf_counter()
return (end - self._start_time) * 1000
return 0.0
# ============================================================================
# Core Profiler
# ============================================================================
class Profiler:
"""
Lightweight profiler for PraisonAI Agents core SDK.
Thread-safe singleton with zero overhead when disabled.
"""
_lock = threading.Lock()
_enabled: bool = False
_timings: List[TimingRecord] = []
_streaming: List[StreamingRecord] = []
_pre_llm_breakdown: Optional[PreLLMBreakdown] = None
_current_phases: Dict[str, float] = {} # Track in-progress phases
_phase_stack: List[str] = [] # Track nested phases
@classmethod
def enable(cls) -> None:
"""Enable profiling."""
cls._enabled = True
@classmethod
def disable(cls) -> None:
"""Disable profiling."""
cls._enabled = False
@classmethod
def is_enabled(cls) -> bool:
"""Check if profiling is enabled."""
return cls._enabled or os.environ.get('PRAISONAI_PROFILE', '').lower() in ('1', 'true', 'yes')
@classmethod
def clear(cls) -> None:
"""Clear all profiling data."""
with cls._lock:
cls._timings.clear()
cls._streaming.clear()
cls._pre_llm_breakdown = None
cls._current_phases.clear()
cls._phase_stack.clear()
@classmethod
def record_timing(cls, name: str, duration_ms: float, category: str = "block",
parent: str = "", metadata: Optional[Dict[str, Any]] = None) -> None:
"""Record a timing measurement."""
if not cls.is_enabled():
return
with cls._lock:
cls._timings.append(TimingRecord(
name=name,
duration_ms=duration_ms,
category=category,
parent=parent,
metadata=metadata or {}
))
@classmethod
def record_streaming(cls, name: str, ttft_ms: float, total_ms: float,
chunk_count: int = 0) -> None:
"""Record streaming metrics."""
if not cls.is_enabled():
return
with cls._lock:
cls._streaming.append(StreamingRecord(
name=name,
ttft_ms=ttft_ms,
total_ms=total_ms,
chunk_count=chunk_count
))
@classmethod
def start_phase(cls, phase_name: str) -> None:
"""Start timing a phase (for pre-LLM breakdown)."""
if not cls.is_enabled():
return
with cls._lock:
cls._current_phases[phase_name] = time.perf_counter()
cls._phase_stack.append(phase_name)
@classmethod
def end_phase(cls, phase_name: str) -> float:
"""End timing a phase and return duration in ms."""
if not cls.is_enabled():
return 0.0
with cls._lock:
start = cls._current_phases.pop(phase_name, None)
if phase_name in cls._phase_stack:
cls._phase_stack.remove(phase_name)
if start is None:
return 0.0
duration_ms = (time.perf_counter() - start) * 1000
# Record as timing
parent = cls._phase_stack[-1] if cls._phase_stack else ""
cls._timings.append(TimingRecord(
name=phase_name,
duration_ms=duration_ms,
category="phase",
parent=parent
))
return duration_ms
@classmethod
@contextmanager
def phase(cls, name: str, category: str = "phase"):
"""Context manager for timing a phase."""
if not cls.is_enabled():
yield
return
start = time.perf_counter()
parent = ""
with cls._lock:
parent = cls._phase_stack[-1] if cls._phase_stack else ""
cls._phase_stack.append(name)
try:
yield
finally:
duration_ms = (time.perf_counter() - start) * 1000
with cls._lock:
if name in cls._phase_stack:
cls._phase_stack.remove(name)
cls._timings.append(TimingRecord(
name=name,
duration_ms=duration_ms,
category=category,
parent=parent
))
@classmethod
@contextmanager
def streaming(cls, name: str):
"""Context manager for streaming profiling."""
tracker = StreamingTracker(name)
tracker.start()
try:
yield tracker
finally:
tracker.end()
@classmethod
def set_pre_llm_breakdown(cls, breakdown: PreLLMBreakdown) -> None:
"""Set the pre-LLM breakdown data."""
if not cls.is_enabled():
return
with cls._lock:
cls._pre_llm_breakdown = breakdown
@classmethod
def get_timings(cls, category: Optional[str] = None) -> List[TimingRecord]:
"""Get timing records."""
with cls._lock:
if category:
return [t for t in cls._timings if t.category == category]
return cls._timings.copy()
@classmethod
def get_streaming_records(cls) -> List[StreamingRecord]:
"""Get streaming records."""
with cls._lock:
return cls._streaming.copy()
@classmethod
def get_pre_llm_breakdown(cls) -> Optional[PreLLMBreakdown]:
"""Get pre-LLM breakdown."""
with cls._lock:
return cls._pre_llm_breakdown
@classmethod
def get_summary(cls) -> Dict[str, Any]:
"""Get profiling summary."""
with cls._lock:
total_time = sum(t.duration_ms for t in cls._timings)
# Group by category
by_category: Dict[str, float] = {}
for t in cls._timings:
by_category[t.category] = by_category.get(t.category, 0) + t.duration_ms
# Top slowest
slowest = sorted(cls._timings, key=lambda x: x.duration_ms, reverse=True)[:15]
# Pre-LLM breakdown
pre_llm = cls._pre_llm_breakdown.to_dict() if cls._pre_llm_breakdown else {}
# Streaming stats
streaming_stats = []
for s in cls._streaming:
streaming_stats.append({
'name': s.name,
'ttft_ms': s.ttft_ms,
'total_ms': s.total_ms,
'chunk_count': s.chunk_count
})
return {
'total_time_ms': total_time,
'timing_count': len(cls._timings),
'by_category': by_category,
'slowest_operations': [(s.name, s.duration_ms, s.category) for s in slowest],
'pre_llm_breakdown': pre_llm,
'streaming': streaming_stats
}
@classmethod
def report(cls, output: str = "console") -> str:
"""Generate and output profiling report."""
summary = cls.get_summary()
lines = [
"=" * 70,
"PraisonAI Agents Core Profiling Report",
"=" * 70,
"",
f"Total Time: {summary['total_time_ms']:.2f}ms",
f"Timing Records: {summary['timing_count']}",
"",
]
# Pre-LLM Breakdown (key for debugging the 7s delay)
if summary['pre_llm_breakdown']:
lines.append("## Pre-LLM Latency Breakdown")
lines.append("-" * 50)
for phase, ms in summary['pre_llm_breakdown'].items():
if ms > 0:
lines.append(f" {phase:<30}: {ms:>10.2f} ms")
lines.append("")
# By Category
lines.append("## By Category")
lines.append("-" * 50)
for cat, time_ms in summary['by_category'].items():
lines.append(f" {cat}: {time_ms:.2f}ms")
lines.append("")
# Slowest Operations
lines.append("## Slowest Operations")
lines.append("-" * 50)
for name, time_ms, cat in summary['slowest_operations']:
lines.append(f" {name:<40}: {time_ms:>10.2f}ms [{cat}]")
lines.append("")
# Streaming Stats
if summary['streaming']:
lines.append("## Streaming Metrics")
lines.append("-" * 50)
for s in summary['streaming']:
lines.append(f" {s['name']}: TTFT={s['ttft_ms']:.2f}ms, Total={s['total_ms']:.2f}ms, Chunks={s['chunk_count']}")
lines.append("")
lines.append("=" * 70)
report_text = "\n".join(lines)
if output == "console":
print(report_text)
return report_text
@classmethod
def export_json(cls) -> str:
"""Export profiling data as JSON."""
summary = cls.get_summary()
with cls._lock:
data = {
'summary': summary,
'timings': [asdict(t) for t in cls._timings],
'streaming': [asdict(s) for s in cls._streaming],
}
return json.dumps(data, indent=2, default=str)
# ============================================================================
# Decorators
# ============================================================================
def profile_func(func: Optional[Callable] = None, *, category: str = "function"):
"""Decorator to profile a function."""
def decorator(fn: Callable) -> Callable:
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if not Profiler.is_enabled():
return fn(*args, **kwargs)
start = time.perf_counter()
try:
return fn(*args, **kwargs)
finally:
duration_ms = (time.perf_counter() - start) * 1000
Profiler.record_timing(fn.__name__, duration_ms, category)
return wrapper
if func is not None:
return decorator(func)
return decorator
def profile_async(func: Optional[Callable] = None, *, category: str = "async"):
"""Decorator to profile an async function."""
def decorator(fn: Callable) -> Callable:
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
if not Profiler.is_enabled():
return await fn(*args, **kwargs)
start = time.perf_counter()
try:
return await fn(*args, **kwargs)
finally:
duration_ms = (time.perf_counter() - start) * 1000
Profiler.record_timing(fn.__name__, duration_ms, category)
return wrapper
if func is not None:
return decorator(func)
return decorator
@contextmanager
def profile_block(name: str, category: str = "block"):
"""Context manager for profiling a block of code."""
if not Profiler.is_enabled():
yield
return
start = time.perf_counter()
try:
yield
finally:
duration_ms = (time.perf_counter() - start) * 1000
Profiler.record_timing(name, duration_ms, category)
# ============================================================================
# Exports
# ============================================================================
__all__ = [
'Profiler',
'StreamingTracker',
'TimingRecord',
'StreamingRecord',
'PreLLMBreakdown',
'profile_func',
'profile_async',
'profile_block',
]
@@ -0,0 +1,75 @@
"""
PraisonAI Streaming Module.
Provides the StreamEvent protocol for pass-through streaming from LLM providers.
This module is lazily loaded to ensure zero performance impact when streaming
features are not used.
Usage:
from praisonaiagents.streaming import (
StreamEvent,
StreamEventType,
StreamMetrics,
StreamEventEmitter,
create_text_printer_callback,
)
"""
__all__ = [
"StreamEvent",
"StreamEventType",
"StreamMetrics",
"StreamEventEmitter",
"StreamCallback",
"AsyncStreamCallback",
"create_text_printer_callback",
"create_metrics_callback",
"StreamLogger",
"create_logging_callback",
]
def __getattr__(name: str):
"""Lazy loading of streaming components."""
if name in __all__:
# Events module exports
if name in (
"StreamEvent", "StreamEventType", "StreamMetrics",
"StreamEventEmitter", "StreamCallback", "AsyncStreamCallback",
"create_text_printer_callback", "create_metrics_callback"
):
from .events import (
StreamEvent,
StreamEventType,
StreamMetrics,
StreamEventEmitter,
StreamCallback,
AsyncStreamCallback,
create_text_printer_callback,
create_metrics_callback,
)
_events_exports = {
"StreamEvent": StreamEvent,
"StreamEventType": StreamEventType,
"StreamMetrics": StreamMetrics,
"StreamEventEmitter": StreamEventEmitter,
"StreamCallback": StreamCallback,
"AsyncStreamCallback": AsyncStreamCallback,
"create_text_printer_callback": create_text_printer_callback,
"create_metrics_callback": create_metrics_callback,
}
return _events_exports.get(name)
# Logging module exports
if name in ("StreamLogger", "create_logging_callback"):
from .logging import StreamLogger, create_logging_callback
_logging_exports = {
"StreamLogger": StreamLogger,
"create_logging_callback": create_logging_callback,
}
return _logging_exports.get(name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,300 @@
"""
StreamEvent protocol for PraisonAI streaming.
This module defines the minimal streaming event protocol that both OpenAI SDK
and LiteLLM streaming paths map their native chunks/events into.
Design principles:
- Zero overhead when not used (lazy imports, no global state)
- Multi-agent safe (per-agent callbacks, no shared mutable state)
- Async-safe (separate sync/async callback interfaces)
- Provider-agnostic (same events for OpenAI, LiteLLM, etc.)
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Protocol
import time
class StreamEventType(Enum):
"""Types of streaming events emitted during LLM response streaming."""
REQUEST_START = "request_start" # Before API call is made
HEADERS_RECEIVED = "headers_received" # When HTTP 200 headers arrive
FIRST_TOKEN = "first_token" # First content delta received (TTFT marker)
DELTA_TEXT = "delta_text" # Text content delta
DELTA_TOOL_CALL = "delta_tool_call" # Tool call delta (function name, args chunk)
TOOL_CALL_END = "tool_call_end" # Tool call complete
LAST_TOKEN = "last_token" # Final content delta
STREAM_END = "stream_end" # Stream completed successfully
ERROR = "error" # Error during streaming
@dataclass
class StreamEvent:
"""
A single streaming event emitted during LLM response streaming.
Attributes:
type: The type of event (see StreamEventType)
timestamp: High-precision timestamp (time.perf_counter())
content: Text content for DELTA_TEXT events
tool_call: Tool call data for DELTA_TOOL_CALL events
metadata: Additional event-specific metadata
error: Error message for ERROR events
"""
type: StreamEventType
timestamp: float = field(default_factory=time.perf_counter)
content: Optional[str] = None
tool_call: Optional[Dict[str, Any]] = None
metadata: Optional[Dict[str, Any]] = None
error: Optional[str] = None
def __repr__(self) -> str:
parts = [f"StreamEvent({self.type.value}"]
if self.content:
preview = self.content[:20] + "..." if len(self.content) > 20 else self.content
parts.append(f", content={preview!r}")
if self.tool_call:
parts.append(f", tool_call={self.tool_call.get('name', 'unknown')}")
if self.error:
parts.append(f", error={self.error!r}")
parts.append(")")
return "".join(parts)
class StreamCallback(Protocol):
"""Protocol for synchronous stream event callbacks."""
def __call__(self, event: StreamEvent) -> None:
"""Called when a stream event is emitted."""
...
class AsyncStreamCallback(Protocol):
"""Protocol for asynchronous stream event callbacks."""
async def __call__(self, event: StreamEvent) -> None:
"""Called when a stream event is emitted (async)."""
...
@dataclass
class StreamMetrics:
"""
Timing metrics for a streaming response.
Tracks key timestamps to measure:
- TTFT (Time To First Token): How long until first token arrives
- Stream Duration: How long the token streaming takes
- Total Time: End-to-end request time
All times are in seconds, measured with time.perf_counter() for precision.
"""
request_start: float = 0.0
headers_received: float = 0.0
first_token: float = 0.0
last_token: float = 0.0
stream_end: float = 0.0
token_count: int = 0
@property
def ttft(self) -> float:
"""Time To First Token in seconds (first_token - request_start)."""
if self.first_token and self.request_start:
return self.first_token - self.request_start
return 0.0
@property
def stream_duration(self) -> float:
"""Duration of token streaming in seconds (last_token - first_token)."""
if self.last_token and self.first_token:
return self.last_token - self.first_token
return 0.0
@property
def total_time(self) -> float:
"""Total request time in seconds (stream_end - request_start)."""
if self.stream_end and self.request_start:
return self.stream_end - self.request_start
return 0.0
@property
def tokens_per_second(self) -> float:
"""Token generation rate (tokens / stream_duration)."""
if self.stream_duration > 0 and self.token_count > 0:
return self.token_count / self.stream_duration
return 0.0
def update_from_event(self, event: StreamEvent) -> None:
"""Update metrics from a StreamEvent."""
if event.type == StreamEventType.REQUEST_START:
self.request_start = event.timestamp
elif event.type == StreamEventType.HEADERS_RECEIVED:
self.headers_received = event.timestamp
elif event.type == StreamEventType.FIRST_TOKEN:
self.first_token = event.timestamp
self.token_count = 1
elif event.type == StreamEventType.DELTA_TEXT:
self.token_count += 1
elif event.type == StreamEventType.LAST_TOKEN:
self.last_token = event.timestamp
elif event.type == StreamEventType.STREAM_END:
self.stream_end = event.timestamp
def to_dict(self) -> Dict[str, Any]:
"""Convert metrics to dictionary for logging/display."""
return {
"ttft_ms": round(self.ttft * 1000, 1),
"stream_duration_ms": round(self.stream_duration * 1000, 1),
"total_time_ms": round(self.total_time * 1000, 1),
"token_count": self.token_count,
"tokens_per_second": round(self.tokens_per_second, 1),
}
def format_summary(self) -> str:
"""Format metrics as a human-readable summary string."""
return (
f"TTFT: {self.ttft*1000:.0f}ms | "
f"Stream: {self.stream_duration*1000:.0f}ms | "
f"Total: {self.total_time*1000:.0f}ms | "
f"Tokens: {self.token_count} ({self.tokens_per_second:.1f}/s)"
)
class StreamEventEmitter:
"""
Manages stream event callbacks for an agent or streaming session.
Thread-safe and multi-agent safe - each agent has its own emitter instance.
Supports both sync and async callbacks.
Usage:
emitter = StreamEventEmitter()
emitter.add_callback(my_callback)
# During streaming:
emitter.emit(StreamEvent(type=StreamEventType.DELTA_TEXT, content="Hello"))
"""
def __init__(self) -> None:
self._callbacks: List[StreamCallback] = []
self._async_callbacks: List[AsyncStreamCallback] = []
self._metrics: Optional[StreamMetrics] = None
self._collect_metrics: bool = False
def add_callback(self, callback: StreamCallback) -> None:
"""Add a synchronous callback to receive stream events."""
self._callbacks.append(callback)
def remove_callback(self, callback: StreamCallback) -> None:
"""Remove a synchronous callback."""
if callback in self._callbacks:
self._callbacks.remove(callback)
def add_async_callback(self, callback: AsyncStreamCallback) -> None:
"""Add an asynchronous callback to receive stream events."""
self._async_callbacks.append(callback)
def remove_async_callback(self, callback: AsyncStreamCallback) -> None:
"""Remove an asynchronous callback."""
if callback in self._async_callbacks:
self._async_callbacks.remove(callback)
def enable_metrics(self) -> None:
"""Enable metrics collection for this streaming session."""
self._collect_metrics = True
self._metrics = StreamMetrics()
def get_metrics(self) -> Optional[StreamMetrics]:
"""Get collected metrics (None if metrics not enabled)."""
return self._metrics
def reset_metrics(self) -> None:
"""Reset metrics for a new streaming session."""
if self._collect_metrics:
self._metrics = StreamMetrics()
def emit(self, event: StreamEvent) -> None:
"""
Emit a stream event to all registered callbacks.
This is the synchronous emission path. For async streaming,
use emit_async().
"""
# Update metrics if enabled
if self._collect_metrics and self._metrics:
self._metrics.update_from_event(event)
# Call all sync callbacks
for callback in self._callbacks:
try:
callback(event)
except Exception:
# Don't let callback errors break streaming
pass
async def emit_async(self, event: StreamEvent) -> None:
"""
Emit a stream event to all registered callbacks (async version).
Calls both sync and async callbacks.
"""
# Update metrics if enabled
if self._collect_metrics and self._metrics:
self._metrics.update_from_event(event)
# Call sync callbacks
for callback in self._callbacks:
try:
callback(event)
except Exception:
pass
# Call async callbacks
for callback in self._async_callbacks:
try:
await callback(event)
except Exception:
pass
@property
def has_callbacks(self) -> bool:
"""Check if any callbacks are registered."""
return bool(self._callbacks or self._async_callbacks)
def create_text_printer_callback(flush: bool = True) -> StreamCallback:
"""
Create a simple callback that prints text deltas to stdout.
Args:
flush: Whether to flush stdout after each print (default True)
Returns:
A StreamCallback that prints DELTA_TEXT content
"""
def printer(event: StreamEvent) -> None:
if event.type == StreamEventType.DELTA_TEXT and event.content:
print(event.content, end="", flush=flush)
elif event.type == StreamEventType.STREAM_END:
print() # Newline at end
return printer
def create_metrics_callback(metrics: StreamMetrics) -> StreamCallback:
"""
Create a callback that updates a StreamMetrics instance.
Args:
metrics: The StreamMetrics instance to update
Returns:
A StreamCallback that updates the metrics
"""
def updater(event: StreamEvent) -> None:
metrics.update_from_event(event)
return updater
@@ -0,0 +1,207 @@
"""
Streaming logging utilities for PraisonAI.
Provides guarded logging for streaming events with clear timestamps
for request_start, headers_received, first_token, last_token, and stream_end.
Usage:
from praisonaiagents.streaming.logging import StreamLogger
logger = StreamLogger(verbose=True, metrics=True)
logger.log_request_start(model="gpt-4o-mini")
# ... streaming ...
logger.log_first_token()
# ... more tokens ...
logger.log_stream_end()
print(logger.get_metrics_summary())
"""
import logging
import time
from typing import Optional, Dict, Any
from .events import StreamMetrics, StreamEvent, StreamEventType
class StreamLogger:
"""
Guarded logging for streaming events with timing metrics.
Only logs when verbose=True or metrics=True to ensure zero overhead
when logging is disabled.
Attributes:
verbose: Whether to log detailed streaming events
metrics: Whether to collect and display timing metrics
"""
def __init__(
self,
verbose: bool = False,
metrics: bool = False,
logger: Optional[logging.Logger] = None
):
self.verbose = verbose
self.metrics = metrics
self._logger = logger or logging.getLogger(__name__)
self._metrics = StreamMetrics() if metrics else None
self._enabled = verbose or metrics
def log_request_start(
self,
model: Optional[str] = None,
message_count: Optional[int] = None
) -> None:
"""Log when the streaming request is initiated."""
if not self._enabled:
return
timestamp = time.perf_counter()
if self._metrics:
self._metrics.request_start = timestamp
if self.verbose:
extra = f" model={model}" if model else ""
extra += f" messages={message_count}" if message_count else ""
self._logger.info(f"[STREAM] REQUEST_START{extra}")
def log_headers_received(self) -> None:
"""Log when HTTP response headers are received."""
if not self._enabled:
return
timestamp = time.perf_counter()
if self._metrics:
self._metrics.headers_received = timestamp
if self.verbose:
self._logger.info("[STREAM] HEADERS_RECEIVED")
def log_first_token(self, content: Optional[str] = None) -> None:
"""Log when the first content token is received (TTFT marker)."""
if not self._enabled:
return
timestamp = time.perf_counter()
if self._metrics:
self._metrics.first_token = timestamp
self._metrics.token_count = 1
if self.verbose:
preview = content[:20] + "..." if content and len(content) > 20 else content
self._logger.info(f"[STREAM] FIRST_TOKEN content={preview!r}")
def log_delta_text(self, content: Optional[str] = None) -> None:
"""Log a text delta (only when verbose, not for every token)."""
if not self._enabled:
return
if self._metrics:
self._metrics.token_count += 1
# Don't log every delta to avoid noise - just track metrics
def log_last_token(self) -> None:
"""Log when the last content token is received."""
if not self._enabled:
return
timestamp = time.perf_counter()
if self._metrics:
self._metrics.last_token = timestamp
if self.verbose:
self._logger.info("[STREAM] LAST_TOKEN")
def log_stream_end(self, chunk_count: Optional[int] = None) -> None:
"""Log when the stream ends."""
if not self._enabled:
return
timestamp = time.perf_counter()
if self._metrics:
self._metrics.stream_end = timestamp
if self.verbose:
extra = f" chunks={chunk_count}" if chunk_count else ""
self._logger.info(f"[STREAM] STREAM_END{extra}")
def log_error(self, error: str) -> None:
"""Log a streaming error."""
if self.verbose:
self._logger.error(f"[STREAM] ERROR: {error}")
def get_metrics(self) -> Optional[StreamMetrics]:
"""Get the collected metrics (None if metrics not enabled)."""
return self._metrics
def get_metrics_summary(self) -> str:
"""Get a formatted summary of streaming metrics."""
if not self._metrics:
return "Metrics not enabled"
return self._metrics.format_summary()
def get_metrics_dict(self) -> Optional[Dict[str, Any]]:
"""Get metrics as a dictionary."""
if not self._metrics:
return None
return self._metrics.to_dict()
def update_from_event(self, event: StreamEvent) -> None:
"""Update logger state from a StreamEvent."""
if not self._enabled:
return
if self._metrics:
self._metrics.update_from_event(event)
if self.verbose:
if event.type == StreamEventType.REQUEST_START:
self.log_request_start()
elif event.type == StreamEventType.HEADERS_RECEIVED:
self.log_headers_received()
elif event.type == StreamEventType.FIRST_TOKEN:
self.log_first_token(event.content)
elif event.type == StreamEventType.LAST_TOKEN:
self.log_last_token()
elif event.type == StreamEventType.STREAM_END:
self.log_stream_end()
elif event.type == StreamEventType.ERROR:
self.log_error(event.error or "Unknown error")
def create_logging_callback(
verbose: bool = False,
metrics: bool = False,
logger: Optional[logging.Logger] = None
) -> tuple:
"""
Create a StreamLogger and a callback function for use with streaming.
Args:
verbose: Whether to log detailed events
metrics: Whether to collect timing metrics
logger: Optional custom logger
Returns:
Tuple of (StreamLogger, callback_function)
Usage:
stream_logger, callback = create_logging_callback(verbose=True, metrics=True)
# Pass callback to streaming method
response = client.process_stream_response(
...,
stream_callback=callback,
emit_events=True
)
# After streaming, get metrics
print(stream_logger.get_metrics_summary())
"""
stream_logger = StreamLogger(verbose=verbose, metrics=metrics, logger=logger)
def callback(event: StreamEvent) -> None:
stream_logger.update_from_event(event)
return stream_logger, callback
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""
Detailed profiler to identify the exact source of streaming delay.
Traces every step from agent.start() to HTTP request.
"""
import os
import sys
import time
import functools
os.environ['LOGLEVEL'] = 'DEBUG'
# Microsecond precision timestamps
def ts():
return time.perf_counter()
start_time = ts()
def log(msg):
elapsed = (ts() - start_time) * 1000
print(f"[{elapsed:8.2f}ms] {msg}")
log("Script starting...")
# ============================================================
# PATCH 1: OpenAI client initialization
# ============================================================
import openai
original_openai_init = openai.OpenAI.__init__
@functools.wraps(original_openai_init)
def traced_openai_init(self, *args, **kwargs):
t0 = ts()
result = original_openai_init(self, *args, **kwargs)
log(f"OpenAI.__init__() took {(ts()-t0)*1000:.2f}ms")
return result
openai.OpenAI.__init__ = traced_openai_init
# ============================================================
# PATCH 2: OpenAI completions.create
# ============================================================
original_create = openai.resources.chat.completions.Completions.create
@functools.wraps(original_create)
def traced_create(self, *args, **kwargs):
log(f">>> OpenAI.create() CALLED - stream={kwargs.get('stream', False)}")
t0 = ts()
result = original_create(self, *args, **kwargs)
log(f"<<< OpenAI.create() RETURNED in {(ts()-t0)*1000:.2f}ms")
return result
openai.resources.chat.completions.Completions.create = traced_create
# ============================================================
# PATCH 3: httpx.Client.send (actual HTTP request)
# ============================================================
import httpx
original_send = httpx.Client.send
@functools.wraps(original_send)
def traced_send(self, request, *args, **kwargs):
log(f">>> HTTPX.send() {request.method} {request.url}")
t0 = ts()
result = original_send(self, request, *args, **kwargs)
log(f"<<< HTTPX.send() returned in {(ts()-t0)*1000:.2f}ms - status={result.status_code}")
return result
httpx.Client.send = traced_send
# ============================================================
# PATCH 4: Rich Live display
# ============================================================
from rich.live import Live
original_live_init = Live.__init__
original_live_enter = Live.__enter__
original_live_exit = Live.__exit__
@functools.wraps(original_live_init)
def traced_live_init(self, *args, **kwargs):
log(f">>> Live.__init__() called")
t0 = ts()
result = original_live_init(self, *args, **kwargs)
log(f"<<< Live.__init__() took {(ts()-t0)*1000:.2f}ms")
return result
@functools.wraps(original_live_enter)
def traced_live_enter(self):
log(f">>> Live.__enter__() called")
t0 = ts()
result = original_live_enter(self)
log(f"<<< Live.__enter__() took {(ts()-t0)*1000:.2f}ms")
return result
@functools.wraps(original_live_exit)
def traced_live_exit(self, *args):
log(f">>> Live.__exit__() called")
t0 = ts()
result = original_live_exit(self, *args)
log(f"<<< Live.__exit__() took {(ts()-t0)*1000:.2f}ms")
return result
Live.__init__ = traced_live_init
Live.__enter__ = traced_live_enter
Live.__exit__ = traced_live_exit
log("Patches applied, importing praisonaiagents...")
# ============================================================
# Import and trace Agent
# ============================================================
t0 = ts()
from praisonaiagents import Agent
log(f"Agent import took {(ts()-t0)*1000:.2f}ms")
# ============================================================
# PATCH 5: Agent methods
# ============================================================
from praisonaiagents.agent.agent import Agent as AgentClass
original_chat_completion = AgentClass._chat_completion
original_format_tools = AgentClass._format_tools_for_completion
@functools.wraps(original_chat_completion)
def traced_chat_completion(self, messages, temperature=1.0, tools=None, stream=True,
reasoning_steps=False, task_name=None, task_description=None, task_id=None):
log(f">>> Agent._chat_completion() stream={stream}, _using_custom_llm={self._using_custom_llm}")
t0 = ts()
result = original_chat_completion(self, messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id)
log(f"<<< Agent._chat_completion() took {(ts()-t0)*1000:.2f}ms")
return result
@functools.wraps(original_format_tools)
def traced_format_tools(self, tools):
log(f">>> Agent._format_tools_for_completion() tools={tools}")
t0 = ts()
result = original_format_tools(self, tools)
log(f"<<< Agent._format_tools_for_completion() took {(ts()-t0)*1000:.2f}ms")
return result
AgentClass._chat_completion = traced_chat_completion
AgentClass._format_tools_for_completion = traced_format_tools
# ============================================================
# PATCH 6: OpenAIClient methods
# ============================================================
from praisonaiagents.llm.openai_client import OpenAIClient
original_chat_with_tools = OpenAIClient.chat_completion_with_tools
original_process_stream = OpenAIClient.process_stream_response
original_create_completion = OpenAIClient.create_completion
original_format_tools_client = OpenAIClient.format_tools
@functools.wraps(original_chat_with_tools)
def traced_chat_with_tools(self, *args, **kwargs):
log(f">>> OpenAIClient.chat_completion_with_tools() stream={kwargs.get('stream', True)}")
t0 = ts()
result = original_chat_with_tools(self, *args, **kwargs)
log(f"<<< OpenAIClient.chat_completion_with_tools() took {(ts()-t0)*1000:.2f}ms")
return result
@functools.wraps(original_process_stream)
def traced_process_stream(self, *args, **kwargs):
log(f">>> OpenAIClient.process_stream_response()")
t0 = ts()
result = original_process_stream(self, *args, **kwargs)
log(f"<<< OpenAIClient.process_stream_response() took {(ts()-t0)*1000:.2f}ms")
return result
@functools.wraps(original_create_completion)
def traced_create_completion(self, *args, **kwargs):
log(f">>> OpenAIClient.create_completion() stream={kwargs.get('stream', False)}")
t0 = ts()
result = original_create_completion(self, *args, **kwargs)
log(f"<<< OpenAIClient.create_completion() took {(ts()-t0)*1000:.2f}ms")
return result
@functools.wraps(original_format_tools_client)
def traced_format_tools_client(self, tools):
log(f">>> OpenAIClient.format_tools() tools count={len(tools) if tools else 0}")
t0 = ts()
result = original_format_tools_client(self, tools)
log(f"<<< OpenAIClient.format_tools() took {(ts()-t0)*1000:.2f}ms")
return result
OpenAIClient.chat_completion_with_tools = traced_chat_with_tools
OpenAIClient.process_stream_response = traced_process_stream
OpenAIClient.create_completion = traced_create_completion
OpenAIClient.format_tools = traced_format_tools_client
# ============================================================
# PATCH 7: sync_client property (lazy init)
# ============================================================
original_sync_client_getter = OpenAIClient.sync_client.fget
def traced_sync_client_getter(self):
log(f">>> OpenAIClient.sync_client property accessed")
t0 = ts()
result = original_sync_client_getter(self)
log(f"<<< OpenAIClient.sync_client took {(ts()-t0)*1000:.2f}ms")
return result
OpenAIClient.sync_client = property(traced_sync_client_getter)
log("All patches applied. Creating agent...")
# ============================================================
# RUN THE TEST
# ============================================================
t0 = ts()
agent = Agent(
instructions="You are a helpful assistant"
)
log(f"Agent created in {(ts()-t0)*1000:.2f}ms")
log(f"Agent.stream={agent.stream}, Agent.verbose={agent.verbose}")
log("=" * 60)
log("Starting agent.start()...")
log("=" * 60)
t0 = ts()
result = agent.start("Say hello in one word")
log(f"agent.start() completed in {(ts()-t0)*1000:.2f}ms")
log("=" * 60)
log("DONE")
log("=" * 60)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.11.1"
version = "0.11.3"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,24 @@
"""
Streaming Example with TTFT Metrics
This example demonstrates true pass-through streaming with timing metrics.
When verbose output is enabled, tokens are displayed as they arrive from the provider.
Key timing metrics:
- TTFT (Time To First Token): Time from request to first token received
- Stream Duration: Time from first to last token
- Total Time: End-to-end request time
"""
from praisonaiagents import Agent
from praisonaiagents.config import OutputConfig
# Create agent with verbose output (shows streaming progress)
agent = Agent(
instructions="You are a helpful assistant",
llm="gpt-4o-mini",
output=OutputConfig(verbose=True) # Show generation progress
)
# Run the agent - verbose mode shows streaming progress via Rich Live display
result = agent.start("Write a short paragraph about the history of computing")
print(f"\n--- Response ---\n{result}")
-11
View File
@@ -1,11 +0,0 @@
from praisonaiagents import Agent
agent = Agent(
instructions="You are a helpful assistant",
llm="gpt-4o-mini"
)
# Use chat method with stream=True for streaming responses
for chunk in agent.chat("Write a short paragraph about the history of computing", stream=True):
print(chunk, end="", flush=True)
print() # Final newline
@@ -0,0 +1 @@
"""Unit tests for streaming module."""
@@ -0,0 +1,387 @@
"""
Unit tests for streaming events module.
Tests cover:
- StreamEvent creation and representation
- StreamEventType enum values
- StreamMetrics calculations (TTFT, stream duration, total time)
- StreamEventEmitter callback management
- create_text_printer_callback and create_metrics_callback helpers
"""
import time
from praisonaiagents.streaming.events import (
StreamEvent,
StreamEventType,
StreamMetrics,
StreamEventEmitter,
create_text_printer_callback,
create_metrics_callback,
)
class TestStreamEventType:
"""Tests for StreamEventType enum."""
def test_event_types_exist(self):
"""All required event types should exist."""
assert StreamEventType.REQUEST_START.value == "request_start"
assert StreamEventType.HEADERS_RECEIVED.value == "headers_received"
assert StreamEventType.FIRST_TOKEN.value == "first_token"
assert StreamEventType.DELTA_TEXT.value == "delta_text"
assert StreamEventType.DELTA_TOOL_CALL.value == "delta_tool_call"
assert StreamEventType.TOOL_CALL_END.value == "tool_call_end"
assert StreamEventType.LAST_TOKEN.value == "last_token"
assert StreamEventType.STREAM_END.value == "stream_end"
assert StreamEventType.ERROR.value == "error"
class TestStreamEvent:
"""Tests for StreamEvent dataclass."""
def test_create_basic_event(self):
"""Create a basic event with type only."""
event = StreamEvent(type=StreamEventType.REQUEST_START)
assert event.type == StreamEventType.REQUEST_START
assert event.timestamp > 0
assert event.content is None
assert event.tool_call is None
assert event.metadata is None
assert event.error is None
def test_create_event_with_content(self):
"""Create an event with content."""
event = StreamEvent(
type=StreamEventType.DELTA_TEXT,
content="Hello"
)
assert event.type == StreamEventType.DELTA_TEXT
assert event.content == "Hello"
def test_create_event_with_tool_call(self):
"""Create an event with tool call data."""
tool_data = {"name": "calculator", "arguments": '{"x": 1}'}
event = StreamEvent(
type=StreamEventType.DELTA_TOOL_CALL,
tool_call=tool_data
)
assert event.type == StreamEventType.DELTA_TOOL_CALL
assert event.tool_call == tool_data
def test_create_event_with_metadata(self):
"""Create an event with metadata."""
event = StreamEvent(
type=StreamEventType.REQUEST_START,
metadata={"model": "gpt-4o-mini", "message_count": 2}
)
assert event.metadata["model"] == "gpt-4o-mini"
assert event.metadata["message_count"] == 2
def test_create_error_event(self):
"""Create an error event."""
event = StreamEvent(
type=StreamEventType.ERROR,
error="Connection timeout"
)
assert event.type == StreamEventType.ERROR
assert event.error == "Connection timeout"
def test_event_repr(self):
"""Event repr should be informative."""
event = StreamEvent(
type=StreamEventType.DELTA_TEXT,
content="Hello world"
)
repr_str = repr(event)
assert "DELTA_TEXT" in repr_str or "delta_text" in repr_str
assert "Hello world" in repr_str
class TestStreamMetrics:
"""Tests for StreamMetrics dataclass."""
def test_default_metrics(self):
"""Default metrics should be zero."""
metrics = StreamMetrics()
assert metrics.request_start == 0.0
assert metrics.headers_received == 0.0
assert metrics.first_token == 0.0
assert metrics.last_token == 0.0
assert metrics.stream_end == 0.0
assert metrics.token_count == 0
def test_ttft_calculation(self):
"""TTFT should be first_token - request_start."""
metrics = StreamMetrics()
metrics.request_start = 100.0
metrics.first_token = 100.5
assert metrics.ttft == 0.5
def test_ttft_zero_when_no_first_token(self):
"""TTFT should be 0 when first_token not set."""
metrics = StreamMetrics()
metrics.request_start = 100.0
assert metrics.ttft == 0.0
def test_stream_duration_calculation(self):
"""Stream duration should be last_token - first_token."""
metrics = StreamMetrics()
metrics.first_token = 100.0
metrics.last_token = 102.0
assert metrics.stream_duration == 2.0
def test_total_time_calculation(self):
"""Total time should be stream_end - request_start."""
metrics = StreamMetrics()
metrics.request_start = 100.0
metrics.stream_end = 103.0
assert metrics.total_time == 3.0
def test_tokens_per_second(self):
"""Tokens per second calculation."""
metrics = StreamMetrics()
metrics.first_token = 100.0
metrics.last_token = 102.0 # 2 second duration
metrics.token_count = 100
assert metrics.tokens_per_second == 50.0
def test_tokens_per_second_zero_duration(self):
"""Tokens per second should be 0 when duration is 0."""
metrics = StreamMetrics()
assert metrics.tokens_per_second == 0.0
def test_update_from_event(self):
"""Metrics should update from events."""
metrics = StreamMetrics()
# Request start
event1 = StreamEvent(type=StreamEventType.REQUEST_START, timestamp=100.0)
metrics.update_from_event(event1)
assert metrics.request_start == 100.0
# Headers received
event2 = StreamEvent(type=StreamEventType.HEADERS_RECEIVED, timestamp=100.2)
metrics.update_from_event(event2)
assert metrics.headers_received == 100.2
# First token
event3 = StreamEvent(type=StreamEventType.FIRST_TOKEN, timestamp=100.5)
metrics.update_from_event(event3)
assert metrics.first_token == 100.5
assert metrics.token_count == 1
# Delta text
event4 = StreamEvent(type=StreamEventType.DELTA_TEXT, timestamp=100.6)
metrics.update_from_event(event4)
assert metrics.token_count == 2
# Last token
event5 = StreamEvent(type=StreamEventType.LAST_TOKEN, timestamp=102.0)
metrics.update_from_event(event5)
assert metrics.last_token == 102.0
# Stream end
event6 = StreamEvent(type=StreamEventType.STREAM_END, timestamp=102.1)
metrics.update_from_event(event6)
assert metrics.stream_end == 102.1
def test_to_dict(self):
"""Metrics should convert to dict."""
metrics = StreamMetrics()
metrics.request_start = 100.0
metrics.first_token = 100.5
metrics.last_token = 102.0
metrics.stream_end = 102.1
metrics.token_count = 50
d = metrics.to_dict()
assert "ttft_ms" in d
assert "stream_duration_ms" in d
assert "total_time_ms" in d
assert "token_count" in d
assert "tokens_per_second" in d
def test_format_summary(self):
"""Metrics should format as summary string."""
metrics = StreamMetrics()
metrics.request_start = 100.0
metrics.first_token = 100.5
metrics.last_token = 102.0
metrics.stream_end = 102.1
metrics.token_count = 50
summary = metrics.format_summary()
assert "TTFT" in summary
assert "Stream" in summary
assert "Total" in summary
assert "Tokens" in summary
class TestStreamEventEmitter:
"""Tests for StreamEventEmitter."""
def test_create_emitter(self):
"""Create an emitter."""
emitter = StreamEventEmitter()
assert not emitter.has_callbacks
def test_add_callback(self):
"""Add a callback."""
emitter = StreamEventEmitter()
events = []
def callback(event):
events.append(event)
emitter.add_callback(callback)
assert emitter.has_callbacks
def test_remove_callback(self):
"""Remove a callback."""
emitter = StreamEventEmitter()
events = []
def callback(event):
events.append(event)
emitter.add_callback(callback)
emitter.remove_callback(callback)
assert not emitter.has_callbacks
def test_emit_calls_callbacks(self):
"""Emit should call all callbacks."""
emitter = StreamEventEmitter()
events1 = []
events2 = []
emitter.add_callback(lambda e: events1.append(e))
emitter.add_callback(lambda e: events2.append(e))
event = StreamEvent(type=StreamEventType.DELTA_TEXT, content="Hello")
emitter.emit(event)
assert len(events1) == 1
assert len(events2) == 1
assert events1[0].content == "Hello"
def test_emit_with_metrics(self):
"""Emit should update metrics when enabled."""
emitter = StreamEventEmitter()
emitter.enable_metrics()
emitter.emit(StreamEvent(type=StreamEventType.REQUEST_START, timestamp=100.0))
emitter.emit(StreamEvent(type=StreamEventType.FIRST_TOKEN, timestamp=100.5))
emitter.emit(StreamEvent(type=StreamEventType.STREAM_END, timestamp=102.0))
metrics = emitter.get_metrics()
assert metrics is not None
assert metrics.request_start == 100.0
assert metrics.first_token == 100.5
assert metrics.stream_end == 102.0
def test_callback_error_does_not_break_emission(self):
"""Callback errors should not break other callbacks."""
emitter = StreamEventEmitter()
events = []
def bad_callback(event):
raise ValueError("Test error")
def good_callback(event):
events.append(event)
emitter.add_callback(bad_callback)
emitter.add_callback(good_callback)
event = StreamEvent(type=StreamEventType.DELTA_TEXT)
emitter.emit(event) # Should not raise
assert len(events) == 1
def test_reset_metrics(self):
"""Reset metrics should create new metrics."""
emitter = StreamEventEmitter()
emitter.enable_metrics()
emitter.emit(StreamEvent(type=StreamEventType.REQUEST_START, timestamp=100.0))
emitter.reset_metrics()
metrics = emitter.get_metrics()
assert metrics.request_start == 0.0
class TestHelperFunctions:
"""Tests for helper functions."""
def test_create_text_printer_callback(self, capsys):
"""Text printer callback should print content."""
callback = create_text_printer_callback(flush=True)
callback(StreamEvent(type=StreamEventType.DELTA_TEXT, content="Hello"))
callback(StreamEvent(type=StreamEventType.DELTA_TEXT, content=" World"))
callback(StreamEvent(type=StreamEventType.STREAM_END))
captured = capsys.readouterr()
assert "Hello World" in captured.out
def test_create_metrics_callback(self):
"""Metrics callback should update metrics."""
metrics = StreamMetrics()
callback = create_metrics_callback(metrics)
callback(StreamEvent(type=StreamEventType.REQUEST_START, timestamp=100.0))
callback(StreamEvent(type=StreamEventType.FIRST_TOKEN, timestamp=100.5))
assert metrics.request_start == 100.0
assert metrics.first_token == 100.5
class TestStreamingEventOrdering:
"""Tests for event ordering and pass-through behavior."""
def test_events_emitted_immediately(self):
"""Events should be emitted immediately, not buffered."""
emitter = StreamEventEmitter()
timestamps = []
def callback(event):
timestamps.append((event.type, time.perf_counter()))
emitter.add_callback(callback)
# Simulate streaming with small delays
emitter.emit(StreamEvent(type=StreamEventType.REQUEST_START))
time.sleep(0.01)
emitter.emit(StreamEvent(type=StreamEventType.FIRST_TOKEN))
time.sleep(0.01)
emitter.emit(StreamEvent(type=StreamEventType.DELTA_TEXT))
# Verify events were captured in order
assert len(timestamps) == 3
assert timestamps[0][0] == StreamEventType.REQUEST_START
assert timestamps[1][0] == StreamEventType.FIRST_TOKEN
assert timestamps[2][0] == StreamEventType.DELTA_TEXT
# Verify timestamps are increasing (no buffering)
assert timestamps[0][1] < timestamps[1][1] < timestamps[2][1]
def test_no_buffering_before_first_token(self):
"""Events should not be buffered before first token."""
emitter = StreamEventEmitter()
emitter.enable_metrics()
# Emit request start
t1 = time.perf_counter()
emitter.emit(StreamEvent(type=StreamEventType.REQUEST_START, timestamp=t1))
# Small delay (simulating network)
time.sleep(0.05)
# Emit first token
t2 = time.perf_counter()
emitter.emit(StreamEvent(type=StreamEventType.FIRST_TOKEN, timestamp=t2))
metrics = emitter.get_metrics()
# TTFT should reflect the actual delay
assert metrics.ttft >= 0.04 # Allow some tolerance
@@ -0,0 +1,371 @@
"""
Unit tests for the core SDK profiling module.
Tests cover:
- Profiler enable/disable
- Timing recording
- Streaming tracking
- Phase tracking
- Report generation
- Zero overhead when disabled
"""
import pytest
import time
from unittest.mock import patch
class TestProfilerBasics:
"""Test basic Profiler functionality."""
def test_profiler_disabled_by_default(self):
"""Profiler should be disabled by default."""
from praisonaiagents.profiling import Profiler
Profiler.disable() # Ensure clean state
assert not Profiler.is_enabled()
def test_profiler_enable_disable(self):
"""Test enable/disable functionality."""
from praisonaiagents.profiling import Profiler
Profiler.disable()
assert not Profiler.is_enabled()
Profiler.enable()
assert Profiler.is_enabled()
Profiler.disable()
assert not Profiler.is_enabled()
def test_profiler_env_var_enable(self):
"""Test enabling via environment variable."""
from praisonaiagents.profiling import Profiler
Profiler.disable()
with patch.dict('os.environ', {'PRAISONAI_PROFILE': '1'}):
assert Profiler.is_enabled()
with patch.dict('os.environ', {'PRAISONAI_PROFILE': 'true'}):
assert Profiler.is_enabled()
with patch.dict('os.environ', {'PRAISONAI_PROFILE': 'yes'}):
assert Profiler.is_enabled()
def test_profiler_clear(self):
"""Test clearing profiling data."""
from praisonaiagents.profiling import Profiler
Profiler.enable()
Profiler.record_timing("test", 100.0)
assert len(Profiler.get_timings()) > 0
Profiler.clear()
assert len(Profiler.get_timings()) == 0
Profiler.disable()
class TestTimingRecording:
"""Test timing recording functionality."""
def test_record_timing_when_enabled(self):
"""Timing should be recorded when profiler is enabled."""
from praisonaiagents.profiling import Profiler
Profiler.enable()
Profiler.clear()
Profiler.record_timing("test_op", 123.45, category="test")
timings = Profiler.get_timings()
assert len(timings) == 1
assert timings[0].name == "test_op"
assert timings[0].duration_ms == 123.45
assert timings[0].category == "test"
Profiler.disable()
def test_record_timing_when_disabled(self):
"""Timing should NOT be recorded when profiler is disabled."""
from praisonaiagents.profiling import Profiler
Profiler.disable()
Profiler.clear()
Profiler.record_timing("test_op", 123.45)
timings = Profiler.get_timings()
assert len(timings) == 0
def test_record_timing_with_metadata(self):
"""Timing should include metadata."""
from praisonaiagents.profiling import Profiler
Profiler.enable()
Profiler.clear()
Profiler.record_timing("test_op", 100.0, metadata={"key": "value"})
timings = Profiler.get_timings()
assert timings[0].metadata == {"key": "value"}
Profiler.disable()
class TestPhaseTracking:
"""Test phase tracking functionality."""
def test_phase_context_manager(self):
"""Phase context manager should record timing."""
from praisonaiagents.profiling import Profiler
Profiler.enable()
Profiler.clear()
with Profiler.phase("test_phase"):
time.sleep(0.01) # 10ms
timings = Profiler.get_timings(category="phase")
assert len(timings) == 1
assert timings[0].name == "test_phase"
assert timings[0].duration_ms >= 10 # At least 10ms
Profiler.disable()
def test_nested_phases(self):
"""Nested phases should track parent."""
from praisonaiagents.profiling import Profiler
Profiler.enable()
Profiler.clear()
with Profiler.phase("outer"):
with Profiler.phase("inner"):
time.sleep(0.005)
timings = Profiler.get_timings(category="phase")
assert len(timings) == 2
inner = next(t for t in timings if t.name == "inner")
assert inner.parent == "outer"
Profiler.disable()
class TestStreamingTracker:
"""Test streaming tracker functionality."""
def test_streaming_ttft(self):
"""Streaming tracker should measure TTFT."""
from praisonaiagents.profiling import Profiler, StreamingTracker
Profiler.enable()
Profiler.clear()
tracker = StreamingTracker("test_stream")
tracker.start()
time.sleep(0.01) # 10ms to first token
tracker.first_token()
time.sleep(0.02) # 20ms more
tracker.end()
assert tracker.ttft_ms >= 10
assert tracker.elapsed_ms >= 30
records = Profiler.get_streaming_records()
assert len(records) == 1
assert records[0].name == "test_stream"
assert records[0].ttft_ms >= 10
Profiler.disable()
def test_streaming_context_manager(self):
"""Streaming context manager should work."""
from praisonaiagents.profiling import Profiler
Profiler.enable()
Profiler.clear()
with Profiler.streaming("test_stream") as tracker:
time.sleep(0.01)
tracker.first_token()
tracker.chunk()
tracker.chunk()
records = Profiler.get_streaming_records()
assert len(records) == 1
assert records[0].chunk_count == 2
Profiler.disable()
class TestProfileDecorators:
"""Test profiling decorators."""
def test_profile_func_decorator(self):
"""profile_func decorator should record function timing."""
from praisonaiagents.profiling import Profiler, profile_func
Profiler.enable()
Profiler.clear()
@profile_func
def slow_function():
time.sleep(0.01)
return "done"
result = slow_function()
assert result == "done"
timings = Profiler.get_timings(category="function")
assert len(timings) == 1
assert timings[0].name == "slow_function"
assert timings[0].duration_ms >= 10
Profiler.disable()
def test_profile_func_disabled(self):
"""profile_func should have zero overhead when disabled."""
from praisonaiagents.profiling import Profiler, profile_func
Profiler.disable()
Profiler.clear()
@profile_func
def fast_function():
return "done"
result = fast_function()
assert result == "done"
timings = Profiler.get_timings()
assert len(timings) == 0
class TestProfileBlock:
"""Test profile_block context manager."""
def test_profile_block(self):
"""profile_block should record block timing."""
from praisonaiagents.profiling import Profiler, profile_block
Profiler.enable()
Profiler.clear()
with profile_block("my_block", category="custom"):
time.sleep(0.01)
timings = Profiler.get_timings(category="custom")
assert len(timings) == 1
assert timings[0].name == "my_block"
Profiler.disable()
def test_profile_block_disabled(self):
"""profile_block should have zero overhead when disabled."""
from praisonaiagents.profiling import Profiler, profile_block
Profiler.disable()
Profiler.clear()
with profile_block("my_block"):
pass
timings = Profiler.get_timings()
assert len(timings) == 0
class TestReporting:
"""Test report generation."""
def test_get_summary(self):
"""get_summary should return structured data."""
from praisonaiagents.profiling import Profiler
Profiler.enable()
Profiler.clear()
Profiler.record_timing("op1", 100.0, category="cat1")
Profiler.record_timing("op2", 200.0, category="cat2")
summary = Profiler.get_summary()
assert summary['total_time_ms'] == 300.0
assert summary['timing_count'] == 2
assert 'cat1' in summary['by_category']
assert 'cat2' in summary['by_category']
assert len(summary['slowest_operations']) == 2
Profiler.disable()
def test_export_json(self):
"""export_json should return valid JSON."""
from praisonaiagents.profiling import Profiler
import json
Profiler.enable()
Profiler.clear()
Profiler.record_timing("test", 100.0)
json_str = Profiler.export_json()
data = json.loads(json_str)
assert 'summary' in data
assert 'timings' in data
assert len(data['timings']) == 1
Profiler.disable()
class TestWarmup:
"""Test warmup function."""
def test_warmup_returns_timings(self):
"""warmup should return timing information."""
from praisonaiagents import warmup
result = warmup(include_litellm=False, include_openai=False)
assert isinstance(result, dict)
def test_warmup_with_litellm(self):
"""warmup with litellm should return litellm timing."""
from praisonaiagents import warmup
result = warmup(include_litellm=True, include_openai=False)
assert 'litellm' in result
# Should be positive (imported) or -1 (not available)
assert result['litellm'] > 0 or result['litellm'] == -1
class TestZeroOverhead:
"""Test that profiling has zero overhead when disabled."""
def test_record_timing_overhead(self):
"""record_timing should be fast when disabled."""
from praisonaiagents.profiling import Profiler
Profiler.disable()
start = time.perf_counter()
for _ in range(10000):
Profiler.record_timing("test", 100.0)
elapsed = (time.perf_counter() - start) * 1000
# Should complete 10k calls in under 10ms when disabled
assert elapsed < 10, f"record_timing took {elapsed}ms for 10k calls"
def test_phase_context_overhead(self):
"""phase context manager should be fast when disabled."""
from praisonaiagents.profiling import Profiler
Profiler.disable()
start = time.perf_counter()
for _ in range(1000):
with Profiler.phase("test"):
pass
elapsed = (time.perf_counter() - start) * 1000
# Should complete 1k calls in under 10ms when disabled
assert elapsed < 10, f"phase took {elapsed}ms for 1k calls"
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.11.1"
version = "0.11.3"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -0,0 +1,65 @@
"""
Guardrails - Advanced Example
Demonstrates advanced guardrails patterns including:
- Multi-agent configuration
- Custom callbacks/handlers
- Error handling
- All available options
Expected Output:
Comprehensive demonstration of guardrails with multiple agents.
"""
from praisonaiagents import Agent, Task, PraisonAIAgents
# ============================================================
# Section 1: Custom Configuration
# ============================================================
# Define custom guardrails configuration
# config = ...
# ============================================================
# Section 2: Multi-Agent Setup
# ============================================================
agent1 = Agent(
name="Agent1",
instructions="You are the first agent",
# guardrails=config,
)
agent2 = Agent(
name="Agent2",
instructions="You are the second agent",
# guardrails=config,
)
# ============================================================
# Section 3: Task Definition
# ============================================================
task = Task(
description="Demonstrate guardrails with multiple agents",
agent=agent1,
)
# ============================================================
# Section 4: Execution
# ============================================================
agents = PraisonAIAgents(agents=[agent1, agent2], tasks=[task])
result = agents.start()
print(result)
# ============================================================
# Section 5: All Options Reference
# ============================================================
"""
Guardrails Options:
──────────────────
| Option | Type | Default | Description |
|-----------------|----------|---------|----------------------|
| option1 | str | None | Description here |
| option2 | bool | False | Description here |
"""
@@ -0,0 +1,19 @@
"""
Guardrails - Basic Example
Demonstrates minimal usage of guardrails with a single agent.
Expected Output:
Agent response demonstrating guardrails functionality.
"""
from praisonaiagents import Agent
# Basic guardrails usage
agent = Agent(
instructions="You are a helpful assistant",
# guardrails=... # Configure guardrails here
)
# Run the agent
result = agent.start("Hello! Demonstrate guardrails functionality.")
print(result)
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.2.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.7.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.7.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+120 -4
View File
@@ -141,11 +141,18 @@ def main_callback(
)
set_output_controller(state.output_controller)
# If no command provided, show help or handle legacy behavior
# If no command provided, start interactive mode
if ctx.invoked_subcommand is None:
# Check if there are remaining args (legacy behavior)
# This is handled by the legacy adapter in main.py
pass
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig
tui_config = AsyncTUIConfig(
model="gpt-4o-mini",
show_logo=True,
show_status_bar=state.output_format != OutputFormat.json,
)
tui = AsyncTUI(config=tui_config)
tui.run()
def get_output_controller() -> OutputController:
@@ -266,6 +273,115 @@ def register_commands():
app.add_typer(test_app, name="test", help="Run test suite with tier and provider options")
app.add_typer(examples_app, name="examples", help="Run and manage example files")
# Register standardise command
try:
standardise_app = typer.Typer(name="standardise", help="Documentation and examples standardisation (FDEP)")
@standardise_app.command("check")
def standardise_check(
path: str = typer.Option(".", "--path", "-p", help="Project root path"),
feature: str = typer.Option(None, "--feature", help="Specific feature slug"),
scope: str = typer.Option("all", "--scope", help="Scope: all, docs, examples, sdk, cli"),
ci: bool = typer.Option(False, "--ci", help="CI mode"),
):
"""Check for standardisation issues."""
from .commands.standardise import _run_check
import argparse
args = argparse.Namespace(path=path, feature=feature, scope=scope, ci=ci, dry_run=True)
_run_check(args)
@standardise_app.command("report")
def standardise_report(
path: str = typer.Option(".", "--path", "-p", help="Project root path"),
format: str = typer.Option("text", "--format", "-f", help="Format: text, json, markdown"),
output: str = typer.Option(None, "--output", "-o", help="Output file"),
ci: bool = typer.Option(False, "--ci", help="CI mode"),
):
"""Generate detailed report."""
from .commands.standardise import _run_report
import argparse
args = argparse.Namespace(path=path, format=format, output=output, ci=ci, feature=None, scope="all", dry_run=True)
_run_report(args)
@standardise_app.command("fix")
def standardise_fix(
path: str = typer.Option(".", "--path", "-p", help="Project root path"),
feature: str = typer.Option(None, "--feature", help="Specific feature slug"),
apply: bool = typer.Option(False, "--apply", help="Actually apply changes"),
no_backup: bool = typer.Option(False, "--no-backup", help="Don't create backups"),
):
"""Fix standardisation issues."""
from .commands.standardise import _run_fix
import argparse
args = argparse.Namespace(path=path, feature=feature, apply=apply, no_backup=no_backup, scope="all", ci=False, dry_run=not apply)
_run_fix(args)
@standardise_app.command("init")
def standardise_init(
feature: str = typer.Argument(..., help="Feature slug to initialise"),
path: str = typer.Option(".", "--path", "-p", help="Project root path"),
apply: bool = typer.Option(False, "--apply", help="Actually create files"),
):
"""Initialise a new feature with all required artifacts."""
from .commands.standardise import _run_init
import argparse
args = argparse.Namespace(feature=feature, path=path, apply=apply, scope="all", ci=False, dry_run=not apply)
_run_init(args)
@standardise_app.command("ai")
def standardise_ai(
feature: str = typer.Argument(..., help="Feature slug to generate content for"),
gen_type: str = typer.Option("all", "--type", "-t", help="Type: docs, examples, all"),
apply: bool = typer.Option(False, "--apply", help="Actually create files"),
verify: bool = typer.Option(False, "--verify", help="Verify with AI"),
model: str = typer.Option("gpt-4o-mini", "--model", help="LLM model"),
path: str = typer.Option(".", "--path", "-p", help="Project root path"),
):
"""AI-powered generation of docs/examples."""
from .commands.standardise import _run_ai
import argparse
args = argparse.Namespace(feature=feature, type=gen_type, apply=apply, verify=verify, model=model, path=path, scope="all", ci=False, dry_run=not apply)
_run_ai(args)
@standardise_app.command("checkpoint")
def standardise_checkpoint(
message: str = typer.Option(None, "--message", "-m", help="Checkpoint message"),
path: str = typer.Option(".", "--path", "-p", help="Repository path"),
):
"""Create an undo checkpoint."""
from .commands.standardise import _run_checkpoint
import argparse
args = argparse.Namespace(message=message, path=path)
_run_checkpoint(args)
@standardise_app.command("undo")
def standardise_undo(
checkpoint: str = typer.Option(None, "--checkpoint", help="Checkpoint ID"),
list_checkpoints: bool = typer.Option(False, "--list", help="List checkpoints"),
path: str = typer.Option(".", "--path", "-p", help="Repository path"),
):
"""Undo to a previous checkpoint."""
from .commands.standardise import _run_undo
import argparse
args = argparse.Namespace(checkpoint=checkpoint, list=list_checkpoints, path=path)
_run_undo(args)
@standardise_app.command("redo")
def standardise_redo(
path: str = typer.Option(".", "--path", "-p", help="Repository path"),
):
"""Redo after an undo."""
from .commands.standardise import _run_redo
import argparse
args = argparse.Namespace(path=path)
_run_redo(args)
app.add_typer(standardise_app, name="standardise", help="Documentation and examples standardisation (FDEP)")
# Also register as 'standardize' for US spelling
app.add_typer(standardise_app, name="standardize", help="Documentation and examples standardisation (FDEP)")
except Exception:
pass # Graceful degradation if standardise module not available
# Register TUI and queue commands
tui_app = create_tui_debug_app()
queue_app = create_queue_app()
+24 -68
View File
@@ -127,7 +127,7 @@ def chat_main(
profile: bool = typer.Option(False, "--profile", help="Enable CLI profiling (timing breakdown)"),
profile_deep: bool = typer.Option(False, "--profile-deep", help="Enable deep profiling (cProfile stats, higher overhead)"),
# UI backend selection
ui: str = typer.Option("auto", "--ui", help="UI backend: auto, plain, rich, mg (middle-ground)"),
ui_backend: str = typer.Option("auto", "--ui-backend", help="UI backend: auto, plain, rich, mg (middle-ground)"),
json_output: bool = typer.Option(False, "--json", help="Output JSON (forces plain backend)"),
no_color: bool = typer.Option(False, "--no-color", help="Disable colors"),
theme: str = typer.Option("default", "--theme", help="UI theme: default, dark, light, minimal"),
@@ -149,25 +149,12 @@ def chat_main(
praisonai chat "Summarize this" --file README.md
praisonai chat "What is 2+2?" --profile
"""
import asyncio
import os
# Set workspace if provided
if workspace:
os.environ["PRAISONAI_WORKSPACE"] = workspace
# Configure UI backend
from praisonai.cli.ui import select_backend, UIConfig
ui_config = UIConfig(
ui_backend=ui,
json_output=json_output,
no_color=no_color,
theme=theme,
compact=compact,
verbose=verbose,
)
backend = select_backend(ui_config)
# Handle profiling for single prompt mode
if prompt and (profile or profile_deep):
_run_profiled_chat(
@@ -184,60 +171,30 @@ def chat_main(
typer.echo(" Use: praisonai chat \"your prompt\" --profile", err=True)
# Parse memory flag: --no-memory takes precedence, then --memory value
memory_value = _parse_memory_flag(memory, no_memory)
# TODO: Pass memory_value to TUI when memory support is added
_parse_memory_flag(memory, no_memory)
# Try InteractiveCore first (preferred terminal-native implementation)
try:
from praisonai.cli.interactive import InteractiveCore, InteractiveConfig
from praisonai.cli.interactive.frontends import RichFrontend
config = InteractiveConfig(
model=model,
session_id=session_id,
continue_session=continue_session,
workspace=workspace or None,
verbose=verbose,
memory=memory_value,
files=list(file) if file else [],
autonomy=autonomy,
)
core = InteractiveCore(config=config)
if prompt:
# Single prompt mode - use backend for output
from praisonai.cli.ui.events import UIEventType
async def run_prompt():
if continue_session:
core.continue_session()
backend.emit(UIEventType.MESSAGE_START, {})
response = await core.prompt(prompt)
backend.emit(UIEventType.MESSAGE_CHUNK, {'content': response})
backend.emit(UIEventType.MESSAGE_END, {})
asyncio.run(run_prompt())
else:
# Interactive REPL mode - pass ui_config to frontend
frontend = RichFrontend(core=core, config=config, ui_config=ui_config)
asyncio.run(frontend.run())
except ImportError:
# Fallback to legacy terminal-native interactive mode
_run_legacy_terminal_chat(
prompt=prompt,
model=model,
verbose=verbose,
memory=memory,
tools=tools,
user_id=user_id,
session_id=session_id,
continue_session=continue_session,
workspace=workspace,
no_acp=no_acp,
no_lsp=no_lsp,
)
# Use the async TUI (non-blocking, scrollable output)
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig
tui_config = AsyncTUIConfig(
model=model or "gpt-4o-mini",
show_logo=not compact,
show_status_bar=not compact,
session_id=session_id,
workspace=workspace,
)
tui = AsyncTUI(config=tui_config)
if prompt:
# Single prompt mode - direct response, no streaming
response = tui.run_single(prompt)
if response:
print(response)
else:
# Interactive split-pane TUI mode
tui.run()
def _run_profiled_chat(
@@ -275,7 +232,6 @@ def _run_profiled_chat(
"name": "ChatAgent",
"role": "Assistant",
"goal": "Help the user",
"verbose": verbose,
}
if model:
agent_config["llm"] = model
@@ -2,10 +2,14 @@
CLI commands for the FDEP standardisation system.
Commands:
- praisonai standardise check - Check for standardisation issues
- praisonai standardise report - Generate detailed report
- praisonai standardise fix - Fix issues (with --apply)
- praisonai standardise init - Initialise a new feature
- praisonai standardise check - Check for standardisation issues
- praisonai standardise report - Generate detailed report
- praisonai standardise fix - Fix issues (with --apply)
- praisonai standardise init - Initialise a new feature
- praisonai standardise ai - AI-powered generation
- praisonai standardise checkpoint - Create undo checkpoint
- praisonai standardise undo - Undo to previous checkpoint
- praisonai standardise redo - Redo after undo
"""
import sys
@@ -88,6 +92,91 @@ def standardise_command(args=None):
help="Actually create files (default: dry-run)",
)
# ai subcommand
ai_parser = subparsers.add_parser(
"ai",
help="AI-powered generation of docs/examples",
)
ai_parser.add_argument(
"feature",
type=str,
help="Feature slug to generate content for",
)
ai_parser.add_argument(
"--type", "-t",
choices=["docs", "examples", "all"],
default="all",
help="Type of content to generate",
)
ai_parser.add_argument(
"--apply",
action="store_true",
help="Actually create files (default: dry-run preview)",
)
ai_parser.add_argument(
"--verify",
action="store_true",
help="Verify generated content with AI",
)
ai_parser.add_argument(
"--model",
type=str,
default="gpt-4o-mini",
help="LLM model to use (default: gpt-4o-mini)",
)
_add_common_args(ai_parser)
# checkpoint subcommand
checkpoint_parser = subparsers.add_parser(
"checkpoint",
help="Create an undo checkpoint",
)
checkpoint_parser.add_argument(
"--message", "-m",
type=str,
help="Checkpoint message",
)
checkpoint_parser.add_argument(
"--path", "-p",
type=str,
default=".",
help="Repository path",
)
# undo subcommand
undo_parser = subparsers.add_parser(
"undo",
help="Undo to a previous checkpoint",
)
undo_parser.add_argument(
"--checkpoint",
type=str,
help="Specific checkpoint ID to restore",
)
undo_parser.add_argument(
"--list",
action="store_true",
help="List available checkpoints",
)
undo_parser.add_argument(
"--path", "-p",
type=str,
default=".",
help="Repository path",
)
# redo subcommand
redo_parser = subparsers.add_parser(
"redo",
help="Redo after an undo",
)
redo_parser.add_argument(
"--path", "-p",
type=str,
default=".",
help="Repository path",
)
# Parse args
parsed = parser.parse_args(args)
@@ -105,6 +194,14 @@ def standardise_command(args=None):
return _run_fix(parsed)
elif parsed.subcommand == "init":
return _run_init(parsed)
elif parsed.subcommand == "ai":
return _run_ai(parsed)
elif parsed.subcommand == "checkpoint":
return _run_checkpoint(parsed)
elif parsed.subcommand == "undo":
return _run_undo(parsed)
elif parsed.subcommand == "redo":
return _run_redo(parsed)
return 0
@@ -258,6 +355,210 @@ def _run_init(parsed) -> int:
return 0
def _run_ai(parsed) -> int:
"""Run the AI generation subcommand with ACP/LSP runtime support."""
import asyncio
from praisonai.standardise.ai_generator import AIGenerator
from praisonai.standardise.models import ArtifactType, FeatureSlug
config = _create_config(parsed)
slug = FeatureSlug.from_string(parsed.feature)
if not slug.is_valid:
print(f"Error: Invalid feature slug: {slug.validation_error}")
return 2
# Start ACP/LSP runtime for context gathering
runtime = None
try:
from praisonai.cli.features.interactive_runtime import create_runtime
runtime = create_runtime(
workspace=str(config.sdk_root or config.project_root or "."),
lsp=True,
acp=False # ACP not needed for generation
)
# Start runtime in background
asyncio.get_event_loop().run_until_complete(runtime.start())
if runtime.lsp_ready:
print("🔧 LSP server ready for code intelligence")
except Exception:
# Runtime is optional, continue without it
pass
generator = AIGenerator(
model=parsed.model,
sdk_root=config.sdk_root,
docs_root=config.docs_root,
examples_root=config.examples_root,
use_fast_context=True,
)
# Determine which artifacts to generate
artifact_types = []
gen_type = getattr(parsed, "type", "all")
if gen_type in ["docs", "all"]:
artifact_types.extend([
ArtifactType.DOCS_CONCEPT,
ArtifactType.DOCS_FEATURE,
ArtifactType.DOCS_CLI,
ArtifactType.DOCS_SDK,
])
if gen_type in ["examples", "all"]:
artifact_types.extend([
ArtifactType.EXAMPLE_BASIC,
ArtifactType.EXAMPLE_ADVANCED,
])
print(f"🤖 AI Generation for '{slug.normalised}'")
print("=" * 50)
results = []
for artifact_type in artifact_types:
print(f"\n📝 Generating {artifact_type.value}...")
try:
# Generate with execution verification for examples
content, output_path, verification_info = generator.generate(
slug, artifact_type,
dry_run=not parsed.apply,
verify_execution=True,
)
# Show verification results for examples
if verification_info:
if verification_info.get("execution_passed"):
print(" ✅ Code verified: runs successfully")
elif verification_info.get("requires_external"):
libs = ", ".join(verification_info.get("missing_libraries", []))
print(f" ⚠️ Requires external libraries: {libs}")
elif not verification_info.get("syntax_valid"):
print(f" ❌ Syntax error: {verification_info.get('error', 'Unknown')[:100]}")
else:
print(f" ❌ Execution failed after {verification_info.get('attempt', 1)} attempts")
if verification_info.get("error"):
print(f" Error: {verification_info['error'][:150]}")
# AI content verification (optional)
if parsed.verify:
print(" 🔍 AI content review...")
is_valid, summary, issues = generator.verify(content, artifact_type, slug)
if is_valid:
print(f" ✅ {summary}")
else:
print(f" ⚠️ {summary}")
for issue in issues[:3]:
print(f" - {issue}")
# Check if we should write
can_write = True
if verification_info and not verification_info.get("success"):
if not verification_info.get("requires_external"):
can_write = False
print(" ⛔ Not writing: code doesn't run")
if parsed.apply and output_path and can_write:
print(f" ✓ Created: {output_path}")
results.append((artifact_type.value, True, output_path))
elif not parsed.apply:
print(f" 📋 Preview ({len(content)} chars)")
# Show first few lines
preview_lines = content.split("\n")[:10]
for line in preview_lines:
print(f" {line[:60]}")
if len(content.split("\n")) > 10:
print(" ...")
results.append((artifact_type.value, True, None))
else:
results.append((artifact_type.value, False, "Verification failed"))
except Exception as e:
print(f" ❌ Error: {e}")
results.append((artifact_type.value, False, str(e)))
print("\n" + "=" * 50)
print("Summary:")
success_count = sum(1 for _, success, _ in results if success)
print(f" Generated: {success_count}/{len(results)}")
if not parsed.apply:
print("\nRun with --apply to create these files.")
# Cleanup runtime
if runtime:
try:
asyncio.get_event_loop().run_until_complete(runtime.stop())
except Exception:
pass
return 0
def _run_checkpoint(parsed) -> int:
"""Run the checkpoint subcommand."""
from praisonai.standardise.undo_redo import UndoRedoManager
manager = UndoRedoManager(Path(parsed.path).resolve())
success, result = manager.create_checkpoint(parsed.message)
if success:
print(f"✓ Checkpoint created: {result}")
else:
print(f"❌ Failed: {result}")
return 1
return 0
def _run_undo(parsed) -> int:
"""Run the undo subcommand."""
from praisonai.standardise.undo_redo import UndoRedoManager
manager = UndoRedoManager(Path(parsed.path).resolve())
# List checkpoints if requested
if getattr(parsed, "list", False):
checkpoints = manager.list_checkpoints()
if not checkpoints:
print("No checkpoints found.")
return 0
print("Available checkpoints:")
for cp_id, date, message in checkpoints:
print(f" • {cp_id} ({date}): {message[:50]}")
return 0
# Undo to checkpoint
success, result = manager.undo(parsed.checkpoint)
if success:
print(f"✓ {result}")
else:
print(f"❌ {result}")
return 1
return 0
def _run_redo(parsed) -> int:
"""Run the redo subcommand."""
from praisonai.standardise.undo_redo import UndoRedoManager
manager = UndoRedoManager(Path(parsed.path).resolve())
success, result = manager.redo()
if success:
print(f"✓ {result}")
else:
print(f"❌ {result}")
return 1
return 0
# Click-based alternative for integration with main CLI
def register_click_commands(app):
"""Register click commands with the main CLI app."""
@@ -0,0 +1,991 @@
"""
PraisonAI Async TUI Application.
Full-screen terminal UI with:
- Non-blocking LLM execution (UI stays responsive)
- Scrollable output area with keyboard navigation
- Fixed bottom input pane (always visible)
- Activity indicators during processing
Key fixes:
- Uses threading for LLM calls to prevent UI freeze
- Uses Buffer + BufferControl for scrollable output
- Proper app.invalidate() for UI refresh
"""
import shutil
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
from datetime import datetime
# ============================================================================
# ASCII Art Logo - "Praison AI" branding
# ============================================================================
LOGO = r"""
██████╗ ██████╗ █████╗ ██╗███████╗ ██████╗ ███╗ ██╗ █████╗ ██╗
██╔══██╗██╔══██╗██╔══██╗██║██╔════╝██╔═══██╗████╗ ██║ ██╔══██╗██║
██████╔╝██████╔╝███████║██║███████╗██║ ██║██╔██╗ ██║ ███████║██║
██╔═══╝ ██╔══██╗██╔══██║██║╚════██║██║ ██║██║╚██╗██║ ██╔══██║██║
██║ ██║ ██║██║ ██║██║███████║╚██████╔╝██║ ╚████║ ██║ ██║██║
╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝
"""
LOGO_SMALL = r"""
╔═╗┬─┐┌─┐┬┌─┐┌─┐┌┐┌ ╔═╗╦
╠═╝├┬┘├─┤│└─┐│ ││││ ╠═╣║
╩ ┴└─┴ ┴┴└─┘└─┘┘└┘ ╩ ╩╩
"""
LOGO_MINIMAL = "▶ Praison AI"
def get_logo(width: int = 80) -> str:
"""Get appropriate logo based on terminal width."""
if width >= 75:
return LOGO
elif width >= 40:
return LOGO_SMALL
else:
return LOGO_MINIMAL
def get_version() -> str:
"""Get PraisonAI version."""
try:
from praisonai import __version__
return __version__
except Exception:
return "1.0.0"
# ============================================================================
# Configuration
# ============================================================================
@dataclass
class AsyncTUIConfig:
"""Configuration for the Async TUI application."""
model: str = "gpt-4o-mini"
show_logo: bool = True
show_status_bar: bool = True
session_id: Optional[str] = None
workspace: Optional[str] = None
history_file: Optional[str] = None
compact_mode: bool = False
multiline_mode: bool = False
# ============================================================================
# Message Types
# ============================================================================
@dataclass
class ChatMessage:
"""A chat message."""
role: str # "user", "assistant", "system", "status"
content: str
timestamp: datetime = field(default_factory=datetime.now)
# ============================================================================
# Async TUI Application
# ============================================================================
class AsyncTUI:
"""
Async TUI with non-blocking LLM execution and scrollable output.
Key features:
- Input box ALWAYS visible at bottom (never freezes)
- Output scrolls above independently
- LLM runs in background thread
- UI stays responsive during processing
"""
def __init__(self, config: Optional[AsyncTUIConfig] = None):
self.config = config or AsyncTUIConfig()
self.messages: List[ChatMessage] = []
self._running = False
self._agent = None
self._processing = False
self._status_text = ""
self._app = None
self._output_buffer = None
self._prompt_queue: List[str] = [] # Queue for pending prompts
self._conversation_history: List[dict] = [] # Full conversation history
self._total_tokens = 0
self._total_cost = 0.0
self._workspace_files: List[str] = [] # Files in workspace for @ completion
self._runtime = None # InteractiveRuntime for ACP/LSP
self._runtime_started = False
# Terminal size
self.term_width, self.term_height = shutil.get_terminal_size((80, 24))
# Session ID
import uuid
self.session_id = self.config.session_id or str(uuid.uuid4())[:8]
# Scan workspace for files
self._scan_workspace()
def _scan_workspace(self):
"""Scan workspace for files (for @ completion)."""
import os
workspace = self.config.workspace or os.getcwd()
try:
for root, dirs, files in os.walk(workspace):
# Skip hidden directories
dirs[:] = [d for d in dirs if not d.startswith('.')]
for f in files:
if not f.startswith('.'):
rel_path = os.path.relpath(os.path.join(root, f), workspace)
self._workspace_files.append(rel_path)
# Limit to 1000 files
if len(self._workspace_files) >= 1000:
break
except Exception:
pass
def _process_file_mentions(self, prompt: str) -> str:
"""Process @file mentions and include file contents."""
import os
import re
workspace = self.config.workspace or os.getcwd()
# Find all @file mentions
pattern = r'@([^\s]+)'
matches = re.findall(pattern, prompt)
if not matches:
return prompt
file_contents = []
for match in matches:
file_path = os.path.join(workspace, match)
if os.path.isfile(file_path):
try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
file_contents.append(f"\n--- Content of {match} ---\n{content}\n--- End of {match} ---\n")
except Exception as e:
file_contents.append(f"\n[Error reading {match}: {e}]\n")
if file_contents:
return prompt + "\n" + "\n".join(file_contents)
return prompt
def _get_agent(self):
"""Lazy-load the agent with tools."""
if self._agent is None:
try:
from praisonaiagents import Agent
# Load interactive tools (read_file, write_file, execute_command, etc.)
tools = self._load_tools()
self._agent = Agent(
name="Praison",
role="AI Assistant",
goal="Help the user with their requests",
instructions="You are Praison AI, a helpful assistant. Be concise and helpful. You have access to tools for file operations, code execution, and web search.",
llm=self.config.model,
tools=tools if tools else None,
)
except ImportError as e:
raise RuntimeError(f"Failed to import praisonaiagents: {e}")
return self._agent
async def _start_runtime(self):
"""Start the InteractiveRuntime with ACP/LSP servers."""
if self._runtime_started:
return
try:
from praisonai.cli.features.interactive_runtime import create_runtime
self._runtime = create_runtime(
workspace=self.config.workspace or ".",
lsp=True,
acp=True,
approval="auto", # Auto-approve for interactive mode
)
# Start runtime (this starts ACP/LSP servers)
await self._runtime.start()
self._runtime_started = True
# Return status
return {
"lsp": "ready" if self._runtime.lsp_ready else "failed",
"acp": "ready" if self._runtime.acp_ready else "failed",
"read_only": self._runtime.read_only
}
except ImportError as e:
# Runtime not available, continue without it
self._runtime_started = True # Mark as attempted
return {"error": f"Runtime not available: {e}"}
except Exception as e:
self._runtime_started = True
return {"error": str(e)}
def _load_tools(self):
"""Load interactive tools for the agent."""
tools = []
# Try to load all interactive tools (basic + ACP + LSP)
try:
from praisonai.cli.features.interactive_tools import get_interactive_tools
tools = get_interactive_tools(
groups=["basic", "acp", "lsp"], # All tool groups
workspace=self.config.workspace,
)
if tools:
return tools
except ImportError:
pass
except Exception:
pass
# Fallback: try to load basic tools directly from praisonaiagents
try:
from praisonaiagents.tools import (
read_file,
write_file,
list_files,
execute_command,
)
tools = [read_file, write_file, list_files, execute_command]
except ImportError:
pass
# Try to add internet search
try:
from praisonaiagents.tools import internet_search
tools.append(internet_search)
except ImportError:
pass
return tools
def _format_output(self) -> str:
"""Format all messages for the output pane."""
lines = []
# Logo (only if no messages yet)
if self.config.show_logo and not self.messages:
logo = get_logo(self.term_width)
lines.append(logo.strip())
lines.append("")
lines.append(f" v{get_version()} · Model: {self.config.model}")
lines.append("")
lines.append(" Type your message and press Enter. Use /help for commands.")
lines.append(" Use PageUp/PageDown or Ctrl+Up/Down to scroll.")
lines.append("")
# Messages
for msg in self.messages:
if msg.role == "user":
lines.append(f"› {msg.content}")
lines.append("")
elif msg.role == "assistant":
lines.append(f"● {msg.content}")
lines.append("")
elif msg.role == "system":
lines.append(f" {msg.content}")
lines.append("")
elif msg.role == "status":
lines.append(f" ⏳ {msg.content}")
lines.append("")
# Processing indicator
if self._processing:
lines.append(f" ⏳ {self._status_text}")
lines.append("")
return "\n".join(lines)
def _update_output(self):
"""Update the output buffer and refresh UI."""
if self._output_buffer:
new_text = self._format_output()
self._output_buffer.set_document(
self._output_buffer.document.__class__(
text=new_text,
cursor_position=len(new_text) # Cursor at end for auto-scroll
),
bypass_readonly=True
)
if self._app:
self._app.invalidate()
def _handle_command(self, command: str) -> bool:
"""Handle slash commands. Returns True if handled."""
parts = command.split(maxsplit=1)
cmd = parts[0].lower().lstrip("/")
args = parts[1] if len(parts) > 1 else ""
if cmd in ("exit", "quit", "q"):
self._running = False
if self._app:
self._app.exit()
return True
elif cmd == "help":
help_text = """Available Commands:
/help Show this help
/exit, /quit Exit Praison AI
/clear Clear conversation
/new Start new conversation
/model [name] Show or change model
/session Show current session info
/sessions List all saved sessions
/continue Continue most recent session
/history Show conversation history
/export [file] Export conversation to file
/import <file> Import conversation from file
/cost Show token usage and cost
/status Show ACP/LSP runtime status
/compact Toggle compact output mode
/multiline Toggle multiline input mode
/files List workspace files for @ mentions
/queue Show pending prompts in queue
Keyboard Shortcuts:
PageUp/Down Scroll output
Ctrl+Up/Down Scroll output
Ctrl+C Clear input
Ctrl+D Exit (if input empty)
Ctrl+Q Exit
Tips:
Use @filename to include file contents in your prompt
Type multiple prompts while AI is thinking (queued)"""
self.messages.append(ChatMessage(role="system", content=help_text))
return True
elif cmd == "clear":
self.messages.clear()
self._conversation_history.clear()
self.messages.append(ChatMessage(role="system", content="Conversation cleared."))
return True
elif cmd == "new":
self.messages.clear()
self._conversation_history.clear()
import uuid
self.session_id = str(uuid.uuid4())[:8]
self.messages.append(ChatMessage(role="system", content=f"New session: {self.session_id}"))
return True
elif cmd == "model":
if args:
self.config.model = args
self._agent = None
self.messages.append(ChatMessage(role="system", content=f"Model changed to: {args}"))
else:
self.messages.append(ChatMessage(role="system", content=f"Current model: {self.config.model}"))
return True
elif cmd == "session":
info = f"Session: {self.session_id}\nModel: {self.config.model}\nMessages: {len(self._conversation_history)}"
self.messages.append(ChatMessage(role="system", content=info))
return True
elif cmd == "history":
if not self._conversation_history:
self.messages.append(ChatMessage(role="system", content="No conversation history."))
else:
history_lines = []
for i, msg in enumerate(self._conversation_history[-10:], 1):
role = msg.get("role", "unknown")
content = msg.get("content", "")[:80]
history_lines.append(f"{i}. [{role}] {content}...")
self.messages.append(ChatMessage(role="system", content="\n".join(history_lines)))
return True
elif cmd == "export":
filename = args or f"conversation_{self.session_id}.txt"
try:
with open(filename, "w") as f:
f.write("# Praison AI Conversation\n")
f.write(f"# Session: {self.session_id}\n")
f.write(f"# Model: {self.config.model}\n\n")
for msg in self._conversation_history:
f.write(f"[{msg.get('role', 'unknown')}]\n")
f.write(f"{msg.get('content', '')}\n\n")
self.messages.append(ChatMessage(role="system", content=f"Exported to {filename}"))
except Exception as e:
self.messages.append(ChatMessage(role="system", content=f"Export failed: {e}"))
return True
elif cmd == "cost":
cost_info = f"Total tokens: {self._total_tokens}\nEstimated cost: ${self._total_cost:.4f}"
self.messages.append(ChatMessage(role="system", content=cost_info))
return True
elif cmd == "compact":
self.config.compact_mode = not self.config.compact_mode
mode = "enabled" if self.config.compact_mode else "disabled"
self.messages.append(ChatMessage(role="system", content=f"Compact mode {mode}"))
return True
elif cmd == "multiline":
self.config.multiline_mode = not self.config.multiline_mode
mode = "enabled" if self.config.multiline_mode else "disabled"
self.messages.append(ChatMessage(role="system", content=f"Multiline mode {mode}. Use Ctrl+D to submit."))
return True
elif cmd == "files":
if not self._workspace_files:
self.messages.append(ChatMessage(role="system", content="No files found in workspace."))
else:
files_list = self._workspace_files[:20]
more = f"\n... and {len(self._workspace_files) - 20} more" if len(self._workspace_files) > 20 else ""
self.messages.append(ChatMessage(role="system", content="Workspace files:\n" + "\n".join(f" @{f}" for f in files_list) + more))
return True
elif cmd == "queue":
if not self._prompt_queue:
self.messages.append(ChatMessage(role="system", content="No prompts in queue."))
else:
queue_list = [f"{i+1}. {p[:50]}..." for i, p in enumerate(self._prompt_queue)]
self.messages.append(ChatMessage(role="system", content="Queued prompts:\n" + "\n".join(queue_list)))
return True
elif cmd == "sessions":
# List available sessions
try:
from praisonai.cli.session import get_session_store
store = get_session_store()
sessions = store.list_sessions()
if not sessions:
self.messages.append(ChatMessage(role="system", content="No saved sessions found."))
else:
lines = ["Available sessions:"]
for s in sessions[:10]:
sid = s.get("session_id", "unknown")
title = s.get("title", "Untitled")
lines.append(f" {sid}: {title}")
if len(sessions) > 10:
lines.append(f" ... and {len(sessions) - 10} more")
self.messages.append(ChatMessage(role="system", content="\n".join(lines)))
except Exception as e:
self.messages.append(ChatMessage(role="system", content=f"Could not list sessions: {e}"))
return True
elif cmd == "continue":
# Continue most recent session
try:
from praisonai.cli.session import get_session_store
store = get_session_store()
sessions = store.list_sessions()
if not sessions:
self.messages.append(ChatMessage(role="system", content="No sessions to continue."))
else:
# Get most recent
sorted_sessions = sorted(
sessions,
key=lambda s: s.get("updated_at", s.get("created_at", 0)),
reverse=True
)
if sorted_sessions:
session = store.load(sorted_sessions[0].get("session_id"))
if session:
self.session_id = session.session_id
# Load history
history = session.get_chat_history()
self._conversation_history = history
self.messages.append(ChatMessage(
role="system",
content=f"Continued session: {self.session_id} ({len(history)} messages)"
))
else:
self.messages.append(ChatMessage(role="system", content="Could not load session."))
except Exception as e:
self.messages.append(ChatMessage(role="system", content=f"Could not continue session: {e}"))
return True
elif cmd == "import":
# Import session from file
if not args:
self.messages.append(ChatMessage(role="system", content="Usage: /import <filename>"))
else:
try:
import json
with open(args) as f:
data = json.load(f)
# Load messages from file
messages = data.get("messages", [])
self._conversation_history = messages
self.session_id = data.get("session_id", self.session_id)
self.messages.append(ChatMessage(
role="system",
content=f"Imported {len(messages)} messages from {args}"
))
except FileNotFoundError:
self.messages.append(ChatMessage(role="system", content=f"File not found: {args}"))
except Exception as e:
self.messages.append(ChatMessage(role="system", content=f"Import failed: {e}"))
return True
elif cmd == "status":
# Show runtime status (ACP/LSP)
if self._runtime:
status = self._runtime.get_status()
lsp = status.get("lsp", {})
acp = status.get("acp", {})
info = f"Runtime Status:\n LSP: {lsp.get('status', 'unknown')}\n ACP: {acp.get('status', 'unknown')}\n Read-only: {status.get('read_only', False)}"
self.messages.append(ChatMessage(role="system", content=info))
else:
self.messages.append(ChatMessage(role="system", content="Runtime not initialized."))
return True
else:
self.messages.append(ChatMessage(role="system", content=f"Unknown command: /{cmd}. Use /help for available commands."))
return True
def _execute_prompt(self, prompt: str) -> Optional[str]:
"""Execute a prompt and return the response (suppresses agent output)."""
import sys
import io
import asyncio
try:
agent = self._get_agent()
# Suppress agent's Rich output
old_stdout = sys.stdout
old_stderr = sys.stderr
sys.stdout = io.StringIO()
sys.stderr = io.StringIO()
try:
# Try async execution first for better non-blocking behavior
if hasattr(agent, 'astart'):
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
response = loop.run_until_complete(agent.astart(prompt))
loop.close()
except Exception:
# Fallback to sync
response = agent.start(prompt)
else:
response = agent.start(prompt)
finally:
sys.stdout = old_stdout
sys.stderr = old_stderr
return str(response) if response else None
except Exception as e:
return f"Error: {e}"
def _execute_in_background(self, prompt: str):
"""Execute prompt in background thread (non-blocking)."""
def run():
self._processing = True
self._status_text = "Praison AI is thinking..."
self._update_output()
# Process @file mentions
processed_prompt = self._process_file_mentions(prompt)
# Add to conversation history
self._conversation_history.append({"role": "user", "content": prompt})
# Animate spinner while processing
spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
start_time = time.time()
# Start LLM execution in another thread
result = [None]
error = [None]
done = [False]
def execute_llm():
try:
result[0] = self._execute_prompt(processed_prompt)
except Exception as e:
error[0] = str(e)
finally:
done[0] = True
llm_thread = threading.Thread(target=execute_llm)
llm_thread.start()
# Update spinner while waiting
idx = 0
while not done[0]:
elapsed = time.time() - start_time
queue_info = f" | Queue: {len(self._prompt_queue)}" if self._prompt_queue else ""
self._status_text = f"{spinner[idx]} Praison AI is thinking... ({elapsed:.1f}s){queue_info}"
self._update_output()
idx = (idx + 1) % len(spinner)
time.sleep(0.1)
llm_thread.join()
# Done processing current prompt
self._processing = False
self._status_text = ""
if error[0]:
self.messages.append(ChatMessage(role="assistant", content=f"Error: {error[0]}"))
self._conversation_history.append({"role": "assistant", "content": f"Error: {error[0]}"})
elif result[0]:
self.messages.append(ChatMessage(role="assistant", content=result[0]))
self._conversation_history.append({"role": "assistant", "content": result[0]})
self._update_output()
# Process next item in queue if any
if self._prompt_queue:
next_prompt = self._prompt_queue.pop(0)
self.messages.append(ChatMessage(role="user", content=next_prompt))
self._update_output()
self._execute_in_background(next_prompt)
# Run in background thread
thread = threading.Thread(target=run, daemon=True)
thread.start()
def _queue_or_execute(self, prompt: str):
"""Queue prompt if processing, otherwise execute immediately."""
if self._processing:
# Add to queue
self._prompt_queue.append(prompt)
self.messages.append(ChatMessage(role="system", content=f"Queued: {prompt[:50]}..."))
self._update_output()
else:
# Execute immediately
self.messages.append(ChatMessage(role="user", content=prompt))
self._update_output()
self._execute_in_background(prompt)
def run(self) -> None:
"""Run the async TUI."""
import asyncio
self._running = True
# Start runtime (ACP/LSP servers) before TUI
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
runtime_status = loop.run_until_complete(self._start_runtime())
loop.close()
# Show runtime status
if runtime_status and "error" not in runtime_status:
lsp = runtime_status.get("lsp", "unknown")
acp = runtime_status.get("acp", "unknown")
if lsp == "ready" or acp == "ready":
self.messages.append(ChatMessage(
role="system",
content=f"Runtime started: LSP={lsp}, ACP={acp}"
))
except Exception as e:
# Continue without runtime
self.messages.append(ChatMessage(
role="system",
content=f"Runtime unavailable: {e}"
))
try:
self._run_with_prompt_toolkit()
except ImportError:
self._run_simple()
def _run_simple(self) -> None:
"""Simple fallback mode without prompt_toolkit."""
print(self._format_output())
while self._running:
try:
if self.config.show_status_bar:
status = f" ? for help | {self.config.model} | Session: {self.session_id} "
print(f"\033[90m{status}\033[0m")
user_input = input("› ").strip()
if not user_input:
continue
if user_input.startswith("/"):
self._handle_command(user_input)
for msg in self.messages:
if msg.role == "system":
print(f" {msg.content}")
self.messages = [m for m in self.messages if m.role != "system"]
continue
self.messages.append(ChatMessage(role="user", content=user_input))
print(" ⏳ Praison AI is thinking...")
response = self._execute_prompt(user_input)
if response:
self.messages.append(ChatMessage(role="assistant", content=response))
print(f"\n● {response}\n")
except KeyboardInterrupt:
print("\n Use /exit to quit")
except EOFError:
self._running = False
print("\n Goodbye from Praison AI!")
def _run_with_prompt_toolkit(self) -> None:
"""Run with prompt_toolkit for non-blocking split-pane layout."""
from prompt_toolkit import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.document import Document
from prompt_toolkit.layout.containers import HSplit, VSplit, Window, Float, FloatContainer
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.layout.margins import ScrollbarMargin
from prompt_toolkit.layout.menus import CompletionsMenu
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.styles import Style
from prompt_toolkit.history import FileHistory
from prompt_toolkit.completion import Completer, Completion
# Create completer for commands and files
commands = ["help", "exit", "quit", "clear", "new", "model", "session", "sessions",
"continue", "history", "export", "import", "cost", "status", "compact",
"multiline", "files", "queue"]
workspace_files = self._workspace_files
class PraisonCompleter(Completer):
def get_completions(self, document, complete_event):
text = document.text_before_cursor
# Get the current word being typed
# Handle case where text might be empty or have no words
if not text:
return
# Find the current word (last word or partial word)
words = text.split()
if not words:
current_word = text
elif text.endswith(' '):
current_word = ""
else:
current_word = words[-1]
# Show all slash commands when "/" is typed
if current_word == "/":
for cmd in commands:
yield Completion(f"/{cmd}", start_position=-1, display=f"/{cmd}", display_meta="command")
# Filter slash commands
elif current_word.startswith("/"):
cmd_part = current_word[1:].lower()
for cmd in commands:
if cmd.lower().startswith(cmd_part):
yield Completion(f"/{cmd}", start_position=-len(current_word), display=f"/{cmd}", display_meta="command")
# Show all files when "@" is typed
elif current_word == "@":
for f in workspace_files[:30]:
yield Completion(f"@{f}", start_position=-1, display=f"@{f}", display_meta="file")
# Filter @ file mentions
elif current_word.startswith("@"):
file_part = current_word[1:].lower()
for f in workspace_files[:50]:
if file_part in f.lower():
yield Completion(f"@{f}", start_position=-len(current_word), display=f"@{f}", display_meta="file")
# Setup history
history_file = self.config.history_file or str(Path.home() / ".praison" / "history")
Path(history_file).parent.mkdir(parents=True, exist_ok=True)
# Output buffer (read-only, scrollable)
initial_output = self._format_output()
self._output_buffer = Buffer(
document=Document(initial_output, cursor_position=len(initial_output)),
read_only=True,
name="output"
)
# Scroll offset for output
self._scroll_offset = 0
# Input buffer with completer and auto-complete on typing
input_buffer = Buffer(
multiline=False,
history=FileHistory(history_file),
name="input",
completer=PraisonCompleter(),
complete_while_typing=True, # Show completions as you type
)
# Key bindings
kb = KeyBindings()
@kb.add("enter")
def handle_enter(event):
user_input = input_buffer.text.strip()
if not user_input:
return
input_buffer.reset()
# Handle commands (always process immediately)
if user_input.startswith("/"):
self._handle_command(user_input)
self._update_output()
return
# Queue or execute prompt
self._queue_or_execute(user_input)
@kb.add("c-c")
def handle_ctrl_c(event):
input_buffer.reset()
@kb.add("c-d")
def handle_ctrl_d(event):
if not input_buffer.text:
self._running = False
event.app.exit()
@kb.add("c-q")
def handle_ctrl_q(event):
self._running = False
event.app.exit()
# Scroll bindings for output
@kb.add("pageup")
def handle_pageup(event):
self._output_buffer.cursor_up(count=10)
@kb.add("pagedown")
def handle_pagedown(event):
self._output_buffer.cursor_down(count=10)
@kb.add("c-up")
def handle_ctrl_up(event):
self._output_buffer.cursor_up(count=3)
@kb.add("c-down")
def handle_ctrl_down(event):
self._output_buffer.cursor_down(count=3)
@kb.add("home")
def handle_home(event):
self._output_buffer.cursor_position = 0
@kb.add("end")
def handle_end(event):
self._output_buffer.cursor_position = len(self._output_buffer.text)
# Style
style = Style.from_dict({
"output-area": "bg:#0d0d0d",
"input-area": "bg:#1a1a1a",
"status-bar": "bg:#333333 #888888",
"prompt": "#00aaff bold",
"scrollbar.background": "bg:#333333",
"scrollbar.button": "bg:#666666",
})
# Status bar
def get_status_bar():
if self._processing:
return f" {self._status_text} "
left = "? for help | PageUp/Down to scroll"
center = self.config.model
right = f"Session: {self.session_id}"
return f" {left} | {center} | {right} "
# Layout: Output (top, scrollable) + Status bar + Input (bottom, fixed)
# Use FloatContainer to show completion menu as floating overlay
main_layout = HSplit([
# Output area (scrollable with scrollbar)
Window(
content=BufferControl(
buffer=self._output_buffer,
focusable=False,
),
wrap_lines=True,
style="class:output-area",
right_margins=[ScrollbarMargin(display_arrows=True)],
),
# Status bar (1 line)
Window(
content=FormattedTextControl(get_status_bar),
height=1,
style="class:status-bar",
),
# Input area (fixed at bottom)
HSplit([
Window(height=1), # Spacer
VSplit([
Window(
content=FormattedTextControl([("class:prompt", "› ")]),
width=2,
),
Window(
content=BufferControl(buffer=input_buffer),
wrap_lines=True,
),
]),
Window(height=1), # Spacer
], height=3, style="class:input-area"),
])
# Wrap in FloatContainer to show completions menu
layout = Layout(
FloatContainer(
content=main_layout,
floats=[
Float(
xcursor=True,
ycursor=True,
content=CompletionsMenu(max_height=10, scroll_offset=1),
),
],
)
)
# Create application
self._app = Application(
layout=layout,
key_bindings=kb,
style=style,
full_screen=True,
mouse_support=True,
refresh_interval=0.1, # Refresh every 100ms for spinner
)
# Run
self._app.run()
print("\n Goodbye from Praison AI!")
def run_single(self, prompt: str) -> Optional[str]:
"""Run a single prompt (non-interactive)."""
return self._execute_prompt(prompt)
def start_async_tui(
model: str = "gpt-4o-mini",
show_logo: bool = True,
**kwargs
) -> None:
"""Start the async TUI application."""
config = AsyncTUIConfig(
model=model,
show_logo=show_logo,
)
tui = AsyncTUI(config=config)
tui.run()
@@ -0,0 +1,502 @@
"""
PraisonIO - Interactive CLI I/O handler inspired by Aider.
Key design principles:
1. prompt_toolkit used SYNCHRONOUSLY (never inside asyncio.run)
2. Rich for output rendering
3. MarkdownStream for flicker-free streaming
4. Clean separation of input/output concerns
"""
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Set
# Lazy imports for performance
_PROMPT_TOOLKIT_AVAILABLE = None
_RICH_AVAILABLE = None
def _check_prompt_toolkit():
global _PROMPT_TOOLKIT_AVAILABLE
if _PROMPT_TOOLKIT_AVAILABLE is None:
try:
import prompt_toolkit # noqa: F401
_PROMPT_TOOLKIT_AVAILABLE = True
except ImportError:
_PROMPT_TOOLKIT_AVAILABLE = False
return _PROMPT_TOOLKIT_AVAILABLE
def _check_rich():
global _RICH_AVAILABLE
if _RICH_AVAILABLE is None:
try:
import rich # noqa: F401
_RICH_AVAILABLE = True
except ImportError:
_RICH_AVAILABLE = False
return _RICH_AVAILABLE
@dataclass
class IOConfig:
"""Configuration for PraisonIO."""
# Appearance
pretty: bool = True
user_input_color: str = "#00cc00"
tool_output_color: str = "#0088ff"
tool_error_color: str = "#ff0000"
tool_warning_color: str = "#ffaa00"
assistant_color: str = "#00aaff"
# Input settings
multiline_mode: bool = False
vi_mode: bool = False
# History
input_history_file: Optional[str] = None
# Completion
enable_completions: bool = True
# Streaming
stream_delay: float = 0.05 # 20fps default
class PraisonCompleter:
"""Auto-completer for commands, files, and symbols."""
def __init__(self, commands: Optional[Dict[str, str]] = None):
self.commands = commands or {}
self.files: Set[str] = set()
self.symbols: Set[str] = set()
def add_files(self, files: List[str]) -> None:
"""Add files to completion."""
self.files.update(files)
def add_symbols(self, symbols: List[str]) -> None:
"""Add symbols to completion."""
self.symbols.update(symbols)
def get_completions(self, text: str, cursor_pos: int) -> List[tuple]:
"""Get completions for current input."""
before_cursor = text[:cursor_pos]
words = before_cursor.split()
current_word = words[-1] if words else ""
completions = []
# Slash commands
if current_word.startswith("/"):
cmd_part = current_word[1:].lower()
for cmd, desc in self.commands.items():
if cmd.lower().startswith(cmd_part):
completions.append((f"/{cmd}", desc))
# @ file mentions
elif current_word.startswith("@"):
file_part = current_word[1:].lower()
for f in self.files:
if file_part in f.lower():
completions.append((f"@{f}", "file"))
# General word completion
elif current_word:
word_lower = current_word.lower()
for symbol in self.symbols:
if symbol.lower().startswith(word_lower):
completions.append((symbol, "symbol"))
return completions[:20]
def create_prompt_toolkit_completer(self):
"""Create prompt_toolkit Completer."""
if not _check_prompt_toolkit():
return None
from prompt_toolkit.completion import Completer, Completion
outer = self
class PTCompleter(Completer):
def get_completions(self, document, complete_event):
text = document.text
cursor_pos = document.cursor_position
for completion, meta in outer.get_completions(text, cursor_pos):
words = text[:cursor_pos].split()
current_word = words[-1] if words else ""
start_pos = -len(current_word)
yield Completion(completion, start_position=start_pos, display_meta=meta)
return PTCompleter()
class MarkdownStream:
"""Flicker-free streaming markdown renderer.
Uses Rich Live with a sliding window approach:
- Stable lines print above the live area
- Unstable lines (last N) render in the live window
"""
def __init__(self, live_window: int = 6):
self.live_window = live_window
self.printed: List[str] = []
self.live = None
self._live_started = False
self.min_delay = 1.0 / 20 # 20fps
self.when = 0
def _render_to_lines(self, text: str) -> List[str]:
"""Render markdown to lines."""
if not _check_rich():
return text.splitlines(keepends=True)
import io
from rich.console import Console
from rich.markdown import Markdown
string_io = io.StringIO()
console = Console(file=string_io, force_terminal=True, width=100)
console.print(Markdown(text))
return string_io.getvalue().splitlines(keepends=True)
def update(self, text: str, final: bool = False) -> None:
"""Update the streaming display."""
if not _check_rich():
# Fallback: just print
print(text, end="" if not final else "\n", flush=True)
return
from rich.live import Live
from rich.text import Text
# Start live on first update
if not self._live_started:
self.live = Live(Text(""), refresh_per_second=20)
self.live.start()
self._live_started = True
# Throttle updates
now = time.time()
if not final and now - self.when < self.min_delay:
return
self.when = now
lines = self._render_to_lines(text)
num_lines = len(lines)
# Calculate stable lines
if not final:
num_lines -= self.live_window
if final or num_lines > 0:
num_printed = len(self.printed)
show_count = num_lines - num_printed
if show_count > 0:
show = lines[num_printed:num_lines]
show_text = "".join(show)
self.live.console.print(Text.from_ansi(show_text), end="")
self.printed = lines[:num_lines]
if final:
self.live.update(Text(""))
self.live.stop()
self.live = None
return
# Update live window
rest = lines[num_lines:]
rest_text = "".join(rest)
self.live.update(Text.from_ansi(rest_text))
def __del__(self):
if self.live:
try:
self.live.stop()
except Exception:
pass
class PraisonIO:
"""
Main I/O handler for PraisonAI interactive CLI.
Inspired by Aider's io.py - handles all terminal I/O with:
- Rich formatting for output
- prompt_toolkit for input (with completions, history)
- Streaming markdown rendering
"""
def __init__(self, config: Optional[IOConfig] = None):
self.config = config or IOConfig()
self.completer = PraisonCompleter()
self._console = None
self._prompt_session = None
self._history_file = None
# Setup history file
if self.config.input_history_file:
self._history_file = Path(self.config.input_history_file)
else:
self._history_file = Path.home() / ".praison" / "input_history"
self._history_file.parent.mkdir(parents=True, exist_ok=True)
@property
def console(self):
"""Lazy-load Rich console."""
if self._console is None and _check_rich():
from rich.console import Console
self._console = Console()
return self._console
def _create_prompt_session(self):
"""Create prompt_toolkit session."""
if not _check_prompt_toolkit():
return None
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.key_binding import KeyBindings
kb = KeyBindings()
# Ctrl+C to cancel current input
@kb.add("c-c")
def _(event):
event.current_buffer.reset()
# Alt+Enter for newline in multiline mode
@kb.add("escape", "enter")
def _(event):
event.current_buffer.insert_text("\n")
# Enter to submit (unless in multiline mode)
@kb.add("enter")
def _(event):
if self.config.multiline_mode:
event.current_buffer.insert_text("\n")
else:
event.current_buffer.validate_and_handle()
# Ctrl+D to submit in multiline mode
@kb.add("c-d")
def _(event):
event.current_buffer.validate_and_handle()
session_kwargs = {
"history": FileHistory(str(self._history_file)),
"auto_suggest": AutoSuggestFromHistory(),
"key_bindings": kb,
"multiline": self.config.multiline_mode,
}
if self.config.enable_completions:
completer = self.completer.create_prompt_toolkit_completer()
if completer:
from prompt_toolkit.completion import ThreadedCompleter
session_kwargs["completer"] = ThreadedCompleter(completer)
if self.config.vi_mode:
from prompt_toolkit.enums import EditingMode
session_kwargs["editing_mode"] = EditingMode.VI
return PromptSession(**session_kwargs)
def _get_style(self):
"""Get prompt_toolkit style."""
if not _check_prompt_toolkit() or not self.config.pretty:
return None
from prompt_toolkit.styles import Style
style_dict = {}
if self.config.user_input_color:
style_dict[""] = self.config.user_input_color
return Style.from_dict(style_dict) if style_dict else None
def get_input(self, prompt: str = "You: ") -> str:
"""
Get input from user.
This is SYNCHRONOUS - never call from inside asyncio.run().
"""
if self._prompt_session is None:
self._prompt_session = self._create_prompt_session()
if self._prompt_session:
try:
style = self._get_style()
return self._prompt_session.prompt(prompt, style=style)
except KeyboardInterrupt:
return ""
except EOFError:
return "/exit"
else:
# Fallback to basic input
try:
return input(prompt)
except KeyboardInterrupt:
return ""
except EOFError:
return "/exit"
def rule(self) -> None:
"""Print a horizontal rule."""
if self.console and self.config.pretty:
self.console.rule(style=self.config.user_input_color)
else:
print()
def print_welcome(self, model: str = "gpt-4o-mini", tools_count: int = 0, session_id: str = "") -> None:
"""Print welcome message."""
if self.console and self.config.pretty:
from rich.panel import Panel
from rich.text import Text
welcome = Text()
welcome.append("PraisonAI Interactive Mode\n", style="bold cyan")
welcome.append("Type your prompt, use ", style="dim")
welcome.append("/help", style="bold green")
welcome.append(" for commands, ", style="dim")
welcome.append("/exit", style="bold yellow")
welcome.append(" to quit\n", style="dim")
welcome.append(f"Model: {model}", style="dim cyan")
if tools_count:
welcome.append(f" | Tools: {tools_count}", style="dim")
if session_id:
welcome.append(f" | Session: {session_id[:8]}", style="dim")
self.console.print(Panel(welcome, border_style="blue", padding=(0, 1)))
else:
print("PraisonAI Interactive Mode")
print("Type your prompt, use /help for commands, /exit to quit")
print(f"Model: {model} | Tools: {tools_count}")
def print_user_input(self, text: str) -> None:
"""Echo user input with styling."""
if self.console and self.config.pretty:
from rich.text import Text
self.console.print(Text(text, style=self.config.user_input_color))
def print_assistant_start(self) -> None:
"""Print assistant response start marker."""
if self.console and self.config.pretty:
self.console.print("\n[bold cyan]Assistant:[/bold cyan]")
else:
print("\nAssistant:")
def print_assistant_response(self, response: str) -> None:
"""Print assistant response with markdown formatting."""
if self.console and self.config.pretty:
from rich.markdown import Markdown
from rich.panel import Panel
try:
md = Markdown(response)
self.console.print(Panel(md, border_style="cyan", padding=(0, 1)))
except Exception:
self.console.print(response)
else:
print(response)
def stream_response(self, text: str, final: bool = False) -> MarkdownStream:
"""Get or update streaming response."""
if not hasattr(self, '_current_stream') or self._current_stream is None:
self._current_stream = MarkdownStream()
self._current_stream.update(text, final=final)
if final:
self._current_stream = None
return self._current_stream
def tool_output(self, message: str) -> None:
"""Print tool output."""
if self.console and self.config.pretty:
self.console.print(f"[{self.config.tool_output_color}]⚙ {message}[/]")
else:
print(f"⚙ {message}")
def tool_error(self, message: str) -> None:
"""Print tool error."""
if self.console and self.config.pretty:
self.console.print(f"[{self.config.tool_error_color}]✗ {message}[/]")
else:
print(f"✗ {message}", file=sys.stderr)
def tool_warning(self, message: str) -> None:
"""Print tool warning."""
if self.console and self.config.pretty:
self.console.print(f"[{self.config.tool_warning_color}]⚠ {message}[/]")
else:
print(f"⚠ {message}", file=sys.stderr)
def info(self, message: str) -> None:
"""Print info message."""
if self.console and self.config.pretty:
self.console.print(f"[dim]ℹ {message}[/dim]")
else:
print(f"ℹ {message}")
def success(self, message: str) -> None:
"""Print success message."""
if self.console and self.config.pretty:
self.console.print(f"[green]✓ {message}[/green]")
else:
print(f"✓ {message}")
def print_help(self, commands: Dict[str, str]) -> None:
"""Print help for available commands."""
if self.console and self.config.pretty:
from rich.table import Table
table = Table(title="Available Commands", border_style="blue")
table.add_column("Command", style="green")
table.add_column("Description", style="dim")
for cmd, desc in sorted(commands.items()):
table.add_row(f"/{cmd}", desc)
self.console.print(table)
else:
print("\nAvailable Commands:")
for cmd, desc in sorted(commands.items()):
print(f" /{cmd:<12} - {desc}")
def confirm(self, message: str, default: bool = False) -> bool:
"""Ask for confirmation."""
suffix = " [Y/n]: " if default else " [y/N]: "
if self.console and self.config.pretty:
self.console.print(f"[yellow]? {message}[/yellow]", end="")
try:
response = input(suffix).strip().lower()
if not response:
return default
return response in ("y", "yes")
except (EOFError, KeyboardInterrupt):
return False
def add_commands(self, commands: Dict[str, str]) -> None:
"""Add commands for completion."""
self.completer.commands.update(commands)
def add_files(self, files: List[str]) -> None:
"""Add files for completion."""
self.completer.add_files(files)
def add_symbols(self, symbols: List[str]) -> None:
"""Add symbols for completion."""
self.completer.add_symbols(symbols)
@@ -0,0 +1,297 @@
"""
Interactive REPL for PraisonAI.
This is the main entry point for interactive chat mode.
Uses PraisonIO for input/output (prompt_toolkit is used SYNCHRONOUSLY).
"""
import logging
from dataclasses import dataclass
from typing import Dict, List, Optional
from .praison_io import PraisonIO, IOConfig
logger = logging.getLogger(__name__)
@dataclass
class REPLConfig:
"""Configuration for the interactive REPL."""
model: str = "gpt-4o-mini"
verbose: bool = False
memory: bool = False
tools: Optional[List] = None
session_id: Optional[str] = None
continue_session: bool = False
workspace: Optional[str] = None
autonomy: bool = True
# Default slash commands
DEFAULT_COMMANDS: Dict[str, str] = {
"help": "Show available commands",
"exit": "Exit interactive mode",
"quit": "Exit interactive mode",
"clear": "Clear conversation history",
"session": "Show current session info",
"model": "Show or change model",
"cost": "Show token usage and cost",
"history": "Show conversation history",
"export": "Export conversation to file",
"compact": "Toggle compact output mode",
"multiline": "Toggle multiline input mode",
}
class InteractiveREPL:
"""
Interactive REPL for PraisonAI chat.
Key design:
- Uses PraisonIO for all I/O (prompt_toolkit used synchronously)
- Agent execution is async but input is sync
- Clean separation between I/O and agent logic
"""
def __init__(
self,
config: Optional[REPLConfig] = None,
io_config: Optional[IOConfig] = None,
):
self.config = config or REPLConfig()
self.io = PraisonIO(io_config)
self._agent = None
self._running = False
self._conversation_history: List[Dict] = []
self._session_id: Optional[str] = None
self._total_tokens = 0
self._total_cost = 0.0
# Setup commands
self.io.add_commands(DEFAULT_COMMANDS)
def _get_agent(self):
"""Lazy-load the agent."""
if self._agent is None:
try:
from praisonaiagents import Agent
agent_kwargs = {
"name": "InteractiveAgent",
"role": "Assistant",
"goal": "Help the user with their requests",
"instructions": "You are a helpful AI assistant. Be concise and helpful.",
}
if self.config.model:
agent_kwargs["llm"] = self.config.model
if self.config.memory:
agent_kwargs["memory"] = True
if self.config.tools:
agent_kwargs["tools"] = self.config.tools
self._agent = Agent(**agent_kwargs)
except ImportError as e:
self.io.tool_error(f"Failed to import praisonaiagents: {e}")
raise
return self._agent
def _handle_command(self, command: str) -> bool:
"""
Handle slash commands.
Returns True if command was handled, False otherwise.
"""
parts = command.split(maxsplit=1)
cmd = parts[0].lower().lstrip("/")
args = parts[1] if len(parts) > 1 else ""
if cmd in ("exit", "quit", "q"):
self._running = False
self.io.info("Goodbye!")
return True
elif cmd == "help":
self.io.print_help(DEFAULT_COMMANDS)
return True
elif cmd == "clear":
self._conversation_history.clear()
self.io.success("Conversation cleared.")
return True
elif cmd == "session":
if self._session_id:
self.io.info(f"Session: {self._session_id}")
else:
self.io.info("No active session")
return True
elif cmd == "model":
if args:
self.config.model = args
self._agent = None # Reset agent to use new model
self.io.success(f"Model changed to: {args}")
else:
self.io.info(f"Current model: {self.config.model}")
return True
elif cmd == "cost":
self.io.info(f"Total tokens: {self._total_tokens}")
self.io.info(f"Estimated cost: ${self._total_cost:.4f}")
return True
elif cmd == "history":
if not self._conversation_history:
self.io.info("No conversation history")
else:
for i, msg in enumerate(self._conversation_history[-10:], 1):
role = msg.get("role", "unknown")
content = msg.get("content", "")[:100]
self.io.info(f"{i}. [{role}] {content}...")
return True
elif cmd == "export":
filename = args or "conversation.txt"
try:
with open(filename, "w") as f:
for msg in self._conversation_history:
f.write(f"[{msg.get('role', 'unknown')}]\n")
f.write(f"{msg.get('content', '')}\n\n")
self.io.success(f"Exported to {filename}")
except Exception as e:
self.io.tool_error(f"Export failed: {e}")
return True
elif cmd == "compact":
self.io.config.pretty = not self.io.config.pretty
mode = "disabled" if self.io.config.pretty else "enabled"
self.io.info(f"Compact mode {mode}")
return True
elif cmd == "multiline":
self.io.config.multiline_mode = not self.io.config.multiline_mode
mode = "enabled" if self.io.config.multiline_mode else "disabled"
self.io.info(f"Multiline mode {mode}")
return True
else:
self.io.tool_warning(f"Unknown command: /{cmd}")
self.io.info("Type /help for available commands")
return True
def _execute_prompt(self, prompt: str) -> Optional[str]:
"""Execute a prompt and return the response."""
try:
agent = self._get_agent()
# Add to history
self._conversation_history.append({
"role": "user",
"content": prompt
})
# Execute
response = agent.start(prompt)
# Add response to history
if response:
self._conversation_history.append({
"role": "assistant",
"content": str(response)
})
return str(response) if response else None
except Exception as e:
logger.exception("Error executing prompt")
self.io.tool_error(f"Error: {e}")
return None
def run(self) -> None:
"""
Run the interactive REPL.
This is the main entry point. It runs synchronously,
using prompt_toolkit for input (which must be sync).
"""
self._running = True
# Get tools count for display
tools_count = len(self.config.tools) if self.config.tools else 0
# Generate session ID
import uuid
self._session_id = self.config.session_id or str(uuid.uuid4())
# Print welcome
self.io.print_welcome(
model=self.config.model,
tools_count=tools_count,
session_id=self._session_id
)
while self._running:
try:
# Get input (SYNC - this is the key!)
user_input = self.io.get_input("You: ").strip()
if not user_input:
continue
# Handle slash commands
if user_input.startswith("/"):
self._handle_command(user_input)
continue
# Show thinking indicator
self.io.print_assistant_start()
# Execute prompt
response = self._execute_prompt(user_input)
# Display response
if response:
self.io.print_assistant_response(response)
except KeyboardInterrupt:
self.io.info("\nUse /exit to quit")
except EOFError:
self._running = False
self.io.info("Goodbye!")
except Exception as e:
logger.exception("Error in REPL loop")
self.io.tool_error(f"Error: {e}")
def run_single(self, prompt: str) -> Optional[str]:
"""Run a single prompt and return the response."""
return self._execute_prompt(prompt)
def start_interactive(
model: str = "gpt-4o-mini",
verbose: bool = False,
memory: bool = False,
tools: Optional[List] = None,
session_id: Optional[str] = None,
**kwargs
) -> None:
"""
Start the interactive REPL.
This is the main entry point for `praisonai chat` and `praisonai` (no args).
"""
config = REPLConfig(
model=model,
verbose=verbose,
memory=memory,
tools=tools,
session_id=session_id,
)
repl = InteractiveREPL(config=config)
repl.run()
@@ -0,0 +1,490 @@
"""
PraisonAI Split-Pane TUI Application.
Full-screen terminal UI with:
- Fixed bottom input pane (always visible)
- Scrollable top output pane
- Direct response (no streaming - faster)
- Activity indicators showing agent status
Uses prompt_toolkit Application for true split-pane layout.
"""
import shutil
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
from datetime import datetime
# ============================================================================
# ASCII Art Logo - "Praison AI" branding
# ============================================================================
LOGO = r"""
██████╗ ██████╗ █████╗ ██╗███████╗ ██████╗ ███╗ ██╗ █████╗ ██╗
██╔══██╗██╔══██╗██╔══██╗██║██╔════╝██╔═══██╗████╗ ██║ ██╔══██╗██║
██████╔╝██████╔╝███████║██║███████╗██║ ██║██╔██╗ ██║ ███████║██║
██╔═══╝ ██╔══██╗██╔══██║██║╚════██║██║ ██║██║╚██╗██║ ██╔══██║██║
██║ ██║ ██║██║ ██║██║███████║╚██████╔╝██║ ╚████║ ██║ ██║██║
╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝
"""
LOGO_SMALL = r"""
╔═╗┬─┐┌─┐┬┌─┐┌─┐┌┐┌ ╔═╗╦
╠═╝├┬┘├─┤│└─┐│ ││││ ╠═╣║
╩ ┴└─┴ ┴┴└─┘└─┘┘└┘ ╩ ╩╩
"""
LOGO_MINIMAL = "▶ Praison AI"
def get_logo(width: int = 80) -> str:
"""Get appropriate logo based on terminal width."""
if width >= 75:
return LOGO
elif width >= 40:
return LOGO_SMALL
else:
return LOGO_MINIMAL
def get_version() -> str:
"""Get PraisonAI version."""
try:
from praisonai import __version__
return __version__
except Exception:
return "1.0.0"
# ============================================================================
# Configuration
# ============================================================================
@dataclass
class SplitTUIConfig:
"""Configuration for the Split TUI application."""
model: str = "gpt-4o-mini"
show_logo: bool = True
show_status_bar: bool = True
session_id: Optional[str] = None
workspace: Optional[str] = None
history_file: Optional[str] = None
# ============================================================================
# Message Types
# ============================================================================
@dataclass
class ChatMessage:
"""A chat message."""
role: str # "user", "assistant", "system", "status"
content: str
timestamp: datetime = field(default_factory=datetime.now)
# ============================================================================
# Split TUI Application
# ============================================================================
class SplitTUI:
"""
Split-pane TUI with fixed bottom input and scrollable top output.
Key features:
- Input box ALWAYS visible at bottom
- Output scrolls above independently
- Direct response (no streaming) for speed
- Activity indicators during processing
"""
def __init__(self, config: Optional[SplitTUIConfig] = None):
self.config = config or SplitTUIConfig()
self.messages: List[ChatMessage] = []
self._running = False
self._agent = None
self._processing = False
self._status_text = ""
# Terminal size
self.term_width, self.term_height = shutil.get_terminal_size((80, 24))
# Session ID
import uuid
self.session_id = self.config.session_id or str(uuid.uuid4())[:8]
def _get_agent(self):
"""Lazy-load the agent with verbose=False for no streaming."""
if self._agent is None:
try:
from praisonaiagents import Agent
self._agent = Agent(
name="Praison",
role="AI Assistant",
goal="Help the user with their requests",
instructions="You are Praison AI, a helpful assistant. Be concise and helpful.",
llm=self.config.model,
)
except ImportError as e:
raise RuntimeError(f"Failed to import praisonaiagents: {e}")
return self._agent
def _format_output(self) -> str:
"""Format all messages for the output pane."""
lines = []
# Logo
if self.config.show_logo and not self.messages:
logo = get_logo(self.term_width)
lines.append(logo.strip())
lines.append("")
lines.append(f" v{get_version()} · Model: {self.config.model}")
lines.append("")
lines.append(" Type your message and press Enter. Use /help for commands.")
lines.append("")
# Messages
for msg in self.messages:
if msg.role == "user":
lines.append(f"› {msg.content}")
lines.append("")
elif msg.role == "assistant":
lines.append(f"● {msg.content}")
lines.append("")
elif msg.role == "system":
lines.append(f" {msg.content}")
lines.append("")
elif msg.role == "status":
lines.append(f" ⏳ {msg.content}")
# Processing indicator
if self._processing:
lines.append(f" ⏳ {self._status_text}")
return "\n".join(lines)
def _handle_command(self, command: str) -> bool:
"""Handle slash commands. Returns True if handled."""
parts = command.split(maxsplit=1)
cmd = parts[0].lower().lstrip("/")
args = parts[1] if len(parts) > 1 else ""
if cmd in ("exit", "quit", "q"):
self._running = False
return True
elif cmd == "help":
help_text = """Available Commands:
/help Show this help
/exit, /quit Exit Praison AI
/clear Clear conversation
/model [name] Show or change model
/new Start new conversation"""
self.messages.append(ChatMessage(role="system", content=help_text))
return True
elif cmd == "clear":
self.messages.clear()
return True
elif cmd == "new":
self.messages.clear()
import uuid
self.session_id = str(uuid.uuid4())[:8]
self.messages.append(ChatMessage(role="system", content=f"New session: {self.session_id}"))
return True
elif cmd == "model":
if args:
self.config.model = args
self._agent = None
self.messages.append(ChatMessage(role="system", content=f"Model changed to: {args}"))
else:
self.messages.append(ChatMessage(role="system", content=f"Current model: {self.config.model}"))
return True
else:
self.messages.append(ChatMessage(role="system", content=f"Unknown command: /{cmd}. Use /help for available commands."))
return True
def _execute_prompt(self, prompt: str) -> Optional[str]:
"""Execute a prompt and return the response (direct, no streaming)."""
import sys
import io
try:
agent = self._get_agent()
# Suppress agent's Rich output by redirecting stdout/stderr
old_stdout = sys.stdout
old_stderr = sys.stderr
sys.stdout = io.StringIO()
sys.stderr = io.StringIO()
try:
response = agent.start(prompt)
finally:
# Restore stdout/stderr
sys.stdout = old_stdout
sys.stderr = old_stderr
return str(response) if response else None
except Exception as e:
return f"Error: {e}"
def _run_with_status(self, prompt: str) -> Optional[str]:
"""Run prompt with status updates."""
self._processing = True
self._status_text = "Praison AI is thinking..."
# Execute in background
result = [None]
error = [None]
def execute():
try:
result[0] = self._execute_prompt(prompt)
except Exception as e:
error[0] = str(e)
thread = threading.Thread(target=execute)
thread.start()
# Update status while waiting
start_time = time.time()
spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
idx = 0
while thread.is_alive():
elapsed = time.time() - start_time
self._status_text = f"{spinner[idx]} Praison AI is thinking... ({elapsed:.1f}s)"
idx = (idx + 1) % len(spinner)
time.sleep(0.1)
thread.join()
self._processing = False
self._status_text = ""
if error[0]:
return f"Error: {error[0]}"
return result[0]
def run(self) -> None:
"""Run the split-pane TUI."""
self._running = True
# Try to use prompt_toolkit for better experience
try:
self._run_with_prompt_toolkit()
except ImportError:
# Fallback to simple mode
self._run_simple()
def _run_simple(self) -> None:
"""Simple fallback mode without prompt_toolkit."""
# Print welcome
print(self._format_output())
while self._running:
try:
# Status bar
if self.config.show_status_bar:
status = f" ? for help | {self.config.model} | Session: {self.session_id} "
print(f"\033[90m{status}\033[0m")
# Get input
user_input = input("› ").strip()
if not user_input:
continue
# Handle commands
if user_input.startswith("/"):
self._handle_command(user_input)
# Print system messages
for msg in self.messages:
if msg.role == "system":
print(f" {msg.content}")
self.messages = [m for m in self.messages if m.role != "system"]
continue
# Add user message
self.messages.append(ChatMessage(role="user", content=user_input))
# Execute with status
print(" ⏳ Praison AI is thinking...")
response = self._execute_prompt(user_input)
if response:
self.messages.append(ChatMessage(role="assistant", content=response))
print(f"\n● {response}\n")
except KeyboardInterrupt:
print("\n Use /exit to quit")
except EOFError:
self._running = False
print("\n Goodbye from Praison AI!")
def _run_with_prompt_toolkit(self) -> None:
"""Run with prompt_toolkit for split-pane layout."""
from prompt_toolkit import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.layout.containers import HSplit, VSplit, Window
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.styles import Style
from prompt_toolkit.history import FileHistory
# Setup history
history_file = self.config.history_file or str(Path.home() / ".praison" / "history")
Path(history_file).parent.mkdir(parents=True, exist_ok=True)
# Output buffer (read-only, for displaying conversation)
output_text = [self._format_output()]
def get_output_text():
return output_text[0]
# Input buffer
input_buffer = Buffer(
multiline=False,
history=FileHistory(history_file),
)
# Key bindings
kb = KeyBindings()
@kb.add("enter")
def handle_enter(event):
user_input = input_buffer.text.strip()
if not user_input:
return
input_buffer.reset()
# Handle commands
if user_input.startswith("/"):
self._handle_command(user_input)
output_text[0] = self._format_output()
return
# Add user message
self.messages.append(ChatMessage(role="user", content=user_input))
# Show processing status
self.messages.append(ChatMessage(role="status", content="Praison AI is thinking..."))
output_text[0] = self._format_output()
# Execute (this blocks but that's OK for direct response)
response = self._execute_prompt(user_input)
# Remove status message
self.messages = [m for m in self.messages if m.role != "status"]
if response:
self.messages.append(ChatMessage(role="assistant", content=response))
output_text[0] = self._format_output()
@kb.add("c-c")
def handle_ctrl_c(event):
input_buffer.reset()
@kb.add("c-d")
def handle_ctrl_d(event):
if not input_buffer.text:
self._running = False
event.app.exit()
@kb.add("c-q")
def handle_ctrl_q(event):
self._running = False
event.app.exit()
# Style
style = Style.from_dict({
"output-area": "bg:#0d0d0d",
"input-area": "bg:#1a1a1a",
"status-bar": "bg:#333333 #888888",
"prompt": "#00aaff bold",
})
# Status bar text
def get_status_bar():
left = "? for help"
center = self.config.model
right = f"Session: {self.session_id}"
return f" {left} | {center} | {right} "
# Layout: Output (top, scrollable) + Status bar + Input (bottom, fixed)
layout = Layout(
HSplit([
# Output area (takes remaining space)
Window(
content=FormattedTextControl(get_output_text),
wrap_lines=True,
style="class:output-area",
),
# Status bar (1 line)
Window(
content=FormattedTextControl(get_status_bar),
height=1,
style="class:status-bar",
),
# Input area (fixed at bottom, 3 lines for visibility)
HSplit([
Window(height=1), # Spacer
VSplit([
Window(
content=FormattedTextControl([("class:prompt", "› ")]),
width=2,
),
Window(
content=BufferControl(buffer=input_buffer),
wrap_lines=True,
),
]),
Window(height=1), # Spacer
], height=3, style="class:input-area"),
])
)
# Create application
app = Application(
layout=layout,
key_bindings=kb,
style=style,
full_screen=True,
mouse_support=True,
)
# Run
app.run()
print("\n Goodbye from Praison AI!")
def run_single(self, prompt: str) -> Optional[str]:
"""Run a single prompt (non-interactive)."""
return self._execute_prompt(prompt)
def start_split_tui(
model: str = "gpt-4o-mini",
show_logo: bool = True,
**kwargs
) -> None:
"""Start the split-pane TUI application."""
config = SplitTUIConfig(
model=model,
show_logo=show_logo,
)
tui = SplitTUI(config=config)
tui.run()
@@ -0,0 +1,510 @@
"""
PraisonAI Interactive TUI Application.
Full-screen terminal UI with:
- Sticky bottom input box (like Aider/Codex/Claude Code)
- Scrollable output area above
- ASCII art logo banner
- Status bar with model/context info
- Clean, spacious design inspired by Claude Code
Uses prompt_toolkit for the full-screen application.
"""
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
from datetime import datetime
# Lazy imports for performance
_PT_AVAILABLE = None
_RICH_AVAILABLE = None
def _check_prompt_toolkit():
global _PT_AVAILABLE
if _PT_AVAILABLE is None:
try:
import prompt_toolkit # noqa: F401
_PT_AVAILABLE = True
except ImportError:
_PT_AVAILABLE = False
return _PT_AVAILABLE
def _check_rich():
global _RICH_AVAILABLE
if _RICH_AVAILABLE is None:
try:
import rich # noqa: F401
_RICH_AVAILABLE = True
except ImportError:
_RICH_AVAILABLE = False
return _RICH_AVAILABLE
# ============================================================================
# ASCII Art Logo
# ============================================================================
# PraisonAI ASCII art logo - unique gradient style
LOGO_LARGE = r"""
██████╗ ██████╗ █████╗ ██╗███████╗ ██████╗ ███╗ ██╗ █████╗ ██╗
██╔══██╗██╔══██╗██╔══██╗██║██╔════╝██╔═══██╗████╗ ██║ ██╔══██╗██║
██████╔╝██████╔╝███████║██║███████╗██║ ██║██╔██╗ ██║ ███████║██║
██╔═══╝ ██╔══██╗██╔══██║██║╚════██║██║ ██║██║╚██╗██║ ██╔══██║██║
██║ ██║ ██║██║ ██║██║███████║╚██████╔╝██║ ╚████║ ██║ ██║██║
╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝
"""
LOGO_MEDIUM = r"""
████████╗ ████████╗ ██████╗ ██╗███████╗ ██████╗ ███╗ ██╗
██╔═══██║ ██╔═══██║ ██╔══██╗██║██╔════╝██╔═══██╗████╗ ██║
████████║ ████████║ ███████║██║███████╗██║ ██║██╔██╗ ██║
██╔═════╝ ██╔═══██║ ██╔══██║██║╚════██║██║ ██║██║╚██╗██║
██║ ██║ ██║ ██║ ██║██║███████║╚██████╔╝██║ ╚████║
╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═══╝
"""
LOGO_SMALL = r"""
██████╗ ██████╗ █████╗ ██╗███████╗ ██████╗ ███╗ ██╗
██╔══██╗██╔══██╗██╔══██╗██║██╔════╝██╔═══██╗████╗ ██║
██████╔╝██████╔╝███████║██║███████╗██║ ██║██╔██╗ ██║
██╔═══╝ ██╔══██╗██╔══██║██║╚════██║██║ ██║██║╚██╗██║
██║ ██║ ██║██║ ██║██║███████║╚██████╔╝██║ ╚████║
╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═══╝
"""
LOGO_TINY = r"""
╔═╗┬─┐┌─┐┬┌─┐┌─┐┌┐┌ ╔═╗╦
╠═╝├┬┘├─┤│└─┐│ ││││ ╠═╣║
╩ ┴└─┴ ┴┴└─┘└─┘┘└┘ ╩ ╩╩
"""
LOGO_MINIMAL = "▶ PraisonAI"
def get_logo(width: int = 80) -> str:
"""Get appropriate logo based on terminal width."""
if width >= 80:
return LOGO_SMALL
elif width >= 60:
return LOGO_TINY
else:
return LOGO_MINIMAL
def get_version() -> str:
"""Get PraisonAI version."""
try:
from praisonai import __version__
return __version__
except Exception:
return "1.0.0"
# ============================================================================
# Color Scheme
# ============================================================================
@dataclass
class ColorScheme:
"""Color scheme for the TUI."""
# Primary colors
primary: str = "#00aaff" # Cyan-blue (PraisonAI brand)
secondary: str = "#888888" # Gray
accent: str = "#00cc00" # Green
# Text colors
text: str = "#ffffff" # White
text_dim: str = "#666666" # Dim gray
text_muted: str = "#444444" # Very dim
# Status colors
success: str = "#00cc00" # Green
warning: str = "#ffaa00" # Orange
error: str = "#ff4444" # Red
info: str = "#00aaff" # Blue
# UI elements
border: str = "#333333" # Dark gray
input_bg: str = "#1a1a1a" # Very dark
output_bg: str = "#0d0d0d" # Almost black
DEFAULT_COLORS = ColorScheme()
# ============================================================================
# TUI Configuration
# ============================================================================
@dataclass
class TUIConfig:
"""Configuration for the TUI application."""
# Model settings
model: str = "gpt-4o-mini"
# Display settings
show_logo: bool = True
show_tips: bool = True
show_status_bar: bool = True
compact_mode: bool = False
# Colors
colors: ColorScheme = field(default_factory=ColorScheme)
# Input settings
multiline: bool = False
vi_mode: bool = False
# History
history_file: Optional[str] = None
# Session
session_id: Optional[str] = None
workspace: Optional[str] = None
# ============================================================================
# Message Types
# ============================================================================
@dataclass
class Message:
"""A chat message."""
role: str # "user", "assistant", "system", "tool"
content: str
timestamp: datetime = field(default_factory=datetime.now)
metadata: Dict = field(default_factory=dict)
# ============================================================================
# TUI Application
# ============================================================================
class PraisonTUI:
"""
Full-screen TUI application for PraisonAI.
Features:
- Sticky bottom input box
- Scrollable output area
- ASCII art logo
- Status bar
- Clean, spacious design
"""
def __init__(self, config: Optional[TUIConfig] = None):
self.config = config or TUIConfig()
self.messages: List[Message] = []
self._running = False
self._agent = None
self._total_tokens = 0
self._total_cost = 0.0
# Get terminal size
self.term_width, self.term_height = shutil.get_terminal_size((80, 24))
# Generate session ID
import uuid
self.session_id = self.config.session_id or str(uuid.uuid4())[:8]
def _get_agent(self):
"""Lazy-load the agent."""
if self._agent is None:
try:
from praisonaiagents import Agent
self._agent = Agent(
name="PraisonAgent",
role="Assistant",
goal="Help the user with their requests",
instructions="You are a helpful AI assistant. Be concise and helpful.",
llm=self.config.model,
)
except ImportError as e:
raise RuntimeError(f"Failed to import praisonaiagents: {e}")
return self._agent
def _render_logo(self) -> str:
"""Render the ASCII logo with colors."""
if not self.config.show_logo:
return ""
logo = get_logo(self.term_width)
version = get_version()
info_line = f"v{version} · Model: {self.config.model} · Session: {self.session_id}"
return logo.strip() + "\n" + info_line
def _render_welcome(self) -> str:
"""Render welcome message."""
lines = []
# Logo
if self.config.show_logo:
lines.append(self._render_logo())
lines.append("")
# Tips
if self.config.show_tips:
tips = [
"Type your message and press Enter to send",
"Use /help for commands, /exit to quit",
"Press ? for keyboard shortcuts",
]
lines.append(" " + tips[0])
lines.append("")
return "\n".join(lines)
def _render_status_bar(self) -> str:
"""Render the status bar."""
if not self.config.show_status_bar:
return ""
left = f"? for shortcuts"
center = f"{self.config.model}"
right = f"Session: {self.session_id}"
# Calculate spacing
total_len = len(left) + len(center) + len(right)
available = self.term_width - total_len - 4
if available > 0:
left_space = available // 2
right_space = available - left_space
return f" {left}{' ' * left_space}{center}{' ' * right_space}{right} "
else:
return f" {center} "
def _format_message(self, msg: Message) -> str:
"""Format a message for display."""
if msg.role == "user":
prefix = "› "
return f"{prefix}{msg.content}"
elif msg.role == "assistant":
prefix = "● "
return f"{prefix}{msg.content}"
elif msg.role == "tool":
prefix = "⚙ "
return f"{prefix}{msg.content}"
else:
return msg.content
def _handle_command(self, command: str) -> bool:
"""Handle slash commands. Returns True if handled."""
parts = command.split(maxsplit=1)
cmd = parts[0].lower().lstrip("/")
args = parts[1] if len(parts) > 1 else ""
if cmd in ("exit", "quit", "q"):
self._running = False
return True
elif cmd == "help":
help_text = """
Available Commands:
/help Show this help
/exit, /quit Exit interactive mode
/clear Clear conversation
/model [name] Show or change model
/cost Show token usage
/compact Toggle compact mode
/multiline Toggle multiline input
"""
self.messages.append(Message(role="system", content=help_text.strip()))
return True
elif cmd == "clear":
self.messages.clear()
return True
elif cmd == "model":
if args:
self.config.model = args
self._agent = None
self.messages.append(Message(role="system", content=f"Model changed to: {args}"))
else:
self.messages.append(Message(role="system", content=f"Current model: {self.config.model}"))
return True
elif cmd == "cost":
self.messages.append(Message(
role="system",
content=f"Tokens: {self._total_tokens} | Cost: ${self._total_cost:.4f}"
))
return True
elif cmd == "compact":
self.config.compact_mode = not self.config.compact_mode
mode = "enabled" if self.config.compact_mode else "disabled"
self.messages.append(Message(role="system", content=f"Compact mode {mode}"))
return True
elif cmd == "multiline":
self.config.multiline = not self.config.multiline
mode = "enabled" if self.config.multiline else "disabled"
self.messages.append(Message(role="system", content=f"Multiline mode {mode}"))
return True
else:
self.messages.append(Message(role="system", content=f"Unknown command: /{cmd}"))
return True
def _execute_prompt(self, prompt: str) -> Optional[str]:
"""Execute a prompt and return the response."""
try:
agent = self._get_agent()
response = agent.start(prompt)
return str(response) if response else None
except Exception as e:
return f"Error: {e}"
def run(self) -> None:
"""Run the TUI application."""
self._running = True
# Print welcome
print(self._render_welcome())
# Setup history
history_file = self.config.history_file or str(Path.home() / ".praison" / "history")
Path(history_file).parent.mkdir(parents=True, exist_ok=True)
# Create prompt session
if _check_prompt_toolkit():
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.styles import Style
# Key bindings
kb = KeyBindings()
@kb.add("c-c")
def _(event):
event.current_buffer.reset()
@kb.add("c-d")
def _(event):
if not event.current_buffer.text:
event.app.exit()
# Style
style = Style.from_dict({
"": self.config.colors.accent,
"prompt": self.config.colors.primary,
})
session = PromptSession(
history=FileHistory(history_file),
auto_suggest=AutoSuggestFromHistory(),
key_bindings=kb,
style=style,
multiline=self.config.multiline,
)
def get_input():
return session.prompt(
[("class:prompt", "› ")],
placeholder="Type your message or @path/to/file"
)
else:
def get_input():
return input("› ")
# Main loop
while self._running:
try:
# Print status bar
if self.config.show_status_bar and not self.config.compact_mode:
status = self._render_status_bar()
if _check_rich():
from rich.console import Console
Console().print(f"[dim]{status}[/dim]")
else:
print(f"\033[90m{status}\033[0m")
# Get input
user_input = get_input().strip()
if not user_input:
continue
# Handle commands
if user_input.startswith("/"):
self._handle_command(user_input)
# Print system messages
for msg in self.messages:
if msg.role == "system":
print(self._format_message(msg))
self.messages = [m for m in self.messages if m.role != "system"]
continue
# Add user message
self.messages.append(Message(role="user", content=user_input))
# Show thinking indicator
if _check_rich():
from rich.console import Console
Console().print("[dim]Thinking...[/dim]")
# Execute
response = self._execute_prompt(user_input)
if response:
self.messages.append(Message(role="assistant", content=response))
# Display response
if _check_rich():
from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel
console = Console()
try:
md = Markdown(response)
console.print(Panel(md, border_style="cyan", padding=(0, 1)))
except Exception:
console.print(f"● {response}")
else:
print(f"● {response}")
print() # Spacing
except KeyboardInterrupt:
print("\nUse /exit to quit")
except EOFError:
self._running = False
print("\nGoodbye!")
except Exception as e:
print(f"Error: {e}")
print("Goodbye!")
def run_single(self, prompt: str) -> Optional[str]:
"""Run a single prompt."""
return self._execute_prompt(prompt)
def start_tui(
model: str = "gpt-4o-mini",
show_logo: bool = True,
compact: bool = False,
**kwargs
) -> None:
"""Start the TUI application."""
config = TUIConfig(
model=model,
show_logo=show_logo,
compact_mode=compact,
)
tui = PraisonTUI(config=config)
tui.run()
+2
View File
@@ -34,6 +34,8 @@ TYPER_COMMANDS = {
'memory', 'workflow', 'tools', 'knowledge', 'deploy', 'agents', 'skills',
'eval', 'templates', 'recipe', 'todo', 'docs', 'commit', 'hooks', 'rules',
'registry', 'package', 'endpoints',
# Standardisation commands
'standardise', 'standardize',
}
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==3.7.2 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==3.7.7 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -25,6 +25,9 @@ __all__ = [
"ReportGenerator",
# Config
"StandardiseConfig",
# AI Generation
"AIGenerator",
"ExampleVerifier",
]
@@ -70,4 +73,12 @@ def __getattr__(name: str):
from .config import StandardiseConfig
return StandardiseConfig
if name == "AIGenerator":
from .ai_generator import AIGenerator
return AIGenerator
if name == "ExampleVerifier":
from .example_verifier import ExampleVerifier
return ExampleVerifier
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
File diff suppressed because it is too large. Load diff
+28 -16
View File
@@ -117,25 +117,37 @@ class StandardiseConfig:
root = self.project_root
# Docs root detection
# Docs root detection - default: ~/PraisonAIDocs/docs
if self.docs_root is None:
for candidate in ["docs", "PraisonAIDocs/docs"]:
path = root / candidate
if path.exists():
self.docs_root = path
break
# Check parent for PraisonAIDocs
parent_docs = root.parent / "PraisonAIDocs"
if parent_docs.exists() and self.docs_root is None:
self.docs_root = parent_docs / "docs"
# First check home directory for PraisonAIDocs
home_docs = Path.home() / "PraisonAIDocs" / "docs"
if home_docs.exists():
self.docs_root = home_docs
else:
# Fallback to project-relative paths
for candidate in ["docs", "PraisonAIDocs/docs"]:
path = root / candidate
if path.exists():
self.docs_root = path
break
# Check parent for PraisonAIDocs
parent_docs = root.parent / "PraisonAIDocs"
if parent_docs.exists() and self.docs_root is None:
self.docs_root = parent_docs / "docs"
# Examples root detection
# Examples root detection - default: ~/praisonai-package/examples
if self.examples_root is None:
for candidate in ["examples/python", "examples"]:
path = root / candidate
if path.exists():
self.examples_root = path
break
# First check home directory for praisonai-package/examples
home_examples = Path.home() / "praisonai-package" / "examples"
if home_examples.exists():
self.examples_root = home_examples
else:
# Fallback to project-relative paths
for candidate in ["examples/python", "examples"]:
path = root / candidate
if path.exists():
self.examples_root = path
break
# SDK root detection
if self.sdk_root is None:
@@ -0,0 +1,674 @@
"""
Enhanced templates for the FDEP standardisation system.
Based on analysis of existing PraisonAI documentation best practices:
- Progressive disclosure (simple first, advanced later)
- Mermaid diagrams for concepts
- CodeGroup/Tabs for multiple examples
- Consistent structure across page types
"""
from pathlib import Path
from typing import Optional
from .models import ArtifactType, FeatureSlug
# Template constants based on existing doc analysis
CONCEPT_TEMPLATE = '''---
title: "{name}"
sidebarTitle: "{name}"
description: "{description}"
icon: "{icon}"
---
## Overview
{name} provides [brief description of what this feature does and why it exists].
```mermaid
flowchart LR
subgraph Input
A[User Request]
end
subgraph {name}
B[Process] --> C[Validate]
C --> D[Execute]
end
subgraph Output
E[Result]
end
A --> B
D --> E
style B fill:#189AB4,color:#fff
style C fill:#2E8B57,color:#fff
style D fill:#8B0000,color:#fff
```
## Types of {name}
<CardGroup cols={{2}}>
<Card title="Type 1" icon="code">
Description of first type
</Card>
<Card title="Type 2" icon="brain">
Description of second type
</Card>
</CardGroup>
## Quick Start
```python
from praisonaiagents import Agent, Task
# Basic {slug} usage
agent = Agent(
instructions="You are a helpful assistant",
# Configure {slug} here
)
result = agent.start("Your prompt here")
print(result)
```
## When to Use
| Use Case | Recommendation |
|----------|----------------|
| Simple tasks | Use basic configuration |
| Complex workflows | Use advanced patterns |
| Production | Enable all safety features |
## Related Features
- [Related Feature 1](/docs/concepts/related1)
- [Related Feature 2](/docs/concepts/related2)
'''
FEATURE_TEMPLATE = '''---
title: "{name}"
description: "How to use {slug} in PraisonAI"
icon: "code"
---
## Quick Start
<CodeGroup>
```python Basic Usage
from praisonaiagents import Agent, Task
# Simple {slug} example
agent = Agent(
instructions="You are a helpful assistant",
# {slug} configuration
)
result = agent.start("Hello!")
print(result)
```
```python Advanced Usage
from praisonaiagents import Agent, Task, Agents
# Advanced {slug} with multiple agents
agent1 = Agent(name="Agent1", instructions="First agent")
agent2 = Agent(name="Agent2", instructions="Second agent")
task = Task(
description="Complex task using {slug}",
agent=agent1,
)
agents = Agents(agents=[agent1, agent2], tasks=[task])
result = agents.start()
```
</CodeGroup>
## Configuration Options
<Tabs>
<Tab title="Basic">
```python
# Minimal configuration
agent = Agent(
instructions="Your instructions",
# {slug}=True, # Enable feature
)
```
</Tab>
<Tab title="Advanced">
```python
# Full configuration
agent = Agent(
instructions="Your instructions",
# {slug}={{
# "option1": "value1",
# "option2": "value2",
# }},
)
```
</Tab>
</Tabs>
## API Reference
### Parameters
<ParamField body="param1" type="string" required>
Description of the first parameter.
</ParamField>
<ParamField body="param2" type="boolean" default="false">
Description of the second parameter.
</ParamField>
<ParamField body="param3" type="object">
Advanced configuration object.
</ParamField>
## Examples
### Example 1: Basic Usage
```python
from praisonaiagents import Agent
agent = Agent(instructions="You are helpful")
result = agent.start("Hello")
```
### Example 2: With Callbacks
```python
from praisonaiagents import Agent
def on_complete(result):
print(f"Completed: {{result}}")
agent = Agent(
instructions="You are helpful",
# callbacks=[on_complete],
)
```
## Troubleshooting
<Accordion title="Common Issue 1">
**Problem**: Description of the issue
**Solution**: How to fix it
</Accordion>
<Accordion title="Common Issue 2">
**Problem**: Another common issue
**Solution**: The fix for this issue
</Accordion>
## See Also
- [Concept Guide](/docs/concepts/{slug})
- [CLI Reference](/docs/cli/{slug})
- [API Reference](/docs/sdk/praisonaiagents/{slug}/{slug})
'''
CLI_TEMPLATE = '''---
title: "{name}"
sidebarTitle: "{name}"
description: "CLI commands for {slug}"
icon: "terminal"
---
## Quick Start
```bash
praisonai {slug} [OPTIONS]
```
## Commands
### Basic Command
```bash
praisonai {slug}
```
**Expected Output:**
```
[Output example here]
```
### With Options
```bash
praisonai {slug} --option value
```
## Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `--help` | flag | - | Show help message |
| `--verbose` | flag | false | Enable verbose output |
| `--config` | string | - | Path to config file |
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PRAISONAI_{slug_upper}` | Configure {slug} | None |
## Examples
### Example 1: Basic
```bash
praisonai {slug}
```
### Example 2: With Config
```bash
praisonai {slug} --config config.yaml
```
### Example 3: Verbose Mode
```bash
praisonai {slug} --verbose
```
## Integration with Python
You can also use {slug} programmatically:
```python
from praisonaiagents import Agent
# Equivalent to CLI command
agent = Agent(instructions="Your instructions")
result = agent.start("Your prompt")
```
## Troubleshooting
<Accordion title="Command not found">
Ensure PraisonAI is installed: `pip install praisonai`
</Accordion>
<Accordion title="Permission denied">
Check file permissions and try with `sudo` if needed.
</Accordion>
'''
SDK_TEMPLATE = '''---
title: "{name} Module"
description: "API reference for {slug}"
icon: "code"
---
# {name}
The {name} module provides [brief description].
## Installation
```bash
pip install praisonaiagents
```
## Quick Start
```python
from praisonaiagents import {class_name}
# Basic usage
instance = {class_name}()
result = instance.execute()
```
## Classes
### `{class_name}`
Main class for {slug} functionality.
#### Constructor
```python
{class_name}(
param1: str,
param2: bool = False,
**kwargs
)
```
#### Parameters
<ParamField body="param1" type="string" required>
Primary configuration parameter.
</ParamField>
<ParamField body="param2" type="boolean" default="false">
Enable advanced features.
</ParamField>
#### Methods
##### `execute()`
Execute the main functionality.
```python
result = instance.execute(input_data)
```
**Parameters:**
- `input_data` (str): The input to process
**Returns:** `str` - The processed result
##### `configure()`
Update configuration.
```python
instance.configure(option="value")
```
## Type Definitions
```python
from typing import TypedDict, Optional
class {class_name}Config(TypedDict):
param1: str
param2: Optional[bool]
```
## Examples
### Basic Example
```python
from praisonaiagents import {class_name}
instance = {class_name}(param1="value")
result = instance.execute()
print(result)
```
### Advanced Example
```python
from praisonaiagents import {class_name}
# With full configuration
instance = {class_name}(
param1="value",
param2=True,
)
# Custom processing
result = instance.execute()
```
## See Also
- [Concept Guide](/docs/concepts/{slug})
- [Feature Guide](/docs/features/{slug})
'''
EXAMPLE_BASIC_TEMPLATE = '''"""
{name} - Basic Example
Demonstrates minimal usage of {slug} with a single agent.
Features:
- Simple configuration
- Single agent setup
- Basic execution
Expected Output:
Agent response demonstrating {slug} functionality.
Usage:
python {slug}-basic.py
"""
from praisonaiagents import Agent
# ============================================================
# Basic {name} Configuration
# ============================================================
# Create an agent with {slug} enabled
agent = Agent(
name="{name}Agent",
role="{name} Specialist",
goal="Demonstrate basic {slug} functionality",
backstory="You are an expert in {slug} operations.",
instructions="Provide clear, helpful responses demonstrating {slug}.",
# {slug}=True, # Enable {slug} feature
)
# ============================================================
# Execute
# ============================================================
if __name__ == "__main__":
# Run the agent
result = agent.start("Demonstrate {slug} functionality")
print(result)
'''
EXAMPLE_ADVANCED_TEMPLATE = '''"""
{name} - Advanced Example
Demonstrates advanced {slug} patterns including:
- Multi-agent configuration
- Custom callbacks/handlers
- Error handling
- All available options
Features:
- Multiple agents working together
- Custom configuration
- Production-ready patterns
Expected Output:
Comprehensive demonstration of {slug} with multiple agents.
Usage:
python {slug}-advanced.py
"""
from praisonaiagents import Agent, Task, Agents
from typing import Any
# ============================================================
# Section 1: Custom Configuration
# ============================================================
# Define custom {slug} configuration
# config = {{
# "option1": "value1",
# "option2": True,
# }}
# ============================================================
# Section 2: Custom Callbacks
# ============================================================
def on_task_complete(task_output: Any) -> None:
"""Callback when a task completes."""
print(f"Task completed: {{task_output}}")
def on_error(error: Exception) -> None:
"""Callback when an error occurs."""
print(f"Error occurred: {{error}}")
# ============================================================
# Section 3: Multi-Agent Setup
# ============================================================
# Primary agent
primary_agent = Agent(
name="Primary{name}Agent",
role="Primary {name} Handler",
goal="Handle primary {slug} operations",
backstory="You are the primary handler for {slug} tasks.",
instructions="Process {slug} requests thoroughly and accurately.",
# {slug}=config,
)
# Secondary agent
secondary_agent = Agent(
name="Secondary{name}Agent",
role="Secondary {name} Handler",
goal="Support and verify {slug} operations",
backstory="You verify and enhance {slug} results.",
instructions="Review and improve {slug} outputs.",
# {slug}=config,
)
# ============================================================
# Section 4: Task Definition
# ============================================================
primary_task = Task(
description="Demonstrate advanced {slug} with primary processing",
agent=primary_agent,
expected_output="Detailed {slug} demonstration",
)
verification_task = Task(
description="Verify and enhance the {slug} results",
agent=secondary_agent,
expected_output="Verified and enhanced {slug} output",
)
# ============================================================
# Section 5: Execution
# ============================================================
if __name__ == "__main__":
# Create agents team
agents = Agents(
agents=[primary_agent, secondary_agent],
tasks=[primary_task, verification_task],
verbose=True,
)
# Execute
result = agents.start()
print("\\n" + "=" * 60)
print("FINAL RESULT:")
print("=" * 60)
print(result)
# ============================================================
# Section 6: All Options Reference
# ============================================================
"""
{name} Options:
──────────────────────────────────────────────────────────────
| Option | Type | Default | Description |
|-----------------|----------|---------|----------------------|
| option1 | str | None | Primary option |
| option2 | bool | False | Enable advanced mode |
| option3 | int | 10 | Iteration count |
| option4 | list | [] | Additional handlers |
──────────────────────────────────────────────────────────────
"""
'''
class EnhancedTemplateGenerator:
"""Enhanced template generator with best-practice templates."""
# Icon mapping for features
ICON_MAP = {
"guardrails": "shield-halved",
"memory": "brain",
"knowledge": "book",
"tools": "wrench",
"agents": "robot",
"tasks": "list-check",
"workflows": "diagram-project",
"handoffs": "arrow-right-arrow-left",
"sessions": "clock",
"hooks": "plug",
"callbacks": "phone",
"telemetry": "chart-line",
"default": "code",
}
def get_icon(self, slug: str) -> str:
"""Get appropriate icon for a feature."""
return self.ICON_MAP.get(slug, self.ICON_MAP["default"])
def generate(self, slug: FeatureSlug, artifact_type: ArtifactType) -> str:
"""Generate template content for an artifact type."""
slug_str = slug.normalised
name = slug_str.replace("-", " ").title()
class_name = "".join(word.title() for word in slug_str.split("-"))
icon = self.get_icon(slug_str)
slug_upper = slug_str.upper().replace("-", "_")
# Select template
if artifact_type == ArtifactType.DOCS_CONCEPT:
template = CONCEPT_TEMPLATE
elif artifact_type == ArtifactType.DOCS_FEATURE:
template = FEATURE_TEMPLATE
elif artifact_type == ArtifactType.DOCS_CLI:
template = CLI_TEMPLATE
elif artifact_type == ArtifactType.DOCS_SDK:
template = SDK_TEMPLATE
elif artifact_type == ArtifactType.EXAMPLE_BASIC:
template = EXAMPLE_BASIC_TEMPLATE
elif artifact_type == ArtifactType.EXAMPLE_ADVANCED:
template = EXAMPLE_ADVANCED_TEMPLATE
else:
return ""
# Fill template
return template.format(
name=name,
slug=slug_str,
class_name=class_name,
icon=icon,
slug_upper=slug_upper,
description=f"{name} functionality for PraisonAI agents",
)
def get_expected_path(self, slug: FeatureSlug, artifact_type: ArtifactType,
docs_root: Optional[Path] = None,
examples_root: Optional[Path] = None) -> Optional[Path]:
"""Get the expected path for an artifact."""
slug_str = slug.normalised
if artifact_type == ArtifactType.EXAMPLE_BASIC:
if examples_root:
return examples_root / slug_str / f"{slug_str}-basic.py"
elif artifact_type == ArtifactType.EXAMPLE_ADVANCED:
if examples_root:
return examples_root / slug_str / f"{slug_str}-advanced.py"
elif artifact_type == ArtifactType.DOCS_CONCEPT:
if docs_root:
return docs_root / "concepts" / f"{slug_str}.mdx"
elif artifact_type == ArtifactType.DOCS_FEATURE:
if docs_root:
return docs_root / "features" / f"{slug_str}.mdx"
elif artifact_type == ArtifactType.DOCS_CLI:
if docs_root:
return docs_root / "cli" / f"{slug_str}.mdx"
elif artifact_type == ArtifactType.DOCS_SDK:
if docs_root:
return docs_root / "sdk" / "praisonaiagents" / slug_str / f"{slug_str}.mdx"
return None
@@ -0,0 +1,229 @@
"""
Example verification module for the FDEP standardisation system.
Verifies that generated Python examples are:
1. Syntactically correct (can be parsed)
2. Runnable (can be executed without errors)
3. Handle external library dependencies gracefully
Features:
- Syntax validation via ast.parse
- Execution in subprocess with timeout
- Detection of missing external libraries
- Detailed error reporting
"""
import ast
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import List, Tuple
@dataclass
class VerificationResult:
"""Result of example verification."""
success: bool
syntax_valid: bool
execution_passed: bool
output: str
error: str
missing_libraries: List[str]
requires_external: bool
@property
def can_write(self) -> bool:
"""Whether the example is safe to write."""
return self.syntax_valid and (self.execution_passed or self.requires_external)
class ExampleVerifier:
"""Verifies that generated examples are runnable."""
# Known external libraries that may not be installed
EXTERNAL_LIBRARIES = {
"requests", "httpx", "aiohttp", # HTTP
"pandas", "numpy", "scipy", # Data science
"torch", "tensorflow", "transformers", # ML
"redis", "pymongo", "psycopg2", # Databases
"boto3", "google-cloud", # Cloud
"playwright", "selenium", # Browser automation
"beautifulsoup4", "scrapy", # Web scraping
}
# Imports that indicate external dependencies
EXTERNAL_IMPORT_PATTERNS = [
"import requests",
"import httpx",
"import pandas",
"import numpy",
"import torch",
"import tensorflow",
"from transformers",
"import redis",
"import pymongo",
"import boto3",
]
# Patterns that indicate LLM API calls (will timeout during verification)
LLM_CALL_PATTERNS = [
"agent.start(",
"agents.start(",
".chat(",
".run(",
]
def __init__(self, timeout: int = 30):
self.timeout = timeout
def verify(self, code: str) -> VerificationResult:
"""
Verify that the code is valid and runnable.
Args:
code: Python source code to verify
Returns:
VerificationResult with details
"""
# Step 1: Syntax validation
syntax_valid, syntax_error = self._check_syntax(code)
if not syntax_valid:
return VerificationResult(
success=False,
syntax_valid=False,
execution_passed=False,
output="",
error=f"Syntax error: {syntax_error}",
missing_libraries=[],
requires_external=False,
)
# Step 2: Check for external dependencies
requires_external, external_libs = self._detect_external_deps(code)
# Step 3: Skip execution for examples with LLM calls (they will timeout)
if "LLM_API_CALL" in external_libs:
return VerificationResult(
success=True,
syntax_valid=True,
execution_passed=False,
output="",
error="Skipped: Example makes LLM API calls",
missing_libraries=["praisonaiagents (LLM API)"],
requires_external=True,
)
# Step 4: Execute the code
execution_passed, output, error, missing_libs = self._execute(code)
# Combine missing libraries
all_missing = list(set(missing_libs + external_libs))
return VerificationResult(
success=execution_passed or requires_external,
syntax_valid=True,
execution_passed=execution_passed,
output=output,
error=error,
missing_libraries=all_missing,
requires_external=requires_external and not execution_passed,
)
def _check_syntax(self, code: str) -> Tuple[bool, str]:
"""Check if code is syntactically valid."""
try:
ast.parse(code)
return True, ""
except SyntaxError as e:
return False, f"Line {e.lineno}: {e.msg}"
def _detect_external_deps(self, code: str) -> Tuple[bool, List[str]]:
"""Detect if code requires external libraries or makes LLM calls."""
external_libs = []
for pattern in self.EXTERNAL_IMPORT_PATTERNS:
if pattern in code:
lib_name = pattern.split()[-1]
external_libs.append(lib_name)
# Check for LLM API calls that will timeout during verification
for pattern in self.LLM_CALL_PATTERNS:
if pattern in code:
external_libs.append("LLM_API_CALL")
break
return len(external_libs) > 0, external_libs
def _execute(self, code: str) -> Tuple[bool, str, str, List[str]]:
"""Execute code in a subprocess."""
missing_libs = []
# Write code to temp file
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, encoding="utf-8"
) as f:
f.write(code)
temp_path = f.name
try:
# Run with timeout
result = subprocess.run(
[sys.executable, temp_path],
capture_output=True,
text=True,
timeout=self.timeout,
env={**subprocess.os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
)
output = result.stdout
error = result.stderr
# Check for import errors
if result.returncode != 0:
if "ModuleNotFoundError" in error or "ImportError" in error:
# Extract module name
for line in error.split("\n"):
if "No module named" in line:
# Extract module name from error
parts = line.split("'")
if len(parts) >= 2:
missing_libs.append(parts[1].split(".")[0])
return False, output, error, missing_libs
return True, output, error, missing_libs
except subprocess.TimeoutExpired:
return False, "", f"Execution timed out after {self.timeout}s", missing_libs
except Exception as e:
return False, "", str(e), missing_libs
finally:
# Clean up temp file
Path(temp_path).unlink(missing_ok=True)
def format_result(self, result: VerificationResult) -> str:
"""Format verification result for display."""
lines = []
if result.success:
lines.append("✅ Verification PASSED")
else:
lines.append("❌ Verification FAILED")
lines.append(f" Syntax valid: {'✓' if result.syntax_valid else '✗'}")
lines.append(f" Execution passed: {'✓' if result.execution_passed else '✗'}")
if result.requires_external:
lines.append(f" ⚠️ Requires external libraries: {', '.join(result.missing_libraries)}")
lines.append(" (Will be written with warning)")
if result.error and not result.requires_external:
lines.append(f" Error: {result.error[:200]}")
if result.output:
lines.append(f" Output preview: {result.output[:100]}...")
return "\n".join(lines)
@@ -0,0 +1,232 @@
"""
Git-based undo/redo system for the FDEP standardisation system.
Provides:
- Checkpoint creation before changes
- Undo to previous checkpoint
- Redo to next checkpoint
- List checkpoints
"""
import subprocess
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Tuple
class UndoRedoManager:
"""Git-based undo/redo for standardisation changes."""
CHECKPOINT_PREFIX = "standardise-checkpoint-"
def __init__(self, repo_path: Optional[Path] = None):
self.repo_path = repo_path or Path.cwd()
self._verify_git_repo()
def _verify_git_repo(self) -> bool:
"""Verify we're in a git repository."""
try:
result = subprocess.run(
["git", "rev-parse", "--git-dir"],
cwd=self.repo_path,
capture_output=True,
text=True,
)
return result.returncode == 0
except Exception:
return False
def _run_git(self, *args) -> Tuple[bool, str]:
"""Run a git command."""
try:
result = subprocess.run(
["git"] + list(args),
cwd=self.repo_path,
capture_output=True,
text=True,
)
return result.returncode == 0, result.stdout + result.stderr
except Exception as e:
return False, str(e)
def create_checkpoint(self, message: Optional[str] = None) -> Tuple[bool, str]:
"""
Create a checkpoint before making changes.
Returns:
Tuple of (success, checkpoint_id or error message)
"""
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
checkpoint_id = f"{self.CHECKPOINT_PREFIX}{timestamp}"
# Stage all changes
success, output = self._run_git("add", "-A")
if not success:
return False, f"Failed to stage changes: {output}"
# Check if there are changes to commit
success, output = self._run_git("diff", "--cached", "--quiet")
if success:
# No changes to commit, just create a tag at current HEAD
pass
else:
# Commit changes
commit_msg = message or f"Standardise checkpoint: {timestamp}"
success, output = self._run_git("commit", "-m", commit_msg)
if not success and "nothing to commit" not in output:
return False, f"Failed to commit: {output}"
# Create tag
tag_msg = message or f"Standardise checkpoint at {timestamp}"
success, output = self._run_git("tag", "-a", checkpoint_id, "-m", tag_msg)
if not success:
return False, f"Failed to create tag: {output}"
return True, checkpoint_id
def undo(self, checkpoint_id: Optional[str] = None) -> Tuple[bool, str]:
"""
Undo to a previous checkpoint.
Args:
checkpoint_id: Specific checkpoint to restore (default: previous)
Returns:
Tuple of (success, message)
"""
if checkpoint_id is None:
# Find the most recent checkpoint
checkpoints = self.list_checkpoints()
if len(checkpoints) < 2:
return False, "No previous checkpoint to undo to"
checkpoint_id = checkpoints[1][0] # Second most recent
# Verify checkpoint exists
success, output = self._run_git("rev-parse", checkpoint_id)
if not success:
return False, f"Checkpoint not found: {checkpoint_id}"
# Create a safety checkpoint before undoing
self.create_checkpoint("Auto-checkpoint before undo")
# Reset to checkpoint
success, output = self._run_git("reset", "--hard", checkpoint_id)
if not success:
return False, f"Failed to reset: {output}"
return True, f"Restored to checkpoint: {checkpoint_id}"
def redo(self) -> Tuple[bool, str]:
"""
Redo to the next checkpoint (after an undo).
Returns:
Tuple of (success, message)
"""
# Get reflog to find the previous HEAD
success, output = self._run_git("reflog", "-n", "10", "--format=%H %s")
if not success:
return False, "Failed to read reflog"
# Find the commit before the last reset
lines = output.strip().split("\n")
for i, line in enumerate(lines):
if "reset: moving to" in line and i > 0:
# The previous line has the commit we want to redo to
target_hash = lines[i - 1].split()[0]
success, output = self._run_git("reset", "--hard", target_hash)
if success:
return True, "Redo successful"
return False, f"Failed to redo: {output}"
return False, "No redo available"
def list_checkpoints(self) -> List[Tuple[str, str, str]]:
"""
List all standardise checkpoints.
Returns:
List of (checkpoint_id, date, message) tuples
"""
success, output = self._run_git(
"tag", "-l", f"{self.CHECKPOINT_PREFIX}*",
"--format=%(refname:short)|%(creatordate:short)|%(subject)"
)
if not success:
return []
checkpoints = []
for line in output.strip().split("\n"):
if line and "|" in line:
parts = line.split("|", 2)
if len(parts) >= 3:
checkpoints.append((parts[0], parts[1], parts[2]))
elif len(parts) == 2:
checkpoints.append((parts[0], parts[1], ""))
# Sort by date descending
checkpoints.sort(key=lambda x: x[1], reverse=True)
return checkpoints
def delete_checkpoint(self, checkpoint_id: str) -> Tuple[bool, str]:
"""Delete a checkpoint."""
success, output = self._run_git("tag", "-d", checkpoint_id)
if success:
return True, f"Deleted checkpoint: {checkpoint_id}"
return False, f"Failed to delete: {output}"
def get_changes_since(self, checkpoint_id: str) -> List[str]:
"""Get list of files changed since a checkpoint."""
success, output = self._run_git(
"diff", "--name-only", checkpoint_id, "HEAD"
)
if not success:
return []
return [f for f in output.strip().split("\n") if f]
def preview_undo(self, checkpoint_id: str) -> List[str]:
"""Preview what files would change if we undo to a checkpoint."""
return self.get_changes_since(checkpoint_id)
class FileBackupManager:
"""Simple file-based backup for non-git scenarios."""
def __init__(self, backup_dir: Optional[Path] = None):
self.backup_dir = backup_dir or Path(".praison/backups")
self.backup_dir.mkdir(parents=True, exist_ok=True)
def backup_file(self, file_path: Path) -> Optional[Path]:
"""Create a backup of a file."""
if not file_path.exists():
return None
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_name = f"{file_path.stem}_{timestamp}{file_path.suffix}"
backup_path = self.backup_dir / backup_name
import shutil
shutil.copy2(file_path, backup_path)
return backup_path
def restore_file(self, backup_path: Path, target_path: Path) -> bool:
"""Restore a file from backup."""
if not backup_path.exists():
return False
import shutil
shutil.copy2(backup_path, target_path)
return True
def list_backups(self, file_stem: Optional[str] = None) -> List[Path]:
"""List all backups, optionally filtered by file stem."""
backups = list(self.backup_dir.glob("*"))
if file_stem:
backups = [b for b in backups if b.stem.startswith(file_stem)]
backups.sort(key=lambda x: x.stat().st_mtime, reverse=True)
return backups
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.7.2"
__version__ = "3.7.7"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=0.11.1",
"praisonaiagents>=0.11.3",
"python-dotenv>=0.19.0",
"instructor>=1.3.3",
"PyYAML>=6.0",
@@ -0,0 +1,600 @@
"""
Tests for PraisonAI Async TUI Application.
"""
class TestAsyncTUIConfig:
"""Tests for AsyncTUIConfig dataclass."""
def test_default_values(self):
"""Test default configuration values."""
from praisonai.cli.interactive.async_tui import AsyncTUIConfig
config = AsyncTUIConfig()
assert config.model == "gpt-4o-mini"
assert config.show_logo is True
assert config.show_status_bar is True
assert config.session_id is None
assert config.workspace is None
def test_custom_values(self):
"""Test custom configuration values."""
from praisonai.cli.interactive.async_tui import AsyncTUIConfig
config = AsyncTUIConfig(
model="gpt-4o",
show_logo=False,
session_id="test123",
)
assert config.model == "gpt-4o"
assert config.show_logo is False
assert config.session_id == "test123"
class TestChatMessage:
"""Tests for ChatMessage dataclass."""
def test_message_creation(self):
"""Test message creation."""
from praisonai.cli.interactive.async_tui import ChatMessage
msg = ChatMessage(role="user", content="Hello")
assert msg.role == "user"
assert msg.content == "Hello"
assert msg.timestamp is not None
def test_message_roles(self):
"""Test different message roles."""
from praisonai.cli.interactive.async_tui import ChatMessage
roles = ["user", "assistant", "system", "status"]
for role in roles:
msg = ChatMessage(role=role, content="test")
assert msg.role == role
class TestGetLogo:
"""Tests for get_logo function."""
def test_logo_wide_terminal(self):
"""Test logo for wide terminal."""
from praisonai.cli.interactive.async_tui import get_logo, LOGO
logo = get_logo(100)
assert logo == LOGO
def test_logo_medium_terminal(self):
"""Test logo for medium terminal."""
from praisonai.cli.interactive.async_tui import get_logo, LOGO_SMALL
logo = get_logo(50)
assert logo == LOGO_SMALL
def test_logo_narrow_terminal(self):
"""Test logo for narrow terminal."""
from praisonai.cli.interactive.async_tui import get_logo, LOGO_MINIMAL
logo = get_logo(30)
assert logo == LOGO_MINIMAL
def test_logo_contains_praison_ai(self):
"""Test that logo contains 'Praison AI' branding."""
from praisonai.cli.interactive.async_tui import LOGO_MINIMAL
assert "Praison AI" in LOGO_MINIMAL
class TestAsyncTUI:
"""Tests for AsyncTUI class."""
def test_init_default(self):
"""Test default initialization."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert tui.config is not None
assert tui.messages == []
assert tui._running is False
assert tui._processing is False
assert tui.session_id is not None
def test_init_with_config(self):
"""Test initialization with config."""
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig
config = AsyncTUIConfig(model="gpt-4o", show_logo=False)
tui = AsyncTUI(config=config)
assert tui.config.model == "gpt-4o"
assert tui.config.show_logo is False
def test_format_output_empty(self):
"""Test format_output with no messages."""
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig
config = AsyncTUIConfig(show_logo=True)
tui = AsyncTUI(config=config)
output = tui._format_output()
assert "Type your message" in output or "gpt-4o-mini" in output
def test_format_output_with_messages(self):
"""Test format_output with messages."""
from praisonai.cli.interactive.async_tui import AsyncTUI, ChatMessage
tui = AsyncTUI()
tui.messages = [
ChatMessage(role="user", content="Hello"),
ChatMessage(role="assistant", content="Hi there"),
]
output = tui._format_output()
assert "Hello" in output
assert "Hi there" in output
def test_format_output_with_processing(self):
"""Test format_output shows processing indicator."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._processing = True
tui._status_text = "Thinking..."
output = tui._format_output()
assert "Thinking..." in output
def test_handle_command_exit(self):
"""Test handling exit command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._running = True
result = tui._handle_command("/exit")
assert result is True
assert tui._running is False
def test_handle_command_quit(self):
"""Test handling quit command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._running = True
result = tui._handle_command("/quit")
assert result is True
assert tui._running is False
def test_handle_command_help(self):
"""Test handling help command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/help")
assert result is True
assert len(tui.messages) == 1
assert tui.messages[0].role == "system"
assert "PageUp" in tui.messages[0].content # Scroll instructions
def test_handle_command_clear(self):
"""Test handling clear command."""
from praisonai.cli.interactive.async_tui import AsyncTUI, ChatMessage
tui = AsyncTUI()
tui.messages = [ChatMessage(role="user", content="test")]
result = tui._handle_command("/clear")
assert result is True
# Clear now adds a confirmation message
assert len(tui.messages) == 1
assert "cleared" in tui.messages[0].content.lower()
def test_handle_command_new(self):
"""Test handling new command."""
from praisonai.cli.interactive.async_tui import AsyncTUI, ChatMessage
tui = AsyncTUI()
old_session = tui.session_id
tui.messages = [ChatMessage(role="user", content="test")]
result = tui._handle_command("/new")
assert result is True
assert len(tui.messages) == 1
assert tui.session_id != old_session
def test_handle_command_model_show(self):
"""Test handling model command (show)."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/model")
assert result is True
assert len(tui.messages) == 1
assert "gpt-4o-mini" in tui.messages[0].content
def test_handle_command_model_change(self):
"""Test handling model command (change)."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/model gpt-4o")
assert result is True
assert tui.config.model == "gpt-4o"
assert tui._agent is None
def test_handle_command_unknown(self):
"""Test handling unknown command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/unknown")
assert result is True
assert len(tui.messages) == 1
assert "Unknown" in tui.messages[0].content
class TestStartAsyncTUI:
"""Tests for start_async_tui function."""
def test_function_exists(self):
"""Test that start_async_tui function exists."""
from praisonai.cli.interactive.async_tui import start_async_tui
assert callable(start_async_tui)
class TestASCIILogos:
"""Tests for ASCII logo constants."""
def test_logo_exists(self):
"""Test LOGO constant exists and has content."""
from praisonai.cli.interactive.async_tui import LOGO
assert len(LOGO) > 0
assert "██" in LOGO
def test_logo_small_exists(self):
"""Test LOGO_SMALL constant exists."""
from praisonai.cli.interactive.async_tui import LOGO_SMALL
assert len(LOGO_SMALL) > 0
def test_logo_minimal_branding(self):
"""Test LOGO_MINIMAL has correct branding."""
from praisonai.cli.interactive.async_tui import LOGO_MINIMAL
assert "Praison AI" in LOGO_MINIMAL
class TestMessageFormatting:
"""Tests for message formatting."""
def test_user_message_prefix(self):
"""Test user messages have correct prefix."""
from praisonai.cli.interactive.async_tui import AsyncTUI, ChatMessage
tui = AsyncTUI()
tui.messages = [ChatMessage(role="user", content="test")]
output = tui._format_output()
assert "› test" in output
def test_assistant_message_prefix(self):
"""Test assistant messages have correct prefix."""
from praisonai.cli.interactive.async_tui import AsyncTUI, ChatMessage
tui = AsyncTUI()
tui.messages = [ChatMessage(role="assistant", content="response")]
output = tui._format_output()
assert "● response" in output
def test_status_message_prefix(self):
"""Test status messages have correct prefix."""
from praisonai.cli.interactive.async_tui import AsyncTUI, ChatMessage
tui = AsyncTUI()
tui.messages = [ChatMessage(role="status", content="thinking")]
output = tui._format_output()
assert "⏳ thinking" in output
class TestNonBlockingExecution:
"""Tests for non-blocking execution features."""
def test_processing_flag(self):
"""Test processing flag is used correctly."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert tui._processing is False
tui._processing = True
tui._status_text = "Working..."
output = tui._format_output()
assert "Working..." in output
def test_status_text_updates(self):
"""Test status text can be updated."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._processing = True
tui._status_text = "Step 1"
output1 = tui._format_output()
assert "Step 1" in output1
tui._status_text = "Step 2"
output2 = tui._format_output()
assert "Step 2" in output2
class TestNewSlashCommands:
"""Tests for newly added slash commands."""
def test_session_command(self):
"""Test /session command shows session info."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/session")
assert result is True
assert len(tui.messages) == 1
assert "Session:" in tui.messages[0].content
assert tui.session_id in tui.messages[0].content
def test_history_command_empty(self):
"""Test /history command with no history."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/history")
assert result is True
assert "No conversation history" in tui.messages[0].content
def test_history_command_with_history(self):
"""Test /history command with conversation history."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._conversation_history = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there"},
]
result = tui._handle_command("/history")
assert result is True
assert "[user]" in tui.messages[0].content
def test_cost_command(self):
"""Test /cost command shows token usage."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._total_tokens = 100
tui._total_cost = 0.001
result = tui._handle_command("/cost")
assert result is True
assert "100" in tui.messages[0].content
def test_compact_command(self):
"""Test /compact command toggles compact mode."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert tui.config.compact_mode is False
result = tui._handle_command("/compact")
assert result is True
assert tui.config.compact_mode is True
result = tui._handle_command("/compact")
assert tui.config.compact_mode is False
def test_multiline_command(self):
"""Test /multiline command toggles multiline mode."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert tui.config.multiline_mode is False
result = tui._handle_command("/multiline")
assert result is True
assert tui.config.multiline_mode is True
def test_files_command(self):
"""Test /files command lists workspace files."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._workspace_files = ["test.py", "main.py"]
result = tui._handle_command("/files")
assert result is True
assert "@test.py" in tui.messages[0].content
def test_queue_command_empty(self):
"""Test /queue command with empty queue."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/queue")
assert result is True
assert "No prompts in queue" in tui.messages[0].content
def test_queue_command_with_items(self):
"""Test /queue command with queued prompts."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._prompt_queue = ["First prompt", "Second prompt"]
result = tui._handle_command("/queue")
assert result is True
assert "First prompt" in tui.messages[0].content
class TestQueueSystem:
"""Tests for prompt queue system."""
def test_queue_initialization(self):
"""Test queue is initialized empty."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert tui._prompt_queue == []
def test_queue_or_execute_when_not_processing(self):
"""Test _queue_or_execute executes immediately when not processing."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._processing = False
# This would normally start background execution
# Just test that it adds user message
tui._queue_or_execute("test prompt")
assert len(tui.messages) == 1
assert tui.messages[0].role == "user"
assert tui.messages[0].content == "test prompt"
class TestFileProcessing:
"""Tests for @file mention processing."""
def test_process_file_mentions_no_mentions(self):
"""Test processing prompt with no @file mentions."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._process_file_mentions("Hello world")
assert result == "Hello world"
def test_process_file_mentions_with_mention(self):
"""Test processing prompt with @file mention."""
from praisonai.cli.interactive.async_tui import AsyncTUI
import tempfile
import os
tui = AsyncTUI()
# Create a temp file
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write("test content")
temp_path = f.name
try:
tui.config.workspace = os.path.dirname(temp_path)
filename = os.path.basename(temp_path)
result = tui._process_file_mentions(f"Check @{filename}")
assert "test content" in result
finally:
os.unlink(temp_path)
class TestWorkspaceScanning:
"""Tests for workspace file scanning."""
def test_workspace_files_initialized(self):
"""Test workspace files list is initialized."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert isinstance(tui._workspace_files, list)
def test_scan_workspace_limits_files(self):
"""Test workspace scanning limits files (may exceed slightly due to batch processing)."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
# Verify the limit is approximately enforced (may exceed slightly due to batch)
assert len(tui._workspace_files) <= 1100 # Allow some margin
class TestToolLoading:
"""Tests for tool loading functionality."""
def test_load_tools_returns_list(self):
"""Test _load_tools returns a list."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tools = tui._load_tools()
assert isinstance(tools, list)
def test_load_tools_method_exists(self):
"""Test _load_tools method exists."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert hasattr(tui, '_load_tools')
assert callable(tui._load_tools)
class TestNewSessionCommands:
"""Tests for session-related commands."""
def test_sessions_command(self):
"""Test /sessions command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/sessions")
assert result is True
# Should have a message about sessions
assert len(tui.messages) >= 1
def test_continue_command(self):
"""Test /continue command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/continue")
assert result is True
assert len(tui.messages) >= 1
def test_import_command_no_args(self):
"""Test /import command without arguments."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/import")
assert result is True
assert "Usage" in tui.messages[0].content
def test_import_command_file_not_found(self):
"""Test /import command with non-existent file."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/import nonexistent_file.json")
assert result is True
assert "not found" in tui.messages[0].content.lower() or "failed" in tui.messages[0].content.lower()
def test_status_command(self):
"""Test /status command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/status")
assert result is True
# Should show runtime status or "not initialized"
assert len(tui.messages) >= 1
class TestRuntimeIntegration:
"""Tests for runtime integration."""
def test_runtime_fields_initialized(self):
"""Test runtime fields are initialized."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert tui._runtime is None
assert tui._runtime_started is False
def test_start_runtime_method_exists(self):
"""Test _start_runtime method exists."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert hasattr(tui, '_start_runtime')
# It's an async method
import asyncio
assert asyncio.iscoroutinefunction(tui._start_runtime)
@@ -0,0 +1,406 @@
"""
Tests for PraisonIO - Interactive CLI I/O handler.
"""
from unittest.mock import patch
class TestIOConfig:
"""Tests for IOConfig dataclass."""
def test_default_values(self):
"""Test default configuration values."""
from praisonai.cli.interactive.praison_io import IOConfig
config = IOConfig()
assert config.pretty is True
assert config.user_input_color == "#00cc00"
assert config.multiline_mode is False
assert config.vi_mode is False
assert config.enable_completions is True
def test_custom_values(self):
"""Test custom configuration values."""
from praisonai.cli.interactive.praison_io import IOConfig
config = IOConfig(
pretty=False,
user_input_color="#ff0000",
multiline_mode=True,
vi_mode=True,
)
assert config.pretty is False
assert config.user_input_color == "#ff0000"
assert config.multiline_mode is True
assert config.vi_mode is True
class TestPraisonCompleter:
"""Tests for PraisonCompleter."""
def test_init_empty(self):
"""Test initialization with no commands."""
from praisonai.cli.interactive.praison_io import PraisonCompleter
completer = PraisonCompleter()
assert completer.commands == {}
assert len(completer.files) == 0
assert len(completer.symbols) == 0
def test_init_with_commands(self):
"""Test initialization with commands."""
from praisonai.cli.interactive.praison_io import PraisonCompleter
commands = {"help": "Show help", "exit": "Exit"}
completer = PraisonCompleter(commands=commands)
assert completer.commands == commands
def test_add_files(self):
"""Test adding files."""
from praisonai.cli.interactive.praison_io import PraisonCompleter
completer = PraisonCompleter()
completer.add_files(["file1.py", "file2.py"])
assert "file1.py" in completer.files
assert "file2.py" in completer.files
def test_add_symbols(self):
"""Test adding symbols."""
from praisonai.cli.interactive.praison_io import PraisonCompleter
completer = PraisonCompleter()
completer.add_symbols(["MyClass", "my_function"])
assert "MyClass" in completer.symbols
assert "my_function" in completer.symbols
def test_get_completions_slash_commands(self):
"""Test completions for slash commands."""
from praisonai.cli.interactive.praison_io import PraisonCompleter
commands = {"help": "Show help", "history": "Show history", "exit": "Exit"}
completer = PraisonCompleter(commands=commands)
completions = completer.get_completions("/h", 2)
completion_texts = [c[0] for c in completions]
assert "/help" in completion_texts
assert "/history" in completion_texts
assert "/exit" not in completion_texts
def test_get_completions_file_mentions(self):
"""Test completions for @ file mentions."""
from praisonai.cli.interactive.praison_io import PraisonCompleter
completer = PraisonCompleter()
completer.add_files(["main.py", "test.py", "README.md"])
completions = completer.get_completions("@main", 5)
completion_texts = [c[0] for c in completions]
assert "@main.py" in completion_texts
def test_get_completions_symbols(self):
"""Test completions for symbols."""
from praisonai.cli.interactive.praison_io import PraisonCompleter
completer = PraisonCompleter()
completer.add_symbols(["MyClass", "MyFunction", "other"])
completions = completer.get_completions("My", 2)
completion_texts = [c[0] for c in completions]
assert "MyClass" in completion_texts
assert "MyFunction" in completion_texts
assert "other" not in completion_texts
class TestMarkdownStream:
"""Tests for MarkdownStream."""
def test_init(self):
"""Test initialization."""
from praisonai.cli.interactive.praison_io import MarkdownStream
stream = MarkdownStream(live_window=8)
assert stream.live_window == 8
assert stream.printed == []
assert stream.live is None
def test_render_to_lines_fallback(self):
"""Test rendering without Rich."""
from praisonai.cli.interactive.praison_io import MarkdownStream
stream = MarkdownStream()
# This tests the fallback path
text = "Hello\nWorld"
lines = stream._render_to_lines(text)
assert len(lines) >= 1
class TestPraisonIO:
"""Tests for PraisonIO."""
def test_init_default(self):
"""Test default initialization."""
from praisonai.cli.interactive.praison_io import PraisonIO
io = PraisonIO()
assert io.config is not None
assert io.completer is not None
def test_init_with_config(self):
"""Test initialization with config."""
from praisonai.cli.interactive.praison_io import PraisonIO, IOConfig
config = IOConfig(pretty=False)
io = PraisonIO(config=config)
assert io.config.pretty is False
def test_add_commands(self):
"""Test adding commands."""
from praisonai.cli.interactive.praison_io import PraisonIO
io = PraisonIO()
io.add_commands({"test": "Test command"})
assert "test" in io.completer.commands
def test_add_files(self):
"""Test adding files."""
from praisonai.cli.interactive.praison_io import PraisonIO
io = PraisonIO()
io.add_files(["file.py"])
assert "file.py" in io.completer.files
def test_add_symbols(self):
"""Test adding symbols."""
from praisonai.cli.interactive.praison_io import PraisonIO
io = PraisonIO()
io.add_symbols(["MyClass"])
assert "MyClass" in io.completer.symbols
@patch('builtins.print')
def test_rule_no_rich(self, mock_print):
"""Test rule without Rich."""
from praisonai.cli.interactive.praison_io import PraisonIO, IOConfig
config = IOConfig(pretty=False)
io = PraisonIO(config=config)
io.rule()
mock_print.assert_called()
@patch('builtins.print')
def test_tool_output_no_rich(self, mock_print):
"""Test tool output without Rich."""
from praisonai.cli.interactive.praison_io import PraisonIO, IOConfig
config = IOConfig(pretty=False)
io = PraisonIO(config=config)
io.tool_output("Test message")
mock_print.assert_called_with("⚙ Test message")
@patch('builtins.print')
def test_info_no_rich(self, mock_print):
"""Test info without Rich."""
from praisonai.cli.interactive.praison_io import PraisonIO, IOConfig
config = IOConfig(pretty=False)
io = PraisonIO(config=config)
io.info("Test info")
mock_print.assert_called_with("ℹ Test info")
@patch('builtins.print')
def test_success_no_rich(self, mock_print):
"""Test success without Rich."""
from praisonai.cli.interactive.praison_io import PraisonIO, IOConfig
config = IOConfig(pretty=False)
io = PraisonIO(config=config)
io.success("Test success")
mock_print.assert_called_with("✓ Test success")
class TestREPLConfig:
"""Tests for REPLConfig."""
def test_default_values(self):
"""Test default configuration values."""
from praisonai.cli.interactive.repl import REPLConfig
config = REPLConfig()
assert config.model == "gpt-4o-mini"
assert config.verbose is False
assert config.memory is False
assert config.tools is None
assert config.autonomy is True
def test_custom_values(self):
"""Test custom configuration values."""
from praisonai.cli.interactive.repl import REPLConfig
config = REPLConfig(
model="gpt-4o",
verbose=True,
memory=True,
)
assert config.model == "gpt-4o"
assert config.verbose is True
assert config.memory is True
class TestInteractiveREPL:
"""Tests for InteractiveREPL."""
def test_init_default(self):
"""Test default initialization."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
assert repl.config is not None
assert repl.io is not None
assert repl._running is False
def test_init_with_config(self):
"""Test initialization with config."""
from praisonai.cli.interactive.repl import InteractiveREPL, REPLConfig
config = REPLConfig(model="gpt-4o")
repl = InteractiveREPL(config=config)
assert repl.config.model == "gpt-4o"
def test_handle_command_exit(self):
"""Test handling exit command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
repl._running = True
result = repl._handle_command("/exit")
assert result is True
assert repl._running is False
def test_handle_command_quit(self):
"""Test handling quit command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
repl._running = True
result = repl._handle_command("/quit")
assert result is True
assert repl._running is False
def test_handle_command_help(self):
"""Test handling help command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
result = repl._handle_command("/help")
assert result is True
def test_handle_command_clear(self):
"""Test handling clear command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
repl._conversation_history = [{"role": "user", "content": "test"}]
result = repl._handle_command("/clear")
assert result is True
assert len(repl._conversation_history) == 0
def test_handle_command_model_show(self):
"""Test handling model command (show)."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
result = repl._handle_command("/model")
assert result is True
def test_handle_command_model_change(self):
"""Test handling model command (change)."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
result = repl._handle_command("/model gpt-4o")
assert result is True
assert repl.config.model == "gpt-4o"
def test_handle_command_unknown(self):
"""Test handling unknown command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
result = repl._handle_command("/unknown")
assert result is True # Still handled (shows warning)
def test_handle_command_session(self):
"""Test handling session command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
repl._session_id = "test-session-123"
result = repl._handle_command("/session")
assert result is True
def test_handle_command_cost(self):
"""Test handling cost command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
repl._total_tokens = 100
repl._total_cost = 0.01
result = repl._handle_command("/cost")
assert result is True
def test_handle_command_history_empty(self):
"""Test handling history command with empty history."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
result = repl._handle_command("/history")
assert result is True
def test_handle_command_history_with_items(self):
"""Test handling history command with items."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
repl._conversation_history = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
]
result = repl._handle_command("/history")
assert result is True
def test_handle_command_compact(self):
"""Test handling compact command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
original = repl.io.config.pretty
result = repl._handle_command("/compact")
assert result is True
assert repl.io.config.pretty != original
def test_handle_command_multiline(self):
"""Test handling multiline command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
original = repl.io.config.multiline_mode
result = repl._handle_command("/multiline")
assert result is True
assert repl.io.config.multiline_mode != original
class TestStartInteractive:
"""Tests for start_interactive function."""
def test_function_exists(self):
"""Test that start_interactive function exists."""
from praisonai.cli.interactive.repl import start_interactive
assert callable(start_interactive)
@@ -0,0 +1,282 @@
"""
Tests for PraisonAI Split-Pane TUI Application.
"""
class TestSplitTUIConfig:
"""Tests for SplitTUIConfig dataclass."""
def test_default_values(self):
"""Test default configuration values."""
from praisonai.cli.interactive.split_tui import SplitTUIConfig
config = SplitTUIConfig()
assert config.model == "gpt-4o-mini"
assert config.show_logo is True
assert config.show_status_bar is True
assert config.session_id is None
assert config.workspace is None
def test_custom_values(self):
"""Test custom configuration values."""
from praisonai.cli.interactive.split_tui import SplitTUIConfig
config = SplitTUIConfig(
model="gpt-4o",
show_logo=False,
session_id="test123",
)
assert config.model == "gpt-4o"
assert config.show_logo is False
assert config.session_id == "test123"
class TestChatMessage:
"""Tests for ChatMessage dataclass."""
def test_message_creation(self):
"""Test message creation."""
from praisonai.cli.interactive.split_tui import ChatMessage
msg = ChatMessage(role="user", content="Hello")
assert msg.role == "user"
assert msg.content == "Hello"
assert msg.timestamp is not None
def test_message_roles(self):
"""Test different message roles."""
from praisonai.cli.interactive.split_tui import ChatMessage
roles = ["user", "assistant", "system", "status"]
for role in roles:
msg = ChatMessage(role=role, content="test")
assert msg.role == role
class TestGetLogo:
"""Tests for get_logo function."""
def test_logo_wide_terminal(self):
"""Test logo for wide terminal."""
from praisonai.cli.interactive.split_tui import get_logo, LOGO
logo = get_logo(100)
assert logo == LOGO
def test_logo_medium_terminal(self):
"""Test logo for medium terminal."""
from praisonai.cli.interactive.split_tui import get_logo, LOGO_SMALL
logo = get_logo(50)
assert logo == LOGO_SMALL
def test_logo_narrow_terminal(self):
"""Test logo for narrow terminal."""
from praisonai.cli.interactive.split_tui import get_logo, LOGO_MINIMAL
logo = get_logo(30)
assert logo == LOGO_MINIMAL
def test_logo_contains_praison_ai(self):
"""Test that logo contains 'Praison AI' branding."""
from praisonai.cli.interactive.split_tui import LOGO_MINIMAL
assert "Praison AI" in LOGO_MINIMAL
class TestSplitTUI:
"""Tests for SplitTUI class."""
def test_init_default(self):
"""Test default initialization."""
from praisonai.cli.interactive.split_tui import SplitTUI
tui = SplitTUI()
assert tui.config is not None
assert tui.messages == []
assert tui._running is False
assert tui.session_id is not None
def test_init_with_config(self):
"""Test initialization with config."""
from praisonai.cli.interactive.split_tui import SplitTUI, SplitTUIConfig
config = SplitTUIConfig(model="gpt-4o", show_logo=False)
tui = SplitTUI(config=config)
assert tui.config.model == "gpt-4o"
assert tui.config.show_logo is False
def test_format_output_empty(self):
"""Test format_output with no messages."""
from praisonai.cli.interactive.split_tui import SplitTUI, SplitTUIConfig
config = SplitTUIConfig(show_logo=True)
tui = SplitTUI(config=config)
output = tui._format_output()
# Should contain welcome message
assert "Type your message" in output or "gpt-4o-mini" in output
def test_format_output_with_messages(self):
"""Test format_output with messages."""
from praisonai.cli.interactive.split_tui import SplitTUI, ChatMessage
tui = SplitTUI()
tui.messages = [
ChatMessage(role="user", content="Hello"),
ChatMessage(role="assistant", content="Hi there"),
]
output = tui._format_output()
assert "Hello" in output
assert "Hi there" in output
def test_handle_command_exit(self):
"""Test handling exit command."""
from praisonai.cli.interactive.split_tui import SplitTUI
tui = SplitTUI()
tui._running = True
result = tui._handle_command("/exit")
assert result is True
assert tui._running is False
def test_handle_command_quit(self):
"""Test handling quit command."""
from praisonai.cli.interactive.split_tui import SplitTUI
tui = SplitTUI()
tui._running = True
result = tui._handle_command("/quit")
assert result is True
assert tui._running is False
def test_handle_command_help(self):
"""Test handling help command."""
from praisonai.cli.interactive.split_tui import SplitTUI
tui = SplitTUI()
result = tui._handle_command("/help")
assert result is True
assert len(tui.messages) == 1
assert tui.messages[0].role == "system"
assert "/help" in tui.messages[0].content
def test_handle_command_clear(self):
"""Test handling clear command."""
from praisonai.cli.interactive.split_tui import SplitTUI, ChatMessage
tui = SplitTUI()
tui.messages = [ChatMessage(role="user", content="test")]
result = tui._handle_command("/clear")
assert result is True
assert len(tui.messages) == 0
def test_handle_command_new(self):
"""Test handling new command."""
from praisonai.cli.interactive.split_tui import SplitTUI, ChatMessage
tui = SplitTUI()
old_session = tui.session_id
tui.messages = [ChatMessage(role="user", content="test")]
result = tui._handle_command("/new")
assert result is True
assert len(tui.messages) == 1 # New session message
assert tui.session_id != old_session
def test_handle_command_model_show(self):
"""Test handling model command (show)."""
from praisonai.cli.interactive.split_tui import SplitTUI
tui = SplitTUI()
result = tui._handle_command("/model")
assert result is True
assert len(tui.messages) == 1
assert "gpt-4o-mini" in tui.messages[0].content
def test_handle_command_model_change(self):
"""Test handling model command (change)."""
from praisonai.cli.interactive.split_tui import SplitTUI
tui = SplitTUI()
result = tui._handle_command("/model gpt-4o")
assert result is True
assert tui.config.model == "gpt-4o"
assert tui._agent is None # Agent should be reset
def test_handle_command_unknown(self):
"""Test handling unknown command."""
from praisonai.cli.interactive.split_tui import SplitTUI
tui = SplitTUI()
result = tui._handle_command("/unknown")
assert result is True
assert len(tui.messages) == 1
assert "Unknown" in tui.messages[0].content
class TestStartSplitTUI:
"""Tests for start_split_tui function."""
def test_function_exists(self):
"""Test that start_split_tui function exists."""
from praisonai.cli.interactive.split_tui import start_split_tui
assert callable(start_split_tui)
class TestASCIILogos:
"""Tests for ASCII logo constants."""
def test_logo_exists(self):
"""Test LOGO constant exists and has content."""
from praisonai.cli.interactive.split_tui import LOGO
assert len(LOGO) > 0
assert "██" in LOGO # Contains block characters
def test_logo_small_exists(self):
"""Test LOGO_SMALL constant exists."""
from praisonai.cli.interactive.split_tui import LOGO_SMALL
assert len(LOGO_SMALL) > 0
def test_logo_minimal_branding(self):
"""Test LOGO_MINIMAL has correct branding."""
from praisonai.cli.interactive.split_tui import LOGO_MINIMAL
assert "Praison AI" in LOGO_MINIMAL
class TestMessageFormatting:
"""Tests for message formatting."""
def test_user_message_prefix(self):
"""Test user messages have correct prefix."""
from praisonai.cli.interactive.split_tui import SplitTUI, ChatMessage
tui = SplitTUI()
tui.messages = [ChatMessage(role="user", content="test")]
output = tui._format_output()
assert "› test" in output
def test_assistant_message_prefix(self):
"""Test assistant messages have correct prefix."""
from praisonai.cli.interactive.split_tui import SplitTUI, ChatMessage
tui = SplitTUI()
tui.messages = [ChatMessage(role="assistant", content="response")]
output = tui._format_output()
assert "● response" in output
def test_status_message_prefix(self):
"""Test status messages have correct prefix."""
from praisonai.cli.interactive.split_tui import SplitTUI, ChatMessage
tui = SplitTUI()
tui.messages = [ChatMessage(role="status", content="thinking")]
output = tui._format_output()
assert "⏳ thinking" in output
@@ -0,0 +1,344 @@
"""
Tests for PraisonAI TUI Application.
"""
class TestColorScheme:
"""Tests for ColorScheme dataclass."""
def test_default_values(self):
"""Test default color scheme values."""
from praisonai.cli.interactive.tui_app import ColorScheme
colors = ColorScheme()
assert colors.primary == "#00aaff"
assert colors.secondary == "#888888"
assert colors.accent == "#00cc00"
assert colors.text == "#ffffff"
assert colors.success == "#00cc00"
assert colors.error == "#ff4444"
class TestTUIConfig:
"""Tests for TUIConfig dataclass."""
def test_default_values(self):
"""Test default TUI configuration values."""
from praisonai.cli.interactive.tui_app import TUIConfig
config = TUIConfig()
assert config.model == "gpt-4o-mini"
assert config.show_logo is True
assert config.show_tips is True
assert config.show_status_bar is True
assert config.compact_mode is False
assert config.multiline is False
assert config.vi_mode is False
def test_custom_values(self):
"""Test custom TUI configuration values."""
from praisonai.cli.interactive.tui_app import TUIConfig
config = TUIConfig(
model="gpt-4o",
show_logo=False,
compact_mode=True,
)
assert config.model == "gpt-4o"
assert config.show_logo is False
assert config.compact_mode is True
class TestMessage:
"""Tests for Message dataclass."""
def test_message_creation(self):
"""Test message creation."""
from praisonai.cli.interactive.tui_app import Message
msg = Message(role="user", content="Hello")
assert msg.role == "user"
assert msg.content == "Hello"
assert msg.timestamp is not None
assert msg.metadata == {}
def test_message_with_metadata(self):
"""Test message with metadata."""
from praisonai.cli.interactive.tui_app import Message
msg = Message(role="assistant", content="Hi", metadata={"tokens": 10})
assert msg.metadata == {"tokens": 10}
class TestGetLogo:
"""Tests for get_logo function."""
def test_logo_wide_terminal(self):
"""Test logo for wide terminal."""
from praisonai.cli.interactive.tui_app import get_logo, LOGO_SMALL
logo = get_logo(100)
assert logo == LOGO_SMALL
def test_logo_medium_terminal(self):
"""Test logo for medium terminal."""
from praisonai.cli.interactive.tui_app import get_logo, LOGO_TINY
logo = get_logo(65)
assert logo == LOGO_TINY
def test_logo_narrow_terminal(self):
"""Test logo for narrow terminal."""
from praisonai.cli.interactive.tui_app import get_logo, LOGO_MINIMAL
logo = get_logo(50)
assert logo == LOGO_MINIMAL
class TestPraisonTUI:
"""Tests for PraisonTUI class."""
def test_init_default(self):
"""Test default initialization."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
assert tui.config is not None
assert tui.messages == []
assert tui._running is False
assert tui.session_id is not None
def test_init_with_config(self):
"""Test initialization with config."""
from praisonai.cli.interactive.tui_app import PraisonTUI, TUIConfig
config = TUIConfig(model="gpt-4o", show_logo=False)
tui = PraisonTUI(config=config)
assert tui.config.model == "gpt-4o"
assert tui.config.show_logo is False
def test_render_logo_disabled(self):
"""Test render_logo when disabled."""
from praisonai.cli.interactive.tui_app import PraisonTUI, TUIConfig
config = TUIConfig(show_logo=False)
tui = PraisonTUI(config=config)
logo = tui._render_logo()
assert logo == ""
def test_render_logo_enabled(self):
"""Test render_logo when enabled."""
from praisonai.cli.interactive.tui_app import PraisonTUI, TUIConfig
config = TUIConfig(show_logo=True, model="gpt-4o-mini")
tui = PraisonTUI(config=config)
logo = tui._render_logo()
assert "PRAISON" in logo or "gpt-4o-mini" in logo
def test_render_welcome(self):
"""Test render_welcome."""
from praisonai.cli.interactive.tui_app import PraisonTUI, TUIConfig
config = TUIConfig(show_logo=True, show_tips=True)
tui = PraisonTUI(config=config)
welcome = tui._render_welcome()
assert "Type your message" in welcome or len(welcome) > 0
def test_render_status_bar(self):
"""Test render_status_bar."""
from praisonai.cli.interactive.tui_app import PraisonTUI, TUIConfig
config = TUIConfig(show_status_bar=True, model="gpt-4o")
tui = PraisonTUI(config=config)
status = tui._render_status_bar()
assert "gpt-4o" in status
def test_render_status_bar_disabled(self):
"""Test render_status_bar when disabled."""
from praisonai.cli.interactive.tui_app import PraisonTUI, TUIConfig
config = TUIConfig(show_status_bar=False)
tui = PraisonTUI(config=config)
status = tui._render_status_bar()
assert status == ""
def test_format_message_user(self):
"""Test format_message for user role."""
from praisonai.cli.interactive.tui_app import PraisonTUI, Message
tui = PraisonTUI()
msg = Message(role="user", content="Hello")
formatted = tui._format_message(msg)
assert "Hello" in formatted
assert "›" in formatted
def test_format_message_assistant(self):
"""Test format_message for assistant role."""
from praisonai.cli.interactive.tui_app import PraisonTUI, Message
tui = PraisonTUI()
msg = Message(role="assistant", content="Hi there")
formatted = tui._format_message(msg)
assert "Hi there" in formatted
assert "●" in formatted
def test_format_message_tool(self):
"""Test format_message for tool role."""
from praisonai.cli.interactive.tui_app import PraisonTUI, Message
tui = PraisonTUI()
msg = Message(role="tool", content="Running command")
formatted = tui._format_message(msg)
assert "Running command" in formatted
assert "⚙" in formatted
def test_handle_command_exit(self):
"""Test handling exit command."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
tui._running = True
result = tui._handle_command("/exit")
assert result is True
assert tui._running is False
def test_handle_command_quit(self):
"""Test handling quit command."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
tui._running = True
result = tui._handle_command("/quit")
assert result is True
assert tui._running is False
def test_handle_command_help(self):
"""Test handling help command."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
result = tui._handle_command("/help")
assert result is True
# Help should add a system message
assert len(tui.messages) == 1
assert tui.messages[0].role == "system"
def test_handle_command_clear(self):
"""Test handling clear command."""
from praisonai.cli.interactive.tui_app import PraisonTUI, Message
tui = PraisonTUI()
tui.messages = [Message(role="user", content="test")]
result = tui._handle_command("/clear")
assert result is True
assert len(tui.messages) == 0
def test_handle_command_model_show(self):
"""Test handling model command (show)."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
result = tui._handle_command("/model")
assert result is True
assert len(tui.messages) == 1
assert "gpt-4o-mini" in tui.messages[0].content
def test_handle_command_model_change(self):
"""Test handling model command (change)."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
result = tui._handle_command("/model gpt-4o")
assert result is True
assert tui.config.model == "gpt-4o"
def test_handle_command_cost(self):
"""Test handling cost command."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
tui._total_tokens = 100
tui._total_cost = 0.01
result = tui._handle_command("/cost")
assert result is True
assert len(tui.messages) == 1
assert "100" in tui.messages[0].content
def test_handle_command_compact(self):
"""Test handling compact command."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
original = tui.config.compact_mode
result = tui._handle_command("/compact")
assert result is True
assert tui.config.compact_mode != original
def test_handle_command_multiline(self):
"""Test handling multiline command."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
original = tui.config.multiline
result = tui._handle_command("/multiline")
assert result is True
assert tui.config.multiline != original
def test_handle_command_unknown(self):
"""Test handling unknown command."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
result = tui._handle_command("/unknown")
assert result is True
assert len(tui.messages) == 1
assert "Unknown" in tui.messages[0].content
class TestStartTUI:
"""Tests for start_tui function."""
def test_function_exists(self):
"""Test that start_tui function exists."""
from praisonai.cli.interactive.tui_app import start_tui
assert callable(start_tui)
class TestASCIILogos:
"""Tests for ASCII logo constants."""
def test_logo_large_exists(self):
"""Test LOGO_LARGE constant exists."""
from praisonai.cli.interactive.tui_app import LOGO_LARGE
assert len(LOGO_LARGE) > 0
assert "PRAISON" in LOGO_LARGE or "██" in LOGO_LARGE
def test_logo_small_exists(self):
"""Test LOGO_SMALL constant exists."""
from praisonai.cli.interactive.tui_app import LOGO_SMALL
assert len(LOGO_SMALL) > 0
def test_logo_tiny_exists(self):
"""Test LOGO_TINY constant exists."""
from praisonai.cli.interactive.tui_app import LOGO_TINY
assert len(LOGO_TINY) > 0
def test_logo_minimal_exists(self):
"""Test LOGO_MINIMAL constant exists."""
from praisonai.cli.interactive.tui_app import LOGO_MINIMAL
assert "PraisonAI" in LOGO_MINIMAL
@@ -140,7 +140,11 @@ class TestFeatureDiscovery:
cli_root.mkdir()
(cli_root / "features").mkdir()
config = StandardiseConfig(sdk_root=sdk_root, docs_root=docs_root, cli_root=cli_root)
# Create empty examples root to ensure no examples found
examples_root = tmp_path / "examples"
examples_root.mkdir()
config = StandardiseConfig(sdk_root=sdk_root, docs_root=docs_root, cli_root=cli_root, examples_root=examples_root)
discovery = FeatureDiscovery(config)
slug = FeatureSlug.from_string("guardrails")
@@ -0,0 +1,121 @@
"""
Unit tests for the ExampleVerifier.
"""
from praisonai.standardise.example_verifier import ExampleVerifier, VerificationResult
class TestExampleVerifier:
"""Tests for ExampleVerifier."""
def test_valid_simple_code(self):
"""Test verification of valid simple code."""
verifier = ExampleVerifier(timeout=10)
code = '''
print("Hello, World!")
'''
result = verifier.verify(code)
assert result.syntax_valid
assert result.execution_passed
assert result.success
assert result.can_write
def test_syntax_error(self):
"""Test detection of syntax errors."""
verifier = ExampleVerifier(timeout=10)
code = '''
def broken(
print("missing closing paren"
'''
result = verifier.verify(code)
assert not result.syntax_valid
assert not result.success
assert not result.can_write
assert "Syntax error" in result.error
def test_runtime_error(self):
"""Test detection of runtime errors."""
verifier = ExampleVerifier(timeout=10)
code = '''
x = 1 / 0 # Division by zero
'''
result = verifier.verify(code)
assert result.syntax_valid
assert not result.execution_passed
assert not result.success
assert "ZeroDivisionError" in result.error
def test_missing_import(self):
"""Test detection of missing imports."""
verifier = ExampleVerifier(timeout=10)
code = '''
import nonexistent_module_xyz
'''
result = verifier.verify(code)
assert result.syntax_valid
assert not result.execution_passed
assert "nonexistent_module_xyz" in result.missing_libraries
def test_external_library_detection(self):
"""Test detection of external library requirements."""
verifier = ExampleVerifier(timeout=10)
code = '''
import pandas
df = pandas.DataFrame()
'''
result = verifier.verify(code)
assert result.syntax_valid
# May or may not pass depending on if pandas is installed
# But should detect pandas as external
assert "pandas" in result.missing_libraries or result.execution_passed
def test_can_write_with_external_libs(self):
"""Test that code requiring external libs can still be written."""
# Create a result that simulates external lib requirement
result = VerificationResult(
success=True,
syntax_valid=True,
execution_passed=False,
output="",
error="ModuleNotFoundError: No module named 'requests'",
missing_libraries=["requests"],
requires_external=True,
)
assert result.can_write # Should allow writing despite execution failure
def test_timeout_handling(self):
"""Test that infinite loops are handled."""
verifier = ExampleVerifier(timeout=2)
code = '''
import time
time.sleep(10) # Will timeout
'''
result = verifier.verify(code)
assert result.syntax_valid
assert not result.execution_passed
assert "timed out" in result.error.lower()
def test_format_result(self):
"""Test result formatting."""
verifier = ExampleVerifier()
result = VerificationResult(
success=True,
syntax_valid=True,
execution_passed=True,
output="Hello",
error="",
missing_libraries=[],
requires_external=False,
)
formatted = verifier.format_result(result)
assert "PASSED" in formatted
assert "Syntax valid: ✓" in formatted
@@ -0,0 +1,207 @@
"""
Unit tests for the undo/redo system.
"""
from unittest.mock import patch
from praisonai.standardise.undo_redo import UndoRedoManager, FileBackupManager
class TestUndoRedoManager:
"""Tests for UndoRedoManager."""
def test_init_with_path(self, tmp_path):
"""Test initialization with a path."""
manager = UndoRedoManager(tmp_path)
assert manager.repo_path == tmp_path
def test_checkpoint_prefix(self):
"""Test checkpoint prefix constant."""
assert UndoRedoManager.CHECKPOINT_PREFIX == "standardise-checkpoint-"
@patch.object(UndoRedoManager, '_run_git')
def test_create_checkpoint_success(self, mock_git, tmp_path):
"""Test successful checkpoint creation."""
mock_git.side_effect = [
(True, ""), # git add
(False, "changes"), # git diff (has changes)
(True, ""), # git commit
(True, ""), # git tag
]
manager = UndoRedoManager(tmp_path)
success, result = manager.create_checkpoint("Test checkpoint")
assert success
assert result.startswith("standardise-checkpoint-")
@patch.object(UndoRedoManager, '_run_git')
def test_create_checkpoint_no_changes(self, mock_git, tmp_path):
"""Test checkpoint creation with no changes."""
mock_git.side_effect = [
(True, ""), # git add
(True, ""), # git diff (no changes)
(True, ""), # git tag
]
manager = UndoRedoManager(tmp_path)
success, result = manager.create_checkpoint()
assert success
@patch.object(UndoRedoManager, '_run_git')
def test_list_checkpoints(self, mock_git, tmp_path):
"""Test listing checkpoints."""
mock_git.return_value = (True, "standardise-checkpoint-20240101|2024-01-01|Test message\n")
manager = UndoRedoManager(tmp_path)
checkpoints = manager.list_checkpoints()
assert len(checkpoints) == 1
assert checkpoints[0][0] == "standardise-checkpoint-20240101"
assert checkpoints[0][1] == "2024-01-01"
assert checkpoints[0][2] == "Test message"
@patch.object(UndoRedoManager, '_run_git')
def test_list_checkpoints_empty(self, mock_git, tmp_path):
"""Test listing checkpoints when none exist."""
mock_git.return_value = (True, "")
manager = UndoRedoManager(tmp_path)
checkpoints = manager.list_checkpoints()
assert checkpoints == []
@patch.object(UndoRedoManager, '_run_git')
def test_undo_success(self, mock_git, tmp_path):
"""Test successful undo."""
mock_git.side_effect = [
(True, ""), # rev-parse (checkpoint exists)
(True, ""), # git add (safety checkpoint)
(True, ""), # git diff
(True, ""), # git tag
(True, ""), # git reset
]
manager = UndoRedoManager(tmp_path)
success, result = manager.undo("standardise-checkpoint-test")
assert success
assert "Restored" in result
@patch.object(UndoRedoManager, '_run_git')
def test_undo_checkpoint_not_found(self, mock_git, tmp_path):
"""Test undo with non-existent checkpoint."""
mock_git.return_value = (False, "not found")
manager = UndoRedoManager(tmp_path)
success, result = manager.undo("nonexistent")
assert not success
assert "not found" in result
@patch.object(UndoRedoManager, '_run_git')
def test_redo_no_redo_available(self, mock_git, tmp_path):
"""Test redo when no redo is available."""
mock_git.return_value = (True, "abc123 HEAD@{0}: commit: test\n")
manager = UndoRedoManager(tmp_path)
success, result = manager.redo()
assert not success
assert "No redo available" in result
@patch.object(UndoRedoManager, '_run_git')
def test_delete_checkpoint(self, mock_git, tmp_path):
"""Test deleting a checkpoint."""
mock_git.return_value = (True, "")
manager = UndoRedoManager(tmp_path)
success, result = manager.delete_checkpoint("test-checkpoint")
assert success
assert "Deleted" in result
@patch.object(UndoRedoManager, '_run_git')
def test_get_changes_since(self, mock_git, tmp_path):
"""Test getting changes since a checkpoint."""
mock_git.return_value = (True, "file1.py\nfile2.py\n")
manager = UndoRedoManager(tmp_path)
changes = manager.get_changes_since("test-checkpoint")
assert len(changes) == 2
assert "file1.py" in changes
assert "file2.py" in changes
class TestFileBackupManager:
"""Tests for FileBackupManager."""
def test_init_creates_backup_dir(self, tmp_path):
"""Test that init creates backup directory."""
backup_dir = tmp_path / "backups"
_ = FileBackupManager(backup_dir) # Creates dir on init
assert backup_dir.exists()
def test_backup_file(self, tmp_path):
"""Test backing up a file."""
# Create a test file
test_file = tmp_path / "test.txt"
test_file.write_text("test content")
backup_dir = tmp_path / "backups"
manager = FileBackupManager(backup_dir)
backup_path = manager.backup_file(test_file)
assert backup_path is not None
assert backup_path.exists()
assert backup_path.read_text() == "test content"
def test_backup_nonexistent_file(self, tmp_path):
"""Test backing up a non-existent file."""
manager = FileBackupManager(tmp_path / "backups")
result = manager.backup_file(tmp_path / "nonexistent.txt")
assert result is None
def test_restore_file(self, tmp_path):
"""Test restoring a file from backup."""
# Create backup
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
backup_file = backup_dir / "backup.txt"
backup_file.write_text("backup content")
# Target file
target_file = tmp_path / "restored.txt"
manager = FileBackupManager(backup_dir)
result = manager.restore_file(backup_file, target_file)
assert result
assert target_file.exists()
assert target_file.read_text() == "backup content"
def test_list_backups(self, tmp_path):
"""Test listing backups."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
# Create some backup files
(backup_dir / "file1_20240101.txt").write_text("1")
(backup_dir / "file1_20240102.txt").write_text("2")
(backup_dir / "file2_20240101.txt").write_text("3")
backup_manager = FileBackupManager(backup_dir)
# List all
all_backups = backup_manager.list_backups()
assert len(all_backups) == 3
# List filtered
file1_backups = backup_manager.list_backups("file1")
assert len(file1_backups) == 2
+4 -4
View File
@@ -4276,7 +4276,7 @@ requires-dist = [
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
{ name = "praisonaiagents", specifier = ">=0.11.1" },
{ name = "praisonaiagents", specifier = ">=0.11.3" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
{ name = "pydantic", marker = "extra == 'chat'", specifier = "<=2.10.1" },
{ name = "pydantic", marker = "extra == 'code'", specifier = "<=2.10.1" },
@@ -4324,7 +4324,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.11.1"
version = "0.11.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -4333,9 +4333,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/42/8fe814f73b19a7decd12bca9e3c720a97906658bf993fb4c3063505531fb/praisonaiagents-0.11.1.tar.gz", hash = "sha256:b0f3720d7d5334fb9aaeb8b8e62cc1a20b9ce9c1cad489031414326479a3c23f", size = 963761, upload-time = "2026-01-10T14:23:57.185Z" }
sdist = { url = "https://files.pythonhosted.org/packages/96/4a/ab73d7381b0e9139f722dad51f9cb63575032c3d11aa83bc281f76bfd13a/praisonaiagents-0.11.3.tar.gz", hash = "sha256:5aa024935184eaa66578ff4c888e06b052d55568c68bfb702dd35c8a7951e10b", size = 973113, upload-time = "2026-01-11T07:29:29.515Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/97/d7d465eec9443b80fd79daa57bbac7292b680a8702b5d6ef3fb82899bf7d/praisonaiagents-0.11.1-py3-none-any.whl", hash = "sha256:3792747bd2f449995bb4f77eb9086c1b9a88bc2f89414dd447e3d687449c389b", size = 943019, upload-time = "2026-01-10T14:23:54.691Z" },
{ url = "https://files.pythonhosted.org/packages/a2/05/f142e42a88cd61d0af4391be3e97c5ecfe660deeed96ebcdc31f95180977/praisonaiagents-0.11.3-py3-none-any.whl", hash = "sha256:a78dcbaa3eb66cfab52a4c9789654c58eb71b0ea34b2f0dcca54baff91917e7d", size = 954407, upload-time = "2026-01-11T07:29:26.456Z" },
]
[[package]]