Compare commits

...
14 Commits
Author SHA1 Message Date
Mervin Praison bdaf5340f9 Release v3.8.0 2026-01-12 12:49:16 +00:00
Mervin Praison 5579614001 Release v3.7.9 2026-01-12 09:10:46 +00:00
Mervin Praison 2c4dd0e2d4 Release v3.7.8 2026-01-12 07:39:39 +00:00
Mervin Praison fd23d62841 Release v3.7.7 2026-01-11 21:30:04 +00:00
Mervin Praison 5f3bcb78ac Release v3.7.6 2026-01-11 08:23:08 +00:00
Mervin Praison 3c40c2278e Release v3.7.5 2026-01-11 07:34:59 +00:00
Mervin Praison 1921b03aab chore: remove empty trace and comparison test files 2026-01-10 21:09:11 +00:00
Mervin Praison f33bf57662 Release v3.7.4 2026-01-10 21:06:16 +00:00
github-actions[bot] df21c419ea docs: auto-update api.md [skip ci] 2026-01-10 19:45:30 +00:00
Mervin Praison fe997d525c ci: add workflow to auto-update api.md on main branch push 2026-01-10 19:45:04 +00:00
Mervin Praison ca26988774 Release v3.7.3 2026-01-10 19:43:43 +00:00
Mervin Praison 3600594316 Before AI example generation 2026-01-10 16:54:16 +00:00
Mervin Praison 097cd42890 Before testing 2026-01-10 16:04:21 +00:00
Mervin Praison 595add2991 Before testing AI generation 2026-01-10 15:55:26 +00:00
86 changed files with 17298 additions and 266 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>"
+2 -1
View File
@@ -83,4 +83,5 @@ issue_details.json
# MCP Registry tokens
.mcpregistry_*
*.log
*.log
.internal
+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.8.0" \
"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.8.0" \
"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.8.0" \
"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',
]
@@ -44,7 +44,7 @@ _shared_apps = {} # Dict of port -> FastAPI app
if TYPE_CHECKING:
from ..task.task import Task
from ..main import TaskOutput
from ..handoff import Handoff
from .handoff import Handoff, HandoffConfig, HandoffResult
from ..rag.models import RAGResult, ContextPack
class Agent:
@@ -291,96 +291,87 @@ class Agent:
"""Initialize an Agent instance.
Args:
name (Optional[str], optional): Name of the agent used for identification and logging.
If None, defaults to "Agent". Defaults to None.
role (Optional[str], optional): Role or job title that defines the agent's expertise
and behavior patterns. Examples: "Data Analyst", "Content Writer". Defaults to None.
goal (Optional[str], optional): Primary objective or goal the agent aims to achieve.
Defines the agent's purpose and success criteria. Defaults to None.
backstory (Optional[str], optional): Background story or context that shapes the agent's
personality and decision-making approach. Defaults to None.
instructions (Optional[str], optional): Direct instructions that override role, goal,
and backstory when provided. Used for simple, task-specific agents. Defaults to None.
llm (Optional[Union[str, Any]], optional): Language model configuration. Can be a model
name string (e.g., "gpt-5-nano", "anthropic/claude-3-sonnet") or a configured LLM object.
Defaults to environment variable OPENAI_MODEL_NAME or "gpt-5-nano".
tools (Optional[List[Any]], optional): List of tools, functions, or capabilities
available to the agent for task execution. Can include callables, tool objects,
or MCP instances. Defaults to None.
function_calling_llm (Optional[Any], optional): Dedicated language model for function
calling operations. If None, uses the main llm parameter. Defaults to None.
max_iter (int, optional): Maximum number of iterations the agent can perform during
task execution to prevent infinite loops. Defaults to 20.
max_rpm (Optional[int], optional): Maximum requests per minute to rate limit API calls
and prevent quota exhaustion. If None, no rate limiting is applied. Defaults to None.
max_execution_time (Optional[int], optional): Maximum execution time in seconds for
agent operations before timeout. If None, no time limit is enforced. Defaults to None.
memory (Optional[Any], optional): Memory system for storing and retrieving information
across conversations. Requires memory dependencies to be installed. Defaults to None.
verbose (bool, optional): Enable detailed logging and status updates during agent
execution for debugging and monitoring. Defaults to True.
allow_delegation (bool, optional): Allow the agent to delegate tasks to other agents
or sub-processes when appropriate. Defaults to False.
step_callback (Optional[Any], optional): Callback function called after each step
of agent execution for custom monitoring or intervention. Defaults to None.
cache (bool, optional): Enable caching of responses and computations to improve
performance and reduce API costs. Defaults to True.
system_template (Optional[str], optional): Custom template for system prompts that
overrides the default system prompt generation. Defaults to None.
prompt_template (Optional[str], optional): Template for formatting user prompts
before sending to the language model. Defaults to None.
response_template (Optional[str], optional): Template for formatting agent responses
before returning to the user. Defaults to None.
allow_code_execution (Optional[bool], optional): Enable the agent to execute code
snippets during task completion. Use with caution for security. Defaults to False.
max_retry_limit (int, optional): Maximum number of retry attempts for failed operations
before giving up. Helps handle transient errors. Defaults to 2.
code_execution_mode (Literal["safe", "unsafe"], optional): Safety mode for code execution.
"safe" restricts dangerous operations, "unsafe" allows full code execution. Defaults to "safe".
embedder_config (Optional[Dict[str, Any]], optional): Configuration dictionary for
text embedding models used in knowledge retrieval and similarity search. Defaults to None.
knowledge (Optional[List[str]], optional): List of knowledge sources (file paths, URLs,
or text content) to be processed and made available to the agent. Defaults to None.
knowledge_config (Optional[Dict[str, Any]], optional): Configuration for knowledge
processing and retrieval system including chunking and indexing parameters. Defaults to None.
use_system_prompt (Optional[bool], optional): Whether to include system prompts in
conversations to establish agent behavior and context. Defaults to True.
markdown (bool, optional): Enable markdown formatting in agent responses for better
readability and structure. Defaults to True.
stream (bool, optional): Enable streaming responses from the language model for real-time
output when using Agent.start() method. Defaults to False for backward compatibility.
metrics (bool, optional): Enable automatic token usage tracking and display summary
when tasks complete. Simplifies token monitoring for cost optimization. Defaults to False.
self_reflect (bool, optional): Enable self-reflection capabilities where the agent
evaluates and improves its own responses. Defaults to False.
max_reflect (int, optional): Maximum number of self-reflection iterations to prevent
excessive reflection loops. Defaults to 3.
min_reflect (int, optional): Minimum number of self-reflection iterations required
before accepting a response as satisfactory. Defaults to 1.
reflect_llm (Optional[str], optional): Dedicated language model for self-reflection
operations. If None, uses the main llm parameter. Defaults to None.
reflect_prompt (Optional[str], optional): Custom prompt template for self-reflection
that guides the agent's self-evaluation process. Defaults to None.
user_id (Optional[str], optional): Unique identifier for the user or session to
enable personalized responses and memory isolation. Defaults to "praison".
reasoning_steps (bool, optional): Enable step-by-step reasoning output to show the
agent's thought process during problem solving. Defaults to False.
guardrail (Optional[Union[Callable[['TaskOutput'], Tuple[bool, Any]], str]], optional):
Safety mechanism to validate agent outputs. Can be a validation function or
description string for LLM-based validation. Defaults to None.
max_guardrail_retries (int, optional): Maximum number of retry attempts when guardrail
validation fails before giving up. Defaults to 3.
handoffs (Optional[List[Union['Agent', 'Handoff']]], optional): List of agents or
handoff configurations that this agent can delegate tasks to. Enables agent-to-agent
collaboration and task specialization. Defaults to None.
base_url (Optional[str], optional): Base URL for custom LLM endpoints (e.g., Ollama).
If provided, automatically creates a custom LLM instance. Defaults to None.
api_key (Optional[str], optional): API key for LLM provider. If not provided,
falls back to environment variables. Defaults to None.
name: Agent name for identification and logging. Defaults to "Agent".
role: Role/job title defining expertise (e.g., "Data Analyst").
goal: Primary objective the agent aims to achieve.
backstory: Background context shaping personality and decisions.
instructions: Direct instructions (overrides role/goal/backstory). Recommended for simple agents.
llm: Model name string ("gpt-4o", "anthropic/claude-3-sonnet") or LLM object.
Defaults to OPENAI_MODEL_NAME env var or "gpt-5-nano".
model: Alias for llm parameter.
function_calling_llm: Dedicated LLM for function calling. Deprecated: use llm=.
llm_config: LLM configuration dict. Deprecated: use llm=.
base_url: Custom LLM endpoint URL (e.g., for Ollama). Kept separate for auth.
api_key: API key for LLM provider. Kept separate for auth.
tools: List of tools, functions, callables, or MCP instances.
allow_delegation: Allow task delegation to other agents. Defaults to False.
allow_code_execution: Enable code execution during tasks. Defaults to False.
code_execution_mode: "safe" (restricted) or "unsafe" (full access). Defaults to "safe".
handoffs: List of Agent or Handoff objects for agent-to-agent collaboration.
auto_save: Session name for automatic session saving.
rate_limiter: Rate limiter instance for API call throttling.
memory: Memory system configuration. Accepts:
- bool: True enables defaults, False disables
- MemoryConfig: Custom configuration
- MemoryManager: Pre-configured instance
knowledge: Knowledge sources. Accepts:
- bool: True enables defaults
- List[str]: File paths, URLs, or text content
- KnowledgeConfig: Custom configuration
planning: Planning mode. Accepts:
- bool: True enables with defaults
- PlanningConfig: Custom configuration
reflection: Self-reflection. Accepts:
- bool: True enables with defaults
- ReflectionConfig: Custom configuration
guardrails: Output validation. Accepts:
- bool: True enables with defaults
- Callable: Validation function
- GuardrailConfig: Custom configuration
web: Web search/fetch. Accepts:
- bool: True enables with defaults
- WebConfig: Custom configuration
context: Context management. Accepts:
- bool: True enables with defaults
- ManagerConfig: Custom configuration
autonomy: Autonomy settings. Accepts:
- bool: True enables with defaults
- Dict: Configuration dict
- AutonomyConfig: Custom configuration
verification_hooks: List of VerificationHook instances for output verification.
output: Output configuration. Accepts:
- str: Preset name ("silent", "actions", "verbose", "json", "stream")
- OutputConfig: Custom configuration
Controls: verbose, markdown, stream, metrics, reasoning_steps
execution: Execution configuration. Accepts:
- str: Preset name ("fast", "balanced", "thorough")
- ExecutionConfig: Custom configuration
Controls: max_iter, max_rpm, max_execution_time, max_retry_limit
templates: Template configuration (TemplateConfig).
Controls: system_template, prompt_template, response_template
caching: Caching configuration. Accepts:
- bool: True enables with defaults
- CachingConfig: Custom configuration
hooks: Event hooks. Accepts:
- List: List of hook callables
- HooksConfig: Custom configuration
skills: Agent skills. Accepts:
- List[str]: Skill directory paths
- SkillsConfig: Custom configuration
Raises:
ValueError: If all of name, role, goal, backstory, and instructions are None.
ImportError: If memory or LLM features are requested but dependencies are not installed.
Note:
Many legacy parameters have been consolidated into config objects:
- verbose, markdown, stream, metrics, reasoning_steps → output=
- max_iter, max_rpm, max_execution_time, max_retry_limit → execution=
- self_reflect, max_reflect, min_reflect, reflect_llm → reflection=
- guardrail, max_guardrail_retries → guardrails=
- system_template, prompt_template, response_template → templates=
- cache, prompt_caching → caching=
- web_search, web_fetch → web=
"""
# Add check at start if memory is requested
if memory is not None:
@@ -447,14 +438,27 @@ class Agent:
# ─────────────────────────────────────────────────────────────────────
# Resolve OUTPUT param using unified resolver
# Supports: None, str preset, list [preset, overrides], OutputConfig
# DEFAULT: "silent" mode (zero overhead, fastest performance)
# Can be overridden by PRAISONAI_OUTPUT env var
# ─────────────────────────────────────────────────────────────────────
from ..config.presets import DEFAULT_OUTPUT_MODE
# Check for env var override
env_output = os.environ.get('PRAISONAI_OUTPUT', '').lower()
if env_output and env_output in OUTPUT_PRESETS and output is None:
output = env_output
# Use default output mode if not specified
if output is None:
output = DEFAULT_OUTPUT_MODE
_output_config = resolve(
value=output,
param_name="output",
config_class=OutputConfig,
presets=OUTPUT_PRESETS,
array_mode=ArrayMode.PRESET_OVERRIDE,
default=OutputConfig(),
default=OutputConfig(), # OutputConfig defaults to silent mode (zero overhead)
)
if _output_config:
verbose = _output_config.verbose
@@ -463,8 +467,24 @@ class Agent:
metrics = _output_config.metrics
reasoning_steps = _output_config.reasoning_steps
output_style = getattr(_output_config, 'style', None)
actions_trace = getattr(_output_config, 'actions_trace', False) # Default False (silent)
json_output = getattr(_output_config, 'json_output', False)
else:
verbose, markdown, stream, metrics, reasoning_steps = True, True, False, False, False
# Fallback defaults match silent mode (zero overhead)
verbose, markdown, stream, metrics, reasoning_steps = False, False, False, False, False
actions_trace = False # No callbacks by default
json_output = False
# Enable actions output mode if configured
# This registers callbacks to capture tool calls and final output
if actions_trace:
try:
from ..output.actions import enable_actions_mode, is_actions_mode_enabled
if not is_actions_mode_enabled():
output_format = "jsonl" if json_output else "text"
enable_actions_mode(redact=True, use_color=True, format=output_format)
except ImportError:
pass # Actions module not available
# ─────────────────────────────────────────────────────────────────────
# Resolve EXECUTION param using unified resolver
@@ -1065,6 +1085,9 @@ Your Goal: {self.goal}
self._context_param = context # Store raw param for lazy init
self._context_manager = None # Lazy initialized on first use
self._context_manager_initialized = False
# Action trace mode - handled via display callbacks, not separate emitter
self._actions_trace = actions_trace
@property
def auto_memory(self):
@@ -4235,16 +4258,86 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
return None
async def arun(self, prompt: str, **kwargs):
"""Async alias for astart() method"""
return await self.astart(prompt, **kwargs)
"""Async version of run() - silent, non-streaming, returns structured result.
Production-friendly async execution. Does not stream or display output.
Args:
prompt: The input prompt to process
**kwargs: Additional arguments passed to achat()
Returns:
The agent's response as a string
"""
# Force non-streaming, non-display for production use
kwargs['stream'] = False
return await self.achat(prompt, **kwargs)
async def astart(self, prompt: str, **kwargs):
"""Async version of start method"""
"""Async version of start() - interactive, streaming-aware.
Beginner-friendly async execution. Streams by default when in TTY.
Args:
prompt: The input prompt to process
**kwargs: Additional arguments passed to achat()
Returns:
The agent's response as a string
"""
import sys
# Determine streaming behavior (same logic as start())
stream_requested = kwargs.get('stream')
if stream_requested is None:
if getattr(self, 'stream', None) is not None:
stream_requested = self.stream
else:
stream_requested = sys.stdout.isatty()
kwargs['stream'] = stream_requested
return await self.achat(prompt, **kwargs)
def run(self, prompt: str, **kwargs):
"""Alias for start() method"""
return self.start(prompt, **kwargs)
"""Execute agent silently and return structured result.
Production-friendly execution. Does not stream or display output by default.
Use this for programmatic/scripted usage where you want the result only.
Args:
prompt: The input prompt to process
**kwargs: Additional arguments:
- stream (bool): Force streaming if True. Default: False
- display (bool): Force terminal display if True. Default: False
- output (str): Output preset override
Returns:
The agent's response as a string
Example:
```python
agent = Agent(instructions="You are helpful")
result = agent.run("What is 2+2?") # Silent, returns "4"
print(result)
```
"""
# Production defaults: no streaming, no display
if 'stream' not in kwargs:
kwargs['stream'] = False
# Load history context
self._load_history_context()
# Check if planning mode is enabled
if self.planning:
result = self._start_with_planning(prompt, **kwargs)
else:
result = self.chat(prompt, **kwargs)
# Auto-save session if enabled
self._auto_save_session()
return result
def _get_planning_agent(self):
"""Lazy load PlanningAgent for planning mode."""
@@ -4379,18 +4472,59 @@ Write the complete compiled report:"""
# Chat history is preserved in self.chat_history (no action needed)
def start(self, prompt: str, **kwargs):
"""Start the agent with a prompt. This is a convenience method that wraps chat()."""
# Load history from past sessions (now handled via context= param)
"""Start the agent interactively with streaming output.
Beginner-friendly execution. Streams output by default when running in a TTY.
Use this for interactive/terminal usage where you want to see output in real-time.
Args:
prompt: The input prompt to process
**kwargs: Additional arguments:
- stream (bool | None): Override streaming. None = auto-detect TTY
- display (bool | None): Override display. None = auto (True if streaming)
- output (str): Output preset override
Returns:
- If streaming: Generator yielding response chunks
- If not streaming: The complete response as a string
Example:
```python
agent = Agent(instructions="You are helpful")
# Interactive use - streams by default in terminal
for chunk in agent.start("Tell me a story"):
print(chunk, end="", flush=True)
# Or let it handle display automatically
result = agent.start("What is 2+2?") # Streams if TTY
```
"""
import sys
# Load history from past sessions
self._load_history_context()
# Determine streaming behavior
# Priority: explicit kwarg > agent's stream attribute > TTY detection
stream_requested = kwargs.get('stream')
if stream_requested is None:
# Check agent's stream attribute first
if getattr(self, 'stream', None) is not None:
stream_requested = self.stream
else:
# Auto-detect: stream if stdout is a TTY (interactive terminal)
stream_requested = sys.stdout.isatty()
# Check if planning mode is enabled
if self.planning:
result = self._start_with_planning(prompt, **kwargs)
elif kwargs.get('stream', getattr(self, 'stream', False)):
elif stream_requested:
# Return a generator for streaming response
kwargs['stream'] = True
result = self._start_stream(prompt, **kwargs)
else:
# Return regular chat response for backward compatibility
# Return regular chat response
kwargs['stream'] = False
result = self.chat(prompt, **kwargs)
@@ -4399,6 +4533,48 @@ Write the complete compiled report:"""
return result
def iter_stream(self, prompt: str, **kwargs):
"""Stream agent response as an iterator of chunks.
App-friendly streaming. Yields response chunks without terminal display.
Use this for building custom UIs or processing streams programmatically.
Args:
prompt: The input prompt to process
**kwargs: Additional arguments:
- display (bool): Show terminal output. Default: False
- output (str): Output preset override
Yields:
str: Response chunks as they are generated
Example:
```python
agent = Agent(instructions="You are helpful")
# Process stream programmatically
full_response = ""
for chunk in agent.iter_stream("Tell me a story"):
full_response += chunk
# Custom processing here
# Or collect all at once
response = "".join(agent.iter_stream("Hello"))
```
"""
# Load history context
self._load_history_context()
# Force streaming, no display by default (app-friendly)
kwargs['stream'] = True
# Use the internal streaming generator
for chunk in self._start_stream(prompt, **kwargs):
yield chunk
# Auto-save session if enabled
self._auto_save_session()
def _load_history_context(self):
"""Load history from past sessions into context.
@@ -391,10 +391,21 @@ class OutputConfig:
Consolidates: verbose, markdown, stream, metrics, reasoning_steps, output_style
DEFAULT: output="silent" (zero overhead, fastest performance)
Usage:
# Simple preset
# Default is silent mode (no output overhead, programmatic use)
Agent(instructions="...") # Uses output="silent"
# Actions mode (tool calls + final output trace)
Agent(output="actions")
# Verbose mode with Rich panels
Agent(output="verbose")
# JSON mode for piping
Agent(output="json")
# With config
Agent(output=OutputConfig(
verbose=True,
@@ -404,11 +415,11 @@ class OutputConfig:
reasoning_steps=True,
))
"""
# Verbosity
verbose: bool = True
# Verbosity - False by default for silent mode (fastest)
verbose: bool = False
# Formatting
markdown: bool = True
# Formatting - False by default for silent mode
markdown: bool = False
# Streaming
stream: bool = False
@@ -422,6 +433,14 @@ class OutputConfig:
# Output style (custom styling)
style: Optional[Any] = None
# Actions trace mode - shows tool calls, agent lifecycle, final output
# When True, registers callbacks and outputs to stderr
# DEFAULT: False for zero overhead (silent mode)
actions_trace: bool = False
# JSON output mode - emit JSONL events for piping
json_output: bool = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
@@ -430,6 +449,8 @@ class OutputConfig:
"stream": self.stream,
"metrics": self.metrics,
"reasoning_steps": self.reasoning_steps,
"actions_trace": self.actions_trace,
"json_output": self.json_output,
}
@@ -41,6 +41,37 @@ MEMORY_URL_SCHEMES: Dict[str, str] = {
# =============================================================================
OUTPUT_PRESETS: Dict[str, Dict[str, Any]] = {
# Silent preset - DEFAULT for SDK
# Zero overhead, no callbacks, no I/O on hot path
# Fastest performance for programmatic use
"silent": {
"verbose": False,
"markdown": False,
"stream": False,
"metrics": False,
"reasoning_steps": False,
"actions_trace": False,
},
# Actions preset - opt-in observability
# Shows action trace (tool calls, agent lifecycle) + final output
# Registers callbacks, outputs to stderr
"actions": {
"verbose": False,
"markdown": False,
"stream": False,
"metrics": False,
"reasoning_steps": False,
"actions_trace": True, # Special flag for action trace mode
},
# Plain preset - final output only, no action trace
"plain": {
"verbose": False,
"markdown": False,
"stream": False,
"metrics": False,
"reasoning_steps": False,
"actions_trace": False,
},
"minimal": {
"verbose": False,
"markdown": False,
@@ -69,13 +100,6 @@ OUTPUT_PRESETS: Dict[str, Dict[str, Any]] = {
"metrics": True,
"reasoning_steps": True,
},
"silent": {
"verbose": False,
"markdown": False,
"stream": False,
"metrics": False,
"reasoning_steps": False,
},
# Streaming preset - enables streaming by default
"stream": {
"verbose": True,
@@ -84,8 +108,23 @@ OUTPUT_PRESETS: Dict[str, Dict[str, Any]] = {
"metrics": False,
"reasoning_steps": False,
},
# JSON preset - JSONL output for piping
"json": {
"verbose": False,
"markdown": False,
"stream": False,
"metrics": False,
"reasoning_steps": False,
"actions_trace": True,
"json_output": True, # Output as JSONL
},
}
# Default output mode - can be overridden by PRAISONAI_OUTPUT env var
# "silent" = zero overhead, fastest performance (DEFAULT)
# "actions" = tool call trace + final output (opt-in observability)
DEFAULT_OUTPUT_MODE = "silent"
# =============================================================================
# Execution Presets
@@ -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,68 @@
"""
Trace Module for PraisonAI Agents.
Provides lightweight action tracing for the `output="actions"` mode.
Shows agent lifecycle events, tool calls, and final output without
verbose internal details.
Zero Performance Impact:
- All imports are lazy loaded via __getattr__
- NoOpSink is the default (zero overhead when not used)
- Disabled emitter has near-zero overhead
Usage:
from praisonaiagents import Agent
# Simple usage - shows action trace
agent = Agent(instructions="...", output="actions")
agent.start("Do something")
# Advanced - capture to file
from praisonaiagents.trace import ActionTraceConfig
agent = Agent(
instructions="...",
output="actions",
trace=ActionTraceConfig(
sink_type="jsonl",
file_path="trace.jsonl",
)
)
"""
__all__ = [
# Core types
"ActionEvent",
"ActionEventType",
"ActionTraceConfig",
# Sinks
"TraceSink",
"NoOpSink",
"ListSink",
# Emitter
"TraceEmitter",
# Redaction
"redact_dict",
"REDACT_KEYS",
]
def __getattr__(name: str):
"""Lazy load module components to avoid import overhead."""
if name in ("ActionEvent", "ActionEventType", "ActionTraceConfig"):
from .protocol import ActionEvent, ActionEventType, ActionTraceConfig
return locals()[name]
if name in ("TraceSink", "NoOpSink", "ListSink"):
from .protocol import TraceSink, NoOpSink, ListSink
return locals()[name]
if name == "TraceEmitter":
from .protocol import TraceEmitter
return TraceEmitter
if name in ("redact_dict", "REDACT_KEYS"):
from .redact import redact_dict, REDACT_KEYS
return locals()[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,398 @@
"""
Action Trace Protocol for PraisonAI Agents.
Lightweight protocol for tracing agent actions. Uses only stdlib
to ensure zero dependencies and minimal overhead.
Schema Version: 1.0
"""
import json
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
# Schema version for forward compatibility
SCHEMA_VERSION = "1.0"
class ActionEventType(str, Enum):
"""Types of action events."""
AGENT_START = "agent_start"
AGENT_END = "agent_end"
TOOL_START = "tool_start"
TOOL_END = "tool_end"
ERROR = "error"
OUTPUT = "output"
@dataclass
class ActionEvent:
"""
Lightweight action trace event.
Represents a single action in the agent execution trace.
Designed for minimal memory footprint and fast serialization.
"""
event_type: str
timestamp: float
schema_version: str = SCHEMA_VERSION
agent_id: Optional[str] = None
agent_name: Optional[str] = None
tool_name: Optional[str] = None
tool_args: Optional[Dict[str, Any]] = None
tool_result_summary: Optional[str] = None
duration_ms: Optional[float] = None
status: Optional[str] = None # ok, error
error_message: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary, excluding None values."""
result = {
"schema_version": self.schema_version,
"event_type": self.event_type,
"timestamp": self.timestamp,
}
# Add optional fields only if set
if self.agent_id is not None:
result["agent_id"] = self.agent_id
if self.agent_name is not None:
result["agent_name"] = self.agent_name
if self.tool_name is not None:
result["tool_name"] = self.tool_name
if self.tool_args is not None:
result["tool_args"] = self.tool_args
if self.tool_result_summary is not None:
result["tool_result_summary"] = self.tool_result_summary
if self.duration_ms is not None:
result["duration_ms"] = self.duration_ms
if self.status is not None:
result["status"] = self.status
if self.error_message is not None:
result["error_message"] = self.error_message
if self.metadata:
result["metadata"] = self.metadata
return result
def to_json(self) -> str:
"""Convert to JSON string."""
return json.dumps(self.to_dict(), default=str)
@runtime_checkable
class TraceSink(Protocol):
"""
Protocol for trace event sinks.
Implementations receive action events and handle them
(e.g., print to stdout, write to file, send to server).
"""
def emit(self, event: ActionEvent) -> None:
"""Emit an action event."""
...
def flush(self) -> None:
"""Flush any buffered events."""
...
def close(self) -> None:
"""Close the sink and release resources."""
...
class NoOpSink:
"""
No-operation sink that discards all events.
Used as the default sink when tracing is disabled.
Has near-zero overhead.
"""
__slots__ = ()
def emit(self, event: ActionEvent) -> None:
"""Discard the event."""
pass
def flush(self) -> None:
"""No-op."""
pass
def close(self) -> None:
"""No-op."""
pass
class ListSink:
"""
Sink that collects events in a list.
Useful for testing and programmatic access to trace events.
"""
__slots__ = ("_events",)
def __init__(self):
self._events: List[ActionEvent] = []
def emit(self, event: ActionEvent) -> None:
"""Add event to the list."""
self._events.append(event)
def flush(self) -> None:
"""No-op for list sink."""
pass
def close(self) -> None:
"""No-op for list sink."""
pass
def get_events(self) -> List[ActionEvent]:
"""Get all collected events."""
return self._events.copy()
def clear(self) -> None:
"""Clear all collected events."""
self._events.clear()
@dataclass
class ActionTraceConfig:
"""
Configuration for action tracing.
Usage:
# Default (disabled)
config = ActionTraceConfig()
# Enable with stdout
config = ActionTraceConfig(enabled=True, sink_type="stdout")
# Enable with JSONL file
config = ActionTraceConfig(
enabled=True,
sink_type="jsonl",
file_path="trace.jsonl",
)
"""
enabled: bool = True
redact: bool = True
compact: bool = False
sink_type: str = "noop" # noop, stdout, jsonl, file
file_path: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"enabled": self.enabled,
"redact": self.redact,
"compact": self.compact,
"sink_type": self.sink_type,
"file_path": self.file_path,
}
class TraceEmitter:
"""
Emitter for action trace events.
Provides a high-level API for emitting trace events.
Handles redaction and sink management.
Usage:
sink = ListSink()
emitter = TraceEmitter(sink=sink)
emitter.agent_start("researcher")
emitter.tool_start("search", {"query": "AI"})
emitter.tool_end("search", duration_ms=100, status="ok")
emitter.agent_end("researcher", duration_ms=500)
"""
__slots__ = ("_sink", "_enabled", "_redact", "_agent_starts")
def __init__(
self,
sink: Optional[TraceSink] = None,
enabled: bool = True,
redact: bool = True,
):
self._sink = sink or NoOpSink()
self._enabled = enabled
self._redact = redact
self._agent_starts: Dict[str, float] = {} # Track agent start times
def _emit(self, event: ActionEvent) -> None:
"""Internal emit with enabled check."""
if not self._enabled:
return
self._sink.emit(event)
def _redact_args(self, args: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Redact sensitive data from args if enabled."""
if args is None or not self._redact:
return args
from .redact import redact_dict
return redact_dict(args)
def agent_start(
self,
agent_name: str,
agent_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Emit agent start event."""
ts = time.time()
self._agent_starts[agent_name] = ts
self._emit(ActionEvent(
event_type=ActionEventType.AGENT_START.value,
timestamp=ts,
agent_name=agent_name,
agent_id=agent_id,
metadata=metadata or {},
))
def agent_end(
self,
agent_name: str,
agent_id: Optional[str] = None,
duration_ms: Optional[float] = None,
status: str = "ok",
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Emit agent end event."""
ts = time.time()
# Calculate duration if not provided
if duration_ms is None and agent_name in self._agent_starts:
start_ts = self._agent_starts.pop(agent_name, None)
if start_ts:
duration_ms = (ts - start_ts) * 1000
self._emit(ActionEvent(
event_type=ActionEventType.AGENT_END.value,
timestamp=ts,
agent_name=agent_name,
agent_id=agent_id,
duration_ms=duration_ms,
status=status,
metadata=metadata or {},
))
def tool_start(
self,
tool_name: str,
tool_args: Optional[Dict[str, Any]] = None,
agent_name: Optional[str] = None,
agent_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Emit tool start event."""
self._emit(ActionEvent(
event_type=ActionEventType.TOOL_START.value,
timestamp=time.time(),
tool_name=tool_name,
tool_args=self._redact_args(tool_args),
agent_name=agent_name,
agent_id=agent_id,
metadata=metadata or {},
))
def tool_end(
self,
tool_name: str,
duration_ms: float,
status: str = "ok",
result_summary: Optional[str] = None,
agent_name: Optional[str] = None,
agent_id: Optional[str] = None,
error_message: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Emit tool end event."""
self._emit(ActionEvent(
event_type=ActionEventType.TOOL_END.value,
timestamp=time.time(),
tool_name=tool_name,
duration_ms=duration_ms,
status=status,
tool_result_summary=result_summary,
agent_name=agent_name,
agent_id=agent_id,
error_message=error_message,
metadata=metadata or {},
))
def error(
self,
message: str,
tool_name: Optional[str] = None,
agent_name: Optional[str] = None,
agent_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Emit error event."""
self._emit(ActionEvent(
event_type=ActionEventType.ERROR.value,
timestamp=time.time(),
error_message=message,
tool_name=tool_name,
agent_name=agent_name,
agent_id=agent_id,
status="error",
metadata=metadata or {},
))
def output(
self,
content: str,
agent_name: Optional[str] = None,
agent_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Emit final output event."""
# Truncate long output for summary
summary = content[:500] + "..." if len(content) > 500 else content
self._emit(ActionEvent(
event_type=ActionEventType.OUTPUT.value,
timestamp=time.time(),
tool_result_summary=summary,
agent_name=agent_name,
agent_id=agent_id,
metadata=metadata or {},
))
def flush(self) -> None:
"""Flush the sink."""
self._sink.flush()
def close(self) -> None:
"""Close the sink."""
self._sink.close()
# Global default emitter (NoOp by default)
_default_emitter: Optional[TraceEmitter] = None
def get_default_emitter() -> TraceEmitter:
"""Get the default trace emitter."""
global _default_emitter
if _default_emitter is None:
_default_emitter = TraceEmitter(sink=NoOpSink(), enabled=False)
return _default_emitter
def set_default_emitter(emitter: TraceEmitter) -> None:
"""Set the default trace emitter."""
global _default_emitter
_default_emitter = emitter
@@ -0,0 +1,149 @@
"""
Redaction utilities for action trace.
Provides deterministic redaction of sensitive data in tool arguments
and results. Uses only stdlib for zero dependencies.
"""
from typing import Any, Dict, Set
import re
# Keys that should always be redacted (case-insensitive matching)
REDACT_KEYS: Set[str] = {
# API keys and tokens
"api_key",
"apikey",
"api-key",
"secret",
"secret_key",
"secretkey",
"private_key",
"privatekey",
# Authentication
"password",
"passwd",
"pwd",
"token",
"bearer",
"authorization",
"auth",
"auth_token",
"access_token",
"refresh_token",
# Session and cookies
"cookie",
"session",
"session_id",
"sessionid",
"credential",
"credentials",
# Database
"connection_string",
"connectionstring",
"database_url",
"db_url",
"dburl",
"db_password",
"db_pass",
# Cloud provider specific
"aws_secret",
"aws_secret_access_key",
"azure_key",
"gcp_key",
"openai_api_key",
"anthropic_api_key",
}
# Compiled regex for case-insensitive matching
_REDACT_PATTERN = re.compile(
r"(" + "|".join(re.escape(k) for k in REDACT_KEYS) + r")",
re.IGNORECASE
)
REDACTED_VALUE = "[REDACTED]"
def _should_redact(key: str) -> bool:
"""Check if a key should be redacted."""
# Normalize key for comparison
normalized = key.lower().replace("-", "_").replace(" ", "_")
# Check exact match first
if normalized in REDACT_KEYS:
return True
# Check if any redact key is contained in the normalized key
for redact_key in REDACT_KEYS:
if redact_key in normalized:
return True
return False
def redact_dict(data: Dict[str, Any], enabled: bool = True) -> Dict[str, Any]:
"""
Redact sensitive values from a dictionary.
Args:
data: Dictionary to redact
enabled: If False, returns data unchanged
Returns:
New dictionary with sensitive values redacted
"""
if not enabled:
return data
if not data:
return {}
return _redact_value(data)
def _redact_value(value: Any) -> Any:
"""Recursively redact sensitive values."""
if isinstance(value, dict):
return {k: _redact_key_value(k, v) for k, v in value.items()}
elif isinstance(value, list):
return [_redact_value(item) for item in value]
else:
return value
def _redact_key_value(key: str, value: Any) -> Any:
"""Redact a value if its key is sensitive."""
if _should_redact(key):
return REDACTED_VALUE
# Recursively process nested structures
return _redact_value(value)
def redact_string(text: str, enabled: bool = True) -> str:
"""
Redact potential secrets from a string.
This is a best-effort function that looks for common patterns
like "api_key=xxx" or "password: xxx" in strings.
Args:
text: String to redact
enabled: If False, returns text unchanged
Returns:
String with potential secrets redacted
"""
if not enabled or not text:
return text
# Pattern for key=value or key: value
patterns = [
(r'(["\']?)(' + '|'.join(re.escape(k) for k in REDACT_KEYS) + r')(["\']?\s*[:=]\s*["\']?)([^"\'\s,}\]]+)',
r'\1\2\3[REDACTED]'),
]
result = text
for pattern, replacement in patterns:
result = re.sub(pattern, replacement, result, flags=re.IGNORECASE)
return result
@@ -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.6"
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,207 @@
#!/usr/bin/env python3
"""
Micro-benchmark for output mode overhead comparison.
Measures:
1. Import-time cost (cold import)
2. Agent initialization overhead per output mode
3. Hot-path overhead (simulated callback execution)
Usage:
python tests/benchmarks/output_mode_benchmark.py
All stdlib - no external dependencies required.
"""
import subprocess
import sys
import time
from statistics import mean, stdev
def measure_import_time(mode: str, iterations: int = 5) -> dict:
"""Measure cold import time for a specific output mode."""
times = []
# Script that imports and creates an agent with specific output mode
script = f'''
import time
start = time.perf_counter()
import os
os.environ['PRAISONAI_OUTPUT'] = '{mode}'
from praisonaiagents import Agent
agent = Agent(name="BenchAgent", instructions="Test", output="{mode}")
end = time.perf_counter()
print(f"{{(end - start) * 1000:.3f}}")
'''
for _ in range(iterations):
result = subprocess.run(
[sys.executable, '-c', script],
capture_output=True,
text=True,
env={**dict(__import__('os').environ), 'PYTHONDONTWRITEBYTECODE': '1'}
)
if result.returncode == 0:
try:
times.append(float(result.stdout.strip()))
except ValueError:
pass
if not times:
return {"mode": mode, "mean_ms": -1, "stdev_ms": -1, "samples": 0}
return {
"mode": mode,
"mean_ms": mean(times),
"stdev_ms": stdev(times) if len(times) > 1 else 0,
"samples": len(times),
}
def measure_callback_overhead(iterations: int = 1000) -> dict:
"""Measure hot-path overhead of callback execution vs no callbacks."""
results = {}
# Silent mode - no callbacks
silent_times = []
for _ in range(iterations):
start = time.perf_counter()
# Simulate what happens in silent mode: nothing
pass
end = time.perf_counter()
silent_times.append((end - start) * 1_000_000) # microseconds
results["silent"] = {
"mean_us": mean(silent_times),
"stdev_us": stdev(silent_times) if len(silent_times) > 1 else 0,
}
# Actions mode - simulate callback overhead
actions_times = []
# Simulate the work done by actions mode per tool call
def simulate_actions_callback():
import time as t
# Simulate: time.time(), dict creation, string formatting, stderr write
_ = t.time()
event = {
"type": "tool_call",
"tool": "test_tool",
"args": {"arg1": "value1", "arg2": "value2"},
"timestamp": t.time(),
}
formatted = f"[tool_call] {event['tool']}({event['args']})"
# Don't actually write to avoid polluting output
_ = formatted.encode()
for _ in range(iterations):
start = time.perf_counter()
simulate_actions_callback()
end = time.perf_counter()
actions_times.append((end - start) * 1_000_000) # microseconds
results["actions"] = {
"mean_us": mean(actions_times),
"stdev_us": stdev(actions_times) if len(actions_times) > 1 else 0,
}
return results
def measure_agent_init_overhead() -> dict:
"""Measure Agent.__init__ overhead for different output modes."""
import os
# Clear any env var
os.environ.pop('PRAISONAI_OUTPUT', None)
results = {}
iterations = 10
for mode in ["silent", "actions", "verbose"]:
times = []
for _ in range(iterations):
# Import fresh each time to avoid caching effects
from praisonaiagents import Agent
start = time.perf_counter()
agent = Agent(
name="BenchAgent",
instructions="Test agent",
output=mode,
)
end = time.perf_counter()
times.append((end - start) * 1000) # milliseconds
del agent
results[mode] = {
"mean_ms": mean(times),
"stdev_ms": stdev(times) if len(times) > 1 else 0,
}
return results
def main():
print("=" * 60)
print("OUTPUT MODE MICRO-BENCHMARK")
print("=" * 60)
print()
# 1. Import time comparison
print("1. IMPORT + INIT TIME (cold start, subprocess)")
print("-" * 40)
modes = ["silent", "actions", "verbose"]
import_results = []
for mode in modes:
print(f" Measuring {mode}...", end=" ", flush=True)
result = measure_import_time(mode, iterations=3)
import_results.append(result)
print(f"{result['mean_ms']:.1f}ms (±{result['stdev_ms']:.1f})")
print()
# 2. Agent init overhead (warm)
print("2. AGENT INIT OVERHEAD (warm, same process)")
print("-" * 40)
init_results = measure_agent_init_overhead()
for mode, data in init_results.items():
print(f" {mode:12s}: {data['mean_ms']:.2f}ms (±{data['stdev_ms']:.2f})")
print()
# 3. Hot-path callback overhead
print("3. HOT-PATH CALLBACK OVERHEAD (per call)")
print("-" * 40)
callback_results = measure_callback_overhead(iterations=10000)
for mode, data in callback_results.items():
print(f" {mode:12s}: {data['mean_us']:.3f}µs (±{data['stdev_us']:.3f})")
print()
# Summary
print("=" * 60)
print("SUMMARY")
print("=" * 60)
silent_init = init_results.get("silent", {}).get("mean_ms", 0)
actions_init = init_results.get("actions", {}).get("mean_ms", 0)
silent_callback = callback_results.get("silent", {}).get("mean_us", 0)
actions_callback = callback_results.get("actions", {}).get("mean_us", 0)
print(f" Init overhead (actions vs silent): +{actions_init - silent_init:.2f}ms")
print(f" Per-call overhead (actions vs silent): +{actions_callback - silent_callback:.3f}µs")
print()
print(" DEFAULT MODE: silent (zero overhead)")
print(" OPT-IN: output='actions' for observability")
print()
if __name__ == "__main__":
main()
@@ -0,0 +1,257 @@
"""
Tests for Agent execution API semantics: start(), run(), stream()
TDD tests for the new execution API:
- start() = interactive, streams by default in TTY
- run() = production, silent by default
- stream() = app-friendly iterator
"""
import pytest
import sys
from unittest.mock import patch
class TestAgentStartMethod:
"""Tests for Agent.start() - interactive, streaming by default in TTY"""
def test_start_streams_by_default_in_tty(self):
"""start() should stream by default when stdout is a TTY"""
from praisonaiagents import Agent
# Create agent without stream attribute set (None)
agent = Agent(name="Test", instructions="You are helpful")
# Ensure stream attribute is None so TTY detection kicks in
agent.stream = None
# Mock TTY detection
with patch.object(sys.stdout, 'isatty', return_value=True):
# Mock chat to avoid actual LLM call
with patch.object(agent, '_start_stream') as mock_stream:
mock_stream.return_value = iter(["Hello", " World"])
with patch.object(agent, 'chat') as mock_chat:
agent.start("Hello")
# Should call _start_stream when TTY
mock_stream.assert_called_once()
mock_chat.assert_not_called()
def test_start_does_not_stream_when_not_tty(self):
"""start() should not stream when stdout is not a TTY"""
from praisonaiagents import Agent
agent = Agent(name="Test", instructions="You are helpful")
# Mock non-TTY
with patch.object(sys.stdout, 'isatty', return_value=False):
with patch.object(agent, '_start_stream') as mock_stream:
with patch.object(agent, 'chat', return_value="Hello World") as mock_chat:
result = agent.start("Hello")
# Should call chat, not _start_stream
mock_chat.assert_called_once()
mock_stream.assert_not_called()
def test_start_respects_explicit_stream_true(self):
"""start(stream=True) should always stream"""
from praisonaiagents import Agent
agent = Agent(name="Test", instructions="You are helpful")
# Even when not TTY, explicit stream=True should stream
with patch.object(sys.stdout, 'isatty', return_value=False):
with patch.object(agent, '_start_stream') as mock_stream:
mock_stream.return_value = iter(["Hello"])
result = agent.start("Hello", stream=True)
mock_stream.assert_called_once()
def test_start_respects_explicit_stream_false(self):
"""start(stream=False) should never stream"""
from praisonaiagents import Agent
agent = Agent(name="Test", instructions="You are helpful")
# Even when TTY, explicit stream=False should not stream
with patch.object(sys.stdout, 'isatty', return_value=True):
with patch.object(agent, 'chat', return_value="Hello") as mock_chat:
with patch.object(agent, '_start_stream') as mock_stream:
result = agent.start("Hello", stream=False)
mock_chat.assert_called_once()
mock_stream.assert_not_called()
def test_start_respects_agent_stream_attribute(self):
"""start() should respect agent's stream attribute over TTY detection"""
from praisonaiagents import Agent
# Agent with stream=True should always stream
agent = Agent(name="Test", instructions="You are helpful", output="stream")
with patch.object(sys.stdout, 'isatty', return_value=False):
with patch.object(agent, '_start_stream') as mock_stream:
mock_stream.return_value = iter(["Hello"])
# Agent's stream attribute should take precedence
if agent.stream:
result = agent.start("Hello")
mock_stream.assert_called()
class TestAgentRunMethod:
"""Tests for Agent.run() - production, silent by default"""
def test_run_does_not_stream_by_default(self):
"""run() should not stream by default"""
from praisonaiagents import Agent
agent = Agent(name="Test", instructions="You are helpful")
with patch.object(agent, 'chat', return_value="Hello World") as mock_chat:
with patch.object(agent, '_start_stream') as mock_stream:
result = agent.run("Hello")
# Should call chat with stream=False
mock_chat.assert_called_once()
call_kwargs = mock_chat.call_args[1]
assert call_kwargs.get('stream') == False
mock_stream.assert_not_called()
def test_run_returns_result_directly(self):
"""run() should return the result directly, not a generator"""
from praisonaiagents import Agent
agent = Agent(name="Test", instructions="You are helpful")
with patch.object(agent, 'chat', return_value="Hello World") as mock_chat:
result = agent.run("Hello")
assert result == "Hello World"
assert not hasattr(result, '__iter__') or isinstance(result, str)
def test_run_ignores_tty_detection(self):
"""run() should ignore TTY detection and never stream by default"""
from praisonaiagents import Agent
agent = Agent(name="Test", instructions="You are helpful")
# Even in TTY, run() should not stream
with patch.object(sys.stdout, 'isatty', return_value=True):
with patch.object(agent, 'chat', return_value="Hello") as mock_chat:
with patch.object(agent, '_start_stream') as mock_stream:
result = agent.run("Hello")
mock_chat.assert_called_once()
mock_stream.assert_not_called()
def test_run_can_be_forced_to_stream(self):
"""run(stream=True) should stream when explicitly requested"""
from praisonaiagents import Agent
agent = Agent(name="Test", instructions="You are helpful")
with patch.object(agent, 'chat', return_value="Hello") as mock_chat:
result = agent.run("Hello", stream=True)
# When stream=True is passed, it should be passed to chat
call_kwargs = mock_chat.call_args[1]
assert call_kwargs.get('stream') == True
class TestAgentIterStreamMethod:
"""Tests for Agent.iter_stream() - app-friendly iterator"""
def test_iter_stream_returns_iterator(self):
"""iter_stream() should return an iterator"""
from praisonaiagents import Agent
agent = Agent(name="Test", instructions="You are helpful")
with patch.object(agent, '_start_stream') as mock_stream:
mock_stream.return_value = iter(["Hello", " ", "World"])
result = agent.iter_stream("Hello")
# Should be an iterator/generator
assert hasattr(result, '__iter__')
assert hasattr(result, '__next__')
def test_iter_stream_yields_chunks(self):
"""iter_stream() should yield response chunks"""
from praisonaiagents import Agent
agent = Agent(name="Test", instructions="You are helpful")
with patch.object(agent, '_start_stream') as mock_stream:
mock_stream.return_value = iter(["Hello", " ", "World"])
chunks = list(agent.iter_stream("Hello"))
assert chunks == ["Hello", " ", "World"]
def test_iter_stream_always_streams(self):
"""iter_stream() should always stream regardless of TTY"""
from praisonaiagents import Agent
agent = Agent(name="Test", instructions="You are helpful")
# Even when not TTY
with patch.object(sys.stdout, 'isatty', return_value=False):
with patch.object(agent, '_start_stream') as mock_stream:
mock_stream.return_value = iter(["Hello"])
result = agent.iter_stream("Hello")
# Consume the generator
list(result)
mock_stream.assert_called_once()
class TestAsyncMethods:
"""Tests for async versions: arun(), astart()"""
@pytest.mark.asyncio
async def test_arun_does_not_stream_by_default(self):
"""arun() should not stream by default"""
from praisonaiagents import Agent
agent = Agent(name="Test", instructions="You are helpful")
with patch.object(agent, 'achat', return_value="Hello World") as mock_achat:
result = await agent.arun("Hello")
mock_achat.assert_called_once()
call_kwargs = mock_achat.call_args[1]
assert call_kwargs.get('stream') == False
@pytest.mark.asyncio
async def test_astart_streams_in_tty(self):
"""astart() should stream when in TTY"""
from praisonaiagents import Agent
agent = Agent(name="Test", instructions="You are helpful")
# Ensure stream attribute is None so TTY detection kicks in
agent.stream = None
with patch.object(sys.stdout, 'isatty', return_value=True):
with patch.object(agent, 'achat', return_value="Hello") as mock_achat:
await agent.astart("Hello")
call_kwargs = mock_achat.call_args[1]
assert call_kwargs.get('stream')
class TestMethodSemanticDistinction:
"""Tests to verify start() and run() have distinct semantics"""
def test_start_and_run_are_different(self):
"""start() and run() should have different default behaviors"""
from praisonaiagents import Agent
agent = Agent(name="Test", instructions="You are helpful")
# Ensure stream attribute is None so TTY detection kicks in
agent.stream = None
# In TTY context
with patch.object(sys.stdout, 'isatty', return_value=True):
with patch.object(agent, 'chat', return_value="Hello") as mock_chat:
with patch.object(agent, '_start_stream') as mock_stream:
mock_stream.return_value = iter(["Hello"])
# start() should stream
agent.start("Hello")
start_streamed = mock_stream.called
mock_stream.reset_mock()
mock_chat.reset_mock()
# run() should not stream
agent.run("Hello")
run_streamed = mock_stream.called
# They should behave differently
assert start_streamed
assert not run_streamed
@@ -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"
@@ -0,0 +1 @@
"""Tests for the trace module."""
@@ -0,0 +1,450 @@
"""
Tests for the trace protocol module.
TDD tests for ActionEvent, TraceSink protocol, and redaction.
"""
import time
import json
class TestActionEvent:
"""Tests for ActionEvent dataclass."""
def test_action_event_creation_minimal(self):
"""Test creating ActionEvent with minimal fields."""
from praisonaiagents.trace.protocol import ActionEvent
event = ActionEvent(
event_type="agent_start",
timestamp=1736665496.123,
)
assert event.event_type == "agent_start"
assert event.timestamp == 1736665496.123
assert event.schema_version == "1.0"
assert event.agent_id is None
assert event.agent_name is None
assert event.tool_name is None
def test_action_event_creation_full(self):
"""Test creating ActionEvent with all fields."""
from praisonaiagents.trace.protocol import ActionEvent
event = ActionEvent(
event_type="tool_end",
timestamp=1736665497.456,
agent_id="agent-123",
agent_name="researcher",
tool_name="web_search",
tool_args={"query": "AI trends"},
tool_result_summary="Found 10 results",
duration_ms=800.5,
status="ok",
error_message=None,
metadata={"provider": "duckduckgo"},
)
assert event.event_type == "tool_end"
assert event.agent_name == "researcher"
assert event.tool_name == "web_search"
assert event.tool_args == {"query": "AI trends"}
assert event.duration_ms == 800.5
assert event.status == "ok"
assert event.metadata == {"provider": "duckduckgo"}
def test_action_event_to_dict(self):
"""Test ActionEvent.to_dict() method."""
from praisonaiagents.trace.protocol import ActionEvent
event = ActionEvent(
event_type="agent_start",
timestamp=1736665496.123,
agent_name="writer",
)
d = event.to_dict()
assert d["schema_version"] == "1.0"
assert d["event_type"] == "agent_start"
assert d["timestamp"] == 1736665496.123
assert d["agent_name"] == "writer"
# None values should be excluded
assert "tool_name" not in d or d["tool_name"] is None
def test_action_event_to_json(self):
"""Test ActionEvent.to_json() method."""
from praisonaiagents.trace.protocol import ActionEvent
event = ActionEvent(
event_type="tool_start",
timestamp=1736665497.0,
tool_name="read_file",
tool_args={"path": "/tmp/test.txt"},
)
json_str = event.to_json()
parsed = json.loads(json_str)
assert parsed["event_type"] == "tool_start"
assert parsed["tool_name"] == "read_file"
assert parsed["tool_args"] == {"path": "/tmp/test.txt"}
def test_action_event_types(self):
"""Test all valid event types."""
from praisonaiagents.trace.protocol import ActionEvent, ActionEventType
valid_types = [
ActionEventType.AGENT_START,
ActionEventType.AGENT_END,
ActionEventType.TOOL_START,
ActionEventType.TOOL_END,
ActionEventType.ERROR,
ActionEventType.OUTPUT,
]
for event_type in valid_types:
event = ActionEvent(
event_type=event_type.value,
timestamp=time.time(),
)
assert event.event_type == event_type.value
class TestRedaction:
"""Tests for redaction functionality."""
def test_redact_api_key(self):
"""Test redacting api_key field."""
from praisonaiagents.trace.redact import redact_dict
data = {"api_key": "sk-1234567890abcdef", "query": "hello"}
redacted = redact_dict(data)
assert redacted["api_key"] == "[REDACTED]"
assert redacted["query"] == "hello"
def test_redact_multiple_sensitive_keys(self):
"""Test redacting multiple sensitive keys."""
from praisonaiagents.trace.redact import redact_dict
data = {
"api_key": "sk-secret",
"password": "mypassword123",
"token": "bearer-token",
"authorization": "Bearer xyz",
"secret": "top-secret",
"normal_field": "visible",
}
redacted = redact_dict(data)
assert redacted["api_key"] == "[REDACTED]"
assert redacted["password"] == "[REDACTED]"
assert redacted["token"] == "[REDACTED]"
assert redacted["authorization"] == "[REDACTED]"
assert redacted["secret"] == "[REDACTED]"
assert redacted["normal_field"] == "visible"
def test_redact_nested_dict(self):
"""Test redacting nested dictionaries."""
from praisonaiagents.trace.redact import redact_dict
data = {
"config": {
"api_key": "sk-nested",
"endpoint": "https://api.example.com",
},
"name": "test",
}
redacted = redact_dict(data)
assert redacted["config"]["api_key"] == "[REDACTED]"
assert redacted["config"]["endpoint"] == "https://api.example.com"
assert redacted["name"] == "test"
def test_redact_list_of_dicts(self):
"""Test redacting list containing dictionaries."""
from praisonaiagents.trace.redact import redact_dict
data = {
"items": [
{"name": "item1", "secret": "hidden1"},
{"name": "item2", "secret": "hidden2"},
]
}
redacted = redact_dict(data)
assert redacted["items"][0]["name"] == "item1"
assert redacted["items"][0]["secret"] == "[REDACTED]"
assert redacted["items"][1]["secret"] == "[REDACTED]"
def test_redact_case_insensitive(self):
"""Test that redaction is case-insensitive."""
from praisonaiagents.trace.redact import redact_dict
data = {
"API_KEY": "key1",
"Api_Key": "key2",
"apiKey": "key3",
"APIKEY": "key4",
}
redacted = redact_dict(data)
assert redacted["API_KEY"] == "[REDACTED]"
assert redacted["Api_Key"] == "[REDACTED]"
assert redacted["apiKey"] == "[REDACTED]"
assert redacted["APIKEY"] == "[REDACTED]"
def test_redact_connection_strings(self):
"""Test redacting database connection strings."""
from praisonaiagents.trace.redact import redact_dict
data = {
"connection_string": "postgresql://user:pass@host/db",
"database_url": "mysql://root:secret@localhost/mydb",
"db_url": "redis://default:password@redis:6379",
}
redacted = redact_dict(data)
assert redacted["connection_string"] == "[REDACTED]"
assert redacted["database_url"] == "[REDACTED]"
assert redacted["db_url"] == "[REDACTED]"
def test_redact_preserves_original(self):
"""Test that redaction doesn't modify original dict."""
from praisonaiagents.trace.redact import redact_dict
original = {"api_key": "secret", "name": "test"}
redacted = redact_dict(original)
assert original["api_key"] == "secret"
assert redacted["api_key"] == "[REDACTED]"
def test_redact_empty_dict(self):
"""Test redacting empty dictionary."""
from praisonaiagents.trace.redact import redact_dict
assert redact_dict({}) == {}
def test_redact_none_values(self):
"""Test handling None values."""
from praisonaiagents.trace.redact import redact_dict
data = {"api_key": None, "name": "test"}
redacted = redact_dict(data)
# None values for sensitive keys should still be redacted
assert redacted["api_key"] == "[REDACTED]"
assert redacted["name"] == "test"
def test_redact_disabled(self):
"""Test disabling redaction."""
from praisonaiagents.trace.redact import redact_dict
data = {"api_key": "visible", "name": "test"}
result = redact_dict(data, enabled=False)
assert result["api_key"] == "visible"
assert result["name"] == "test"
class TestTraceSink:
"""Tests for TraceSink protocol."""
def test_noop_sink(self):
"""Test NoOpSink does nothing."""
from praisonaiagents.trace.protocol import NoOpSink, ActionEvent
sink = NoOpSink()
event = ActionEvent(event_type="agent_start", timestamp=time.time())
# Should not raise
sink.emit(event)
sink.flush()
sink.close()
def test_list_sink(self):
"""Test ListSink collects events."""
from praisonaiagents.trace.protocol import ListSink, ActionEvent
sink = ListSink()
event1 = ActionEvent(event_type="agent_start", timestamp=1.0)
event2 = ActionEvent(event_type="tool_start", timestamp=2.0)
sink.emit(event1)
sink.emit(event2)
events = sink.get_events()
assert len(events) == 2
assert events[0].event_type == "agent_start"
assert events[1].event_type == "tool_start"
def test_list_sink_clear(self):
"""Test ListSink.clear() method."""
from praisonaiagents.trace.protocol import ListSink, ActionEvent
sink = ListSink()
sink.emit(ActionEvent(event_type="test", timestamp=1.0))
assert len(sink.get_events()) == 1
sink.clear()
assert len(sink.get_events()) == 0
class TestTraceEmitter:
"""Tests for TraceEmitter class."""
def test_emitter_with_noop_sink(self):
"""Test TraceEmitter with NoOpSink."""
from praisonaiagents.trace.protocol import TraceEmitter, NoOpSink
emitter = TraceEmitter(sink=NoOpSink())
# Should not raise
emitter.agent_start("test-agent", agent_id="123")
emitter.tool_start("web_search", {"query": "test"})
emitter.tool_end("web_search", duration_ms=100, status="ok")
emitter.agent_end("test-agent", duration_ms=500)
def test_emitter_with_list_sink(self):
"""Test TraceEmitter collects events."""
from praisonaiagents.trace.protocol import TraceEmitter, ListSink
sink = ListSink()
emitter = TraceEmitter(sink=sink)
emitter.agent_start("researcher")
emitter.tool_start("search", {"q": "AI"})
emitter.tool_end("search", duration_ms=100, status="ok")
emitter.agent_end("researcher", duration_ms=200)
events = sink.get_events()
assert len(events) == 4
assert events[0].event_type == "agent_start"
assert events[1].event_type == "tool_start"
assert events[2].event_type == "tool_end"
assert events[3].event_type == "agent_end"
def test_emitter_redacts_by_default(self):
"""Test TraceEmitter redacts sensitive data by default."""
from praisonaiagents.trace.protocol import TraceEmitter, ListSink
sink = ListSink()
emitter = TraceEmitter(sink=sink, redact=True)
emitter.tool_start("api_call", {"api_key": "sk-secret", "query": "test"})
events = sink.get_events()
assert events[0].tool_args["api_key"] == "[REDACTED]"
assert events[0].tool_args["query"] == "test"
def test_emitter_disabled(self):
"""Test TraceEmitter when disabled."""
from praisonaiagents.trace.protocol import TraceEmitter, ListSink
sink = ListSink()
emitter = TraceEmitter(sink=sink, enabled=False)
emitter.agent_start("test")
emitter.tool_start("tool", {})
# No events should be emitted
assert len(sink.get_events()) == 0
def test_emitter_error_event(self):
"""Test TraceEmitter error event."""
from praisonaiagents.trace.protocol import TraceEmitter, ListSink
sink = ListSink()
emitter = TraceEmitter(sink=sink)
emitter.error("Something went wrong", tool_name="failing_tool")
events = sink.get_events()
assert len(events) == 1
assert events[0].event_type == "error"
assert events[0].error_message == "Something went wrong"
assert events[0].tool_name == "failing_tool"
def test_emitter_output_event(self):
"""Test TraceEmitter output event for final agent output."""
from praisonaiagents.trace.protocol import TraceEmitter, ListSink
sink = ListSink()
emitter = TraceEmitter(sink=sink)
emitter.output("The analysis shows three major trends...")
events = sink.get_events()
assert len(events) == 1
assert events[0].event_type == "output"
assert "three major trends" in events[0].tool_result_summary
class TestActionTraceConfig:
"""Tests for ActionTraceConfig."""
def test_config_defaults(self):
"""Test ActionTraceConfig default values."""
from praisonaiagents.trace.protocol import ActionTraceConfig
config = ActionTraceConfig()
assert config.enabled is True
assert config.redact is True
assert config.compact is False
assert config.sink_type == "noop"
assert config.file_path is None
def test_config_custom(self):
"""Test ActionTraceConfig custom values."""
from praisonaiagents.trace.protocol import ActionTraceConfig
config = ActionTraceConfig(
enabled=True,
redact=False,
compact=True,
sink_type="jsonl",
file_path="/tmp/trace.jsonl",
)
assert config.redact is False
assert config.compact is True
assert config.sink_type == "jsonl"
assert config.file_path == "/tmp/trace.jsonl"
class TestPerformance:
"""Performance tests for trace module."""
def test_noop_sink_overhead(self):
"""Test that NoOpSink has minimal overhead."""
from praisonaiagents.trace.protocol import TraceEmitter, NoOpSink, ActionEvent
emitter = TraceEmitter(sink=NoOpSink())
start = time.perf_counter()
for _ in range(10000):
emitter.tool_start("test", {"arg": "value"})
emitter.tool_end("test", duration_ms=1, status="ok")
elapsed = time.perf_counter() - start
# Should complete 10000 iterations in under 100ms
assert elapsed < 0.1, f"NoOpSink too slow: {elapsed:.3f}s for 10000 iterations"
def test_disabled_emitter_overhead(self):
"""Test that disabled emitter has near-zero overhead."""
from praisonaiagents.trace.protocol import TraceEmitter, ListSink
emitter = TraceEmitter(sink=ListSink(), enabled=False)
start = time.perf_counter()
for _ in range(10000):
emitter.tool_start("test", {"arg": "value"})
elapsed = time.perf_counter() - start
# Should complete 10000 iterations in under 500ms (very conservative)
assert elapsed < 0.5, f"Disabled emitter too slow: {elapsed:.3f}s"
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.11.1"
version = "0.11.6"
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.8.0.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.8.0.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()
+94
View File
@@ -0,0 +1,94 @@
"""
PraisonAI Branding - Single Source of Truth.
This module provides unified branding assets (logo, version, product name)
for all interactive UIs. All frontends should import from here.
Usage:
from praisonai.cli.branding import get_logo, get_version, PRODUCT_NAME
"""
# Product name
PRODUCT_NAME = "Praison AI"
# ASCII Art Logos - responsive to terminal width
LOGO_LARGE = r"""
██████╗ ██████╗ █████╗ ██╗███████╗ ██████╗ ███╗ ██╗ █████╗ ██╗
██╔══██╗██╔══██╗██╔══██╗██║██╔════╝██╔═══██╗████╗ ██║ ██╔══██╗██║
██████╔╝██████╔╝███████║██║███████╗██║ ██║██╔██╗ ██║ ███████║██║
██╔═══╝ ██╔══██╗██╔══██║██║╚════██║██║ ██║██║╚██╗██║ ██╔══██║██║
██║ ██║ ██║██║ ██║██║███████║╚██████╔╝██║ ╚████║ ██║ ██║██║
╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝
"""
LOGO_MEDIUM = r"""
╔═╗┬─┐┌─┐┬┌─┐┌─┐┌┐┌ ╔═╗╦
╠═╝├┬┘├─┤│└─┐│ ││││ ╠═╣║
╩ ┴└─┴ ┴┴└─┘└─┘┘└┘ ╩ ╩╩
"""
LOGO_SMALL = "▶ Praison AI"
LOGO_MINIMAL = "Praison AI"
def get_version() -> str:
"""Get PraisonAI version string."""
try:
from praisonai import __version__
return __version__
except Exception:
return "1.0.0"
def get_logo(width: int = 80) -> str:
"""
Get appropriate logo based on terminal width.
Args:
width: Terminal width in characters
Returns:
ASCII art logo string appropriate for the width
"""
if width >= 75:
return LOGO_LARGE
elif width >= 40:
return LOGO_MEDIUM
else:
return LOGO_SMALL
def get_banner(width: int = 80, show_version: bool = True, model: str = None) -> str:
"""
Get full banner with logo, version, and optional model info.
Args:
width: Terminal width
show_version: Whether to include version
model: Optional model name to display
Returns:
Complete banner string
"""
lines = []
# Logo
logo = get_logo(width)
lines.append(logo.strip())
lines.append("")
# Version and model
version_line = f" v{get_version()}"
if model:
version_line += f" · Model: {model}"
lines.append(version_line)
lines.append("")
return "\n".join(lines)
def get_welcome_tips() -> str:
"""Get welcome tips for new sessions."""
return """ Type your message and press Enter. Use /help for commands.
Use PageUp/PageDown or Ctrl+Up/Down to scroll."""
+36 -70
View File
@@ -67,6 +67,7 @@ def chat_main(
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
no_acp: bool = typer.Option(False, "--no-acp", help="Disable ACP tools"),
no_lsp: bool = typer.Option(False, "--no-lsp", help="Disable LSP tools"),
safe_mode: bool = typer.Option(False, "--safe", help="Safe mode: require approval for file writes and commands"),
autonomy: bool = typer.Option(True, "--autonomy/--no-autonomy", help="Enable agent autonomy for complex tasks"),
# NEW: Agent-like consolidated params for ALL GREEN consistency
knowledge: Optional[str] = typer.Option(
@@ -107,8 +108,8 @@ def chat_main(
flag_value="true",
),
output: Optional[str] = typer.Option(
None, "--output",
help="Output preset. Use --output=verbose, --output=minimal, --output=silent",
None, "--output", "-o",
help="Output mode (default: actions). Options: actions, plain, verbose, json, silent",
),
execution: Optional[str] = typer.Option(
None, "--execution",
@@ -126,8 +127,9 @@ 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)"),
debug: bool = typer.Option(False, "--debug", help="Enable debug logging to ~/.praisonai/async_tui_debug.log"),
# 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 +151,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 +173,38 @@ 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,
)
# Set approval mode based on --safe flag
import os
if safe_mode:
os.environ["PRAISON_APPROVAL_MODE"] = "prompt"
else:
os.environ["PRAISON_APPROVAL_MODE"] = "auto"
# 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,
debug=debug,
)
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 +242,6 @@ def _run_profiled_chat(
"name": "ChatAgent",
"role": "Assistant",
"goal": "Help the user",
"verbose": verbose,
}
if model:
agent_config["llm"] = model
@@ -23,6 +23,7 @@ def code_main(
file: Optional[List[str]] = typer.Option(None, "--file", "-f", help="Attach file(s) to context"),
no_acp: bool = typer.Option(False, "--no-acp", help="Disable ACP tools (file operations)"),
no_lsp: bool = typer.Option(False, "--no-lsp", help="Disable LSP tools (code intelligence)"),
safe_mode: bool = typer.Option(False, "--safe", help="Safe mode: require approval for file writes and commands"),
session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID to resume"),
continue_session: bool = typer.Option(False, "--continue", "-c", help="Continue last session"),
autonomy: bool = typer.Option(True, "--autonomy/--no-autonomy", help="Enable agent autonomy for complex tasks"),
@@ -54,6 +55,12 @@ def code_main(
# Enable code-specific environment
os.environ["PRAISONAI_CODE_MODE"] = "true"
# Set approval mode based on --safe flag
if safe_mode:
os.environ["PRAISON_APPROVAL_MODE"] = "prompt"
else:
os.environ["PRAISON_APPROVAL_MODE"] = "auto"
# Handle profiling for single prompt mode
if prompt and (profile or profile_deep):
_run_profiled_code(
+31 -2
View File
@@ -22,13 +22,14 @@ def run_main(
framework: Optional[str] = typer.Option(None, "--framework", "-f", help="Framework: praisonai, crewai, autogen"),
interactive: bool = typer.Option(False, "--interactive", "-i", help="Interactive mode"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
stream: bool = typer.Option(True, "--stream/--no-stream", help="Stream output"),
stream: bool = typer.Option(False, "--stream/--no-stream", help="Stream output (default: off for production use)"),
trace: bool = typer.Option(False, "--trace", help="Enable tracing"),
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Tools file path"),
max_tokens: int = typer.Option(16000, "--max-tokens", help="Maximum output tokens"),
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)"),
output_mode: Optional[str] = typer.Option(None, "--output", "-o", help="Output mode: silent (default), actions, verbose, json, stream"),
):
"""
Run agents from a file or prompt.
@@ -108,6 +109,7 @@ def run_main(
memory=memory,
tools=tools,
max_tokens=max_tokens,
output_mode=output_mode,
)
else:
# Run as prompt
@@ -120,6 +122,7 @@ def run_main(
memory=memory,
tools=tools,
max_tokens=max_tokens,
output_mode=output_mode,
)
@@ -134,6 +137,7 @@ def _run_from_file(
memory: bool = False,
tools: Optional[str] = None,
max_tokens: int = 16000,
output_mode: Optional[str] = None,
):
"""Run agents from a YAML file."""
output = get_output_controller()
@@ -178,12 +182,37 @@ def _run_prompt(
memory: bool = False,
tools: Optional[str] = None,
max_tokens: int = 16000,
output_mode: Optional[str] = None,
):
"""Run a direct prompt."""
output = get_output_controller()
try:
# Use existing handle_direct_prompt
# If output_mode is "actions", use direct Agent with actions preset
if output_mode == "actions":
from praisonaiagents import Agent
agent_config = {
"name": "RunAgent",
"role": "Assistant",
"goal": "Complete the task",
"output": "actions", # Use actions preset
}
if model:
agent_config["llm"] = model
agent = Agent(**agent_config)
result = agent.start(prompt)
output.emit_result(
message="Prompt completed",
data={"result": str(result) if result else None}
)
# Don't print result again - actions mode already shows output
return
# Use existing handle_direct_prompt for other modes
from praisonai.cli.main import PraisonAI
praison = PraisonAI()
@@ -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."""
@@ -10,6 +10,12 @@ Usage:
praisonai test --provider openai # Run OpenAI provider tests
praisonai test --live # Enable live API tests
praisonai test --parallel auto # Run with parallelization
# Interactive mode tests
praisonai test interactive --csv tests.csv
praisonai test interactive --suite smoke
praisonai test interactive --list
praisonai test interactive --generate-template
"""
import os
@@ -239,6 +245,272 @@ def run(
raise typer.Exit(1)
# Built-in interactive test suites
BUILTIN_SUITES = {
"smoke": "smoke.csv",
"tools": "tools.csv",
"refactor": "refactor.csv",
"multi_agent": "multi_agent.csv",
}
# Special suites that don't use CSV
SPECIAL_SUITES = {
"advanced": "Advanced Agent-Centric (5 complex autonomous scenarios)",
}
def _get_builtin_suite_path(suite: str):
"""Get path to built-in test suite."""
from pathlib import Path
if suite not in BUILTIN_SUITES:
return None
# Find fixtures directory relative to package
package_dir = Path(__file__).parent.parent.parent.parent # Up to src/praisonai
fixtures_dir = package_dir / "tests" / "fixtures" / "interactive"
suite_path = fixtures_dir / BUILTIN_SUITES[suite]
if suite_path.exists():
return suite_path
return None
@app.command("interactive")
def interactive(
csv: Optional[str] = typer.Option(
None, "--csv", "-c",
help="Path to CSV file with test cases"
),
suite: Optional[str] = typer.Option(
None, "--suite", "-s",
help="Built-in test suite: smoke, tools, refactor, multi_agent"
),
model: str = typer.Option(
"gpt-4o-mini", "--model", "-m",
help="LLM model for agent"
),
judge_model: str = typer.Option(
"gpt-4o-mini", "--judge-model",
help="LLM model for judge"
),
workspace: Optional[str] = typer.Option(
None, "--workspace", "-w",
help="Workspace directory (default: temp)"
),
artifacts_dir: Optional[str] = typer.Option(
None, "--artifacts-dir",
help="Directory for test artifacts"
),
fail_fast: bool = typer.Option(
False, "--fail-fast", "-x",
help="Stop on first failure"
),
keep_artifacts: bool = typer.Option(
False, "--keep-artifacts",
help="Keep test artifacts after run"
),
no_judge: bool = typer.Option(
False, "--no-judge",
help="Skip judge evaluation even if rubric present"
),
verbose: bool = typer.Option(
False, "--verbose", "-v",
help="Verbose output"
),
list_suites: bool = typer.Option(
False, "--list",
help="List available built-in suites"
),
generate_template: bool = typer.Option(
False, "--generate-template",
help="Generate CSV template in current directory"
),
):
"""
Run interactive mode tests from CSV.
Tests are executed through the headless interactive core, using the same
pipeline as the interactive TUI (InteractiveRuntime, get_interactive_tools,
ActionOrchestrator).
Examples:
praisonai test interactive --csv tests.csv
praisonai test interactive --suite smoke
praisonai test interactive --suite tools --keep-artifacts
praisonai test interactive --list
praisonai test interactive --generate-template
"""
from pathlib import Path
# List suites
if list_suites:
typer.echo("📋 Available built-in test suites:")
typer.echo("")
typer.echo("CSV-based suites:")
for name, filename in BUILTIN_SUITES.items():
suite_path = _get_builtin_suite_path(name)
status = "✅" if suite_path else "❌ (not found)"
typer.echo(f" {name:15} {filename:20} {status}")
typer.echo("")
typer.echo("Special suites:")
for name, description in SPECIAL_SUITES.items():
typer.echo(f" {name:15} {description}")
typer.echo("")
typer.echo("Usage: praisonai test interactive --suite <name>")
typer.echo(" PRAISONAI_LIVE_INTERACTIVE=1 praisonai test interactive --suite advanced")
return
# Generate template
if generate_template:
# Lazy import
from praisonai.cli.features.csv_test_runner import generate_csv_template
output_path = Path.cwd() / "interactive_tests_template.csv"
generate_csv_template(output_path)
typer.echo(f"✅ Generated CSV template: {output_path}")
return
# Validate inputs
if not csv and not suite:
typer.echo("❌ Either --csv or --suite is required")
typer.echo("")
typer.echo("Examples:")
typer.echo(" praisonai test interactive --csv tests.csv")
typer.echo(" praisonai test interactive --suite smoke")
typer.echo(" praisonai test interactive --suite advanced")
typer.echo(" praisonai test interactive --list")
raise typer.Exit(1)
# Handle special suites
if suite and suite in SPECIAL_SUITES:
if suite == "advanced":
_run_advanced_suite(
model=model,
artifacts_dir=Path(artifacts_dir) if artifacts_dir else None,
keep_artifacts=keep_artifacts,
verbose=verbose,
)
return
# Get CSV path
if csv:
csv_path = Path(csv)
if not csv_path.exists():
typer.echo(f"❌ CSV file not found: {csv}")
raise typer.Exit(1)
else:
csv_path = _get_builtin_suite_path(suite)
if not csv_path:
typer.echo(f"❌ Built-in suite not found: {suite}")
typer.echo("Use --list to see available suites")
raise typer.Exit(1)
# Lazy import runner
from praisonai.cli.features.csv_test_runner import CSVTestRunner
typer.echo(f"🧪 Running interactive tests from: {csv_path}")
typer.echo(f" Model: {model} | Judge: {judge_model}")
typer.echo("")
# Create runner
runner = CSVTestRunner(
csv_path=csv_path,
model=model,
judge_model=judge_model,
workspace=Path(workspace) if workspace else None,
artifacts_dir=Path(artifacts_dir) if artifacts_dir else None,
fail_fast=fail_fast,
keep_artifacts=keep_artifacts,
no_judge=no_judge,
verbose=verbose,
)
# Run tests
summary = runner.run()
# Print summary
typer.echo("")
summary.print_summary()
# Save summary if keeping artifacts
if keep_artifacts and runner.artifacts_dir:
import json
summary_path = runner.artifacts_dir / "summary.json"
summary_path.parent.mkdir(parents=True, exist_ok=True)
with open(summary_path, "w") as f:
json.dump(summary.to_dict(), f, indent=2, default=str)
typer.echo(f"\n📁 Artifacts saved to: {runner.artifacts_dir}")
# Exit code based on results
if summary.failed > 0 or summary.errors > 0:
raise typer.Exit(1)
def _run_advanced_suite(
model: str = "gpt-4o-mini",
artifacts_dir = None,
keep_artifacts: bool = True,
verbose: bool = True,
):
"""Run the Advanced Agent-Centric test suite."""
from pathlib import Path
# Check prerequisites
typer.echo("🔍 Checking Advanced suite prerequisites...")
# Check if live interactive is enabled
live_enabled = os.environ.get("PRAISONAI_LIVE_INTERACTIVE", "0") == "1"
has_api_key = bool(os.environ.get("OPENAI_API_KEY"))
if not live_enabled:
typer.echo("\n⚠️ Cannot run Advanced tests: PRAISONAI_LIVE_INTERACTIVE=1 not set")
typer.echo("")
typer.echo("Prerequisites:")
typer.echo(" 1. Set PRAISONAI_LIVE_INTERACTIVE=1")
typer.echo(" 2. Set OPENAI_API_KEY")
typer.echo("")
typer.echo("Example:")
typer.echo(" PRAISONAI_LIVE_INTERACTIVE=1 praisonai test interactive --suite advanced")
raise typer.Exit(1)
if not has_api_key:
typer.echo("\n⚠️ Cannot run Advanced tests: OPENAI_API_KEY not set")
raise typer.Exit(1)
typer.echo("✅ Live interactive mode enabled")
typer.echo("✅ API key configured")
typer.echo("")
# Lazy import runner and scenarios
from tests.live.interactive.runner import LiveInteractiveRunner
from tests.live.interactive.advanced.scenarios import ALL_ADVANCED_SCENARIOS
# Create runner
runner = LiveInteractiveRunner(
model=model,
artifacts_dir=Path(artifacts_dir) if artifacts_dir else None,
keep_artifacts=keep_artifacts,
verbose=verbose,
)
# Run tests
summary = runner.run_all(ALL_ADVANCED_SCENARIOS)
# Print summary
typer.echo("")
summary.print_summary()
# Show artifacts location
if keep_artifacts:
typer.echo(f"\n📁 Artifacts saved to: {runner.artifacts_dir}")
# Exit code
if summary.failed > 0:
raise typer.Exit(1)
@app.command()
def info():
"""Show test configuration and available options."""
@@ -160,6 +160,8 @@ class ActionOrchestrator:
async def _create_fallback_plan(self, prompt: str, plan_id: str) -> ActionResult:
"""Create a fallback plan when ACP is not available for planning."""
import re # Import at function level for all pattern matching
# This is a simplified fallback - in production, this would use LLM
steps = []
@@ -168,7 +170,6 @@ class ActionOrchestrator:
if "create" in prompt_lower or "new file" in prompt_lower:
# Extract file path if mentioned
import re
file_match = re.search(r'(?:create|new)\s+(?:a\s+)?(?:file\s+)?(?:called\s+)?([^\s]+\.\w+)', prompt_lower)
target = file_match.group(1) if file_match else "new_file.py"
@@ -188,7 +189,8 @@ class ActionOrchestrator:
))
elif "edit" in prompt_lower or "modify" in prompt_lower or "change" in prompt_lower:
file_match = re.search(r'(?:edit|modify|change)\s+([^\s]+\.\w+)', prompt_lower)
# Match patterns like "edit file X", "edit X", "modify X"
file_match = re.search(r'(?:edit|modify|change)\s+(?:file\s+)?([^\s]+(?:\.\w+)?)', prompt_lower)
target = file_match.group(1) if file_match else ""
if target:
@@ -197,11 +199,13 @@ class ActionOrchestrator:
action_type=ActionType.FILE_EDIT,
description=f"Edit file: {target}",
target=target,
params={"changes": prompt}
params={"changes": prompt},
status=ActionStatus.APPROVED # Auto-approve for fallback
))
elif "delete" in prompt_lower or "remove" in prompt_lower:
file_match = re.search(r'(?:delete|remove)\s+([^\s]+\.\w+)', prompt_lower)
# Match patterns like "delete file X", "delete X", "remove X"
file_match = re.search(r'(?:delete|remove)\s+(?:file\s+)?([^\s]+(?:\.\w+)?)', prompt_lower)
target = file_match.group(1) if file_match else ""
if target:
@@ -209,7 +213,8 @@ class ActionOrchestrator:
id=self._generate_step_id(plan_id, 0),
action_type=ActionType.FILE_DELETE,
description=f"Delete file: {target}",
target=target
target=target,
status=ActionStatus.APPROVED # Auto-approve for fallback
))
elif "rename" in prompt_lower:
@@ -218,19 +223,22 @@ class ActionOrchestrator:
action_type=ActionType.REFACTOR,
description="Rename operation",
target="",
params={"prompt": prompt}
params={"prompt": prompt},
status=ActionStatus.APPROVED # Auto-approve for fallback
))
elif "run" in prompt_lower or "execute" in prompt_lower:
cmd_match = re.search(r'(?:run|execute)\s+[`"\']?([^`"\']+)[`"\']?', prompt_lower)
command = cmd_match.group(1) if cmd_match else ""
elif "run" in prompt_lower or "execute" in prompt_lower or "command" in prompt_lower:
# Match patterns like "run command: X", "execute X", "run X"
cmd_match = re.search(r'(?:run|execute)\s+(?:command[:\s]+)?[`"\']?(.+?)[`"\']?$', prompt_lower)
command = cmd_match.group(1).strip() if cmd_match else ""
if command:
steps.append(ActionStep(
id=self._generate_step_id(plan_id, 0),
action_type=ActionType.SHELL_COMMAND,
description=f"Execute: {command}",
target=command
target=command,
status=ActionStatus.APPROVED # Auto-approve for fallback
))
if not steps:
@@ -596,20 +596,34 @@ class CodeIntelligenceRouter:
def _extract_symbol_from_query(self, query: str) -> Optional[str]:
"""Extract symbol name from query."""
# Common patterns for symbol extraction
# Common patterns for symbol extraction (order matters - more specific first)
patterns = [
r'(?:definition\s+of|where\s+is|find|show)\s+["\']?(\w+)["\']?',
# "find all references to X" - capture X after "to"
r'references?\s+to\s+["\']?(\w+)["\']?',
# "definition of X" - capture X after "of"
r'definition\s+of\s+["\']?(\w+)["\']?',
# "where is X defined/used"
r'where\s+is\s+["\']?(\w+)["\']?\s+(?:defined|used)',
# Quoted or backticked symbols
r'["\'](\w+)["\']',
r'`(\w+)`',
# "X is defined/used"
r'(\w+)\s+(?:is\s+)?(?:defined|used)',
# Generic "find/show X" - but filter common words
r'(?:find|show)\s+["\']?(\w+)["\']?',
]
# Words to filter out
filter_words = {'the', 'a', 'an', 'is', 'are', 'in', 'of', 'to', 'for',
'all', 'any', 'some', 'this', 'that', 'where', 'what',
'definition', 'references', 'reference', 'symbol', 'symbols'}
for pattern in patterns:
match = re.search(pattern, query, re.IGNORECASE)
if match:
symbol = match.group(1)
# Filter out common words
if symbol.lower() not in ['the', 'a', 'an', 'is', 'are', 'in', 'of', 'to', 'for']:
if symbol.lower() not in filter_words:
return symbol
return None
@@ -0,0 +1,736 @@
"""
CSV-driven Interactive Test Runner for PraisonAI.
Provides a CSV-driven test runner that:
- Loads test cases from CSV with defined schema
- Runs each test in isolated temp workspace
- Executes prompts via headless interactive core
- Validates tool calls, files, and responses
- Integrates LLM-as-judge for response quality
- Emits artifacts per-test
"""
import csv
import json
import logging
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# CSV Schema definition
CSV_SCHEMA = {
# Required columns
"id": {"required": True, "type": str, "description": "Unique test identifier"},
"name": {"required": True, "type": str, "description": "Test name"},
"prompts": {"required": True, "type": str, "description": "Single prompt or JSON array"},
# Optional columns
"description": {"required": False, "type": str, "default": ""},
"mode": {"required": False, "type": str, "default": "headless"},
"workspace_fixture": {"required": False, "type": str, "default": "empty"},
"expected_tools": {"required": False, "type": str, "default": ""},
"forbidden_tools": {"required": False, "type": str, "default": ""},
"expected_files": {"required": False, "type": str, "default": "{}"},
"expected_response": {"required": False, "type": str, "default": ""},
"judge_rubric": {"required": False, "type": str, "default": ""},
"judge_threshold": {"required": False, "type": float, "default": 7.0},
"judge_model": {"required": False, "type": str, "default": "gpt-4o-mini"},
"timeout": {"required": False, "type": int, "default": 60},
"retries": {"required": False, "type": int, "default": 0},
"skip_if": {"required": False, "type": str, "default": ""},
"agents": {"required": False, "type": str, "default": "[]"},
"workflow": {"required": False, "type": str, "default": "{}"},
}
@dataclass
class TestCase:
"""A single test case from CSV."""
id: str
name: str
prompts: List[str]
description: str = ""
mode: str = "headless"
workspace_fixture: str = "empty"
expected_tools: List[str] = field(default_factory=list)
forbidden_tools: List[str] = field(default_factory=list)
expected_files: Dict[str, str] = field(default_factory=dict)
expected_response: str = ""
judge_rubric: str = ""
judge_threshold: float = 7.0
judge_model: str = "gpt-4o-mini"
timeout: int = 60
retries: int = 0
skip_if: str = ""
agents: List[Dict[str, Any]] = field(default_factory=list)
workflow: Dict[str, Any] = field(default_factory=dict)
def should_skip(self) -> Optional[str]:
"""Check if test should be skipped."""
if not self.skip_if:
return None
conditions = [c.strip() for c in self.skip_if.split(",")]
for condition in conditions:
if condition == "no_openai_key":
if not os.environ.get("OPENAI_API_KEY"):
return "OPENAI_API_KEY not set"
elif condition == "no_anthropic_key":
if not os.environ.get("ANTHROPIC_API_KEY"):
return "ANTHROPIC_API_KEY not set"
elif condition == "no_lsp":
# Could check if LSP is available
pass
elif condition == "no_network":
if os.environ.get("PRAISONAI_NO_NETWORK"):
return "Network disabled"
return None
@dataclass
class TestResult:
"""Result of a single test."""
test_id: str
test_name: str
status: str # passed, failed, skipped, error
duration: float
tool_calls: List[str] = field(default_factory=list)
response: str = ""
judge_score: Optional[float] = None
judge_passed: Optional[bool] = None
judge_reasoning: str = ""
artifacts_path: Optional[str] = None
error: Optional[str] = None
skip_reason: Optional[str] = None
assertions: Dict[str, bool] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"test_id": self.test_id,
"test_name": self.test_name,
"status": self.status,
"duration": self.duration,
"tool_calls": self.tool_calls,
"response": self.response[:500] if self.response else "",
"judge_score": self.judge_score,
"judge_passed": self.judge_passed,
"judge_reasoning": self.judge_reasoning,
"artifacts_path": self.artifacts_path,
"error": self.error,
"skip_reason": self.skip_reason,
"assertions": self.assertions,
}
@dataclass
class TestSummary:
"""Summary of all test results."""
total: int = 0
passed: int = 0
failed: int = 0
skipped: int = 0
errors: int = 0
duration: float = 0.0
judge_avg: Optional[float] = None
results: List[TestResult] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"total": self.total,
"passed": self.passed,
"failed": self.failed,
"skipped": self.skipped,
"errors": self.errors,
"duration": self.duration,
"judge_avg": self.judge_avg,
"pass_rate": self.passed / self.total if self.total > 0 else 0,
"results": [r.to_dict() for r in self.results],
}
def print_summary(self) -> None:
"""Print summary to console."""
try:
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Interactive Test Results")
table.add_column("ID", style="cyan")
table.add_column("Name", style="white")
table.add_column("Status")
table.add_column("Duration", justify="right")
table.add_column("Judge", justify="right")
for r in self.results:
status_style = {
"passed": "[green]✓ passed[/green]",
"failed": "[red]✗ failed[/red]",
"skipped": "[yellow]○ skipped[/yellow]",
"error": "[red]! error[/red]",
}.get(r.status, r.status)
judge_str = f"{r.judge_score:.1f}" if r.judge_score is not None else "-"
table.add_row(
r.test_id,
r.test_name[:30],
status_style,
f"{r.duration:.2f}s",
judge_str,
)
console.print(table)
console.print()
console.print(f"[bold]Total:[/bold] {self.total} | "
f"[green]Passed:[/green] {self.passed} | "
f"[red]Failed:[/red] {self.failed} | "
f"[yellow]Skipped:[/yellow] {self.skipped}")
if self.judge_avg is not None:
console.print(f"[bold]Average Judge Score:[/bold] {self.judge_avg:.2f}")
console.print(f"[bold]Total Duration:[/bold] {self.duration:.2f}s")
except ImportError:
# Fallback to plain print
print("\n=== Interactive Test Results ===")
for r in self.results:
status = {"passed": "✓", "failed": "✗", "skipped": "○", "error": "!"}.get(r.status, "?")
print(f" {status} {r.test_id}: {r.test_name} ({r.duration:.2f}s)")
print(f"\nTotal: {self.total} | Passed: {self.passed} | Failed: {self.failed} | Skipped: {self.skipped}")
def parse_csv(csv_path: Path) -> List[TestCase]:
"""
Parse CSV file into TestCase objects.
Args:
csv_path: Path to CSV file
Returns:
List of TestCase objects
"""
test_cases = []
with open(csv_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row_num, row in enumerate(reader, start=2): # Start at 2 (header is 1)
try:
# Parse prompts (single string or JSON array)
prompts_raw = row.get("prompts", "").strip()
if prompts_raw.startswith("["):
prompts = json.loads(prompts_raw)
else:
prompts = [prompts_raw] if prompts_raw else []
# Parse expected_tools
expected_tools = []
if row.get("expected_tools"):
expected_tools = [t.strip() for t in row["expected_tools"].split(",") if t.strip()]
# Parse forbidden_tools
forbidden_tools = []
if row.get("forbidden_tools"):
forbidden_tools = [t.strip() for t in row["forbidden_tools"].split(",") if t.strip()]
# Parse expected_files JSON
expected_files = {}
if row.get("expected_files"):
try:
expected_files = json.loads(row["expected_files"])
except json.JSONDecodeError:
logger.warning(f"Row {row_num}: Invalid JSON in expected_files")
# Parse agents JSON
agents = []
if row.get("agents"):
try:
agents = json.loads(row["agents"])
except json.JSONDecodeError:
logger.warning(f"Row {row_num}: Invalid JSON in agents")
# Parse workflow JSON
workflow = {}
if row.get("workflow"):
try:
workflow = json.loads(row["workflow"])
except json.JSONDecodeError:
logger.warning(f"Row {row_num}: Invalid JSON in workflow")
# Parse numeric fields
judge_threshold = float(row.get("judge_threshold", 7.0) or 7.0)
timeout = int(row.get("timeout", 60) or 60)
retries = int(row.get("retries", 0) or 0)
test_case = TestCase(
id=row.get("id", f"test_{row_num}"),
name=row.get("name", f"Test {row_num}"),
prompts=prompts,
description=row.get("description", ""),
mode=row.get("mode", "headless"),
workspace_fixture=row.get("workspace_fixture", "empty"),
expected_tools=expected_tools,
forbidden_tools=forbidden_tools,
expected_files=expected_files,
expected_response=row.get("expected_response", ""),
judge_rubric=row.get("judge_rubric", ""),
judge_threshold=judge_threshold,
judge_model=row.get("judge_model", "gpt-4o-mini"),
timeout=timeout,
retries=retries,
skip_if=row.get("skip_if", ""),
agents=agents,
workflow=workflow,
)
test_cases.append(test_case)
except Exception as e:
logger.error(f"Error parsing row {row_num}: {e}")
continue
return test_cases
class CSVTestRunner:
"""
CSV-driven test runner for interactive mode.
Usage:
runner = CSVTestRunner(csv_path="tests.csv")
summary = runner.run()
summary.print_summary()
"""
def __init__(
self,
csv_path: Path,
model: str = "gpt-4o-mini",
judge_model: str = "gpt-4o-mini",
workspace: Optional[Path] = None,
artifacts_dir: Optional[Path] = None,
fail_fast: bool = False,
keep_artifacts: bool = False,
no_judge: bool = False,
verbose: bool = False,
):
"""
Initialize CSV test runner.
Args:
csv_path: Path to CSV file with test cases
model: LLM model for agent
judge_model: LLM model for judge
workspace: Base workspace directory
artifacts_dir: Directory for artifacts
fail_fast: Stop on first failure
keep_artifacts: Keep artifacts after run
no_judge: Skip judge even if rubric present
verbose: Verbose output
"""
self.csv_path = Path(csv_path)
self.model = model
self.judge_model = judge_model
self.workspace = workspace
self.artifacts_dir = artifacts_dir
self.fail_fast = fail_fast
self.keep_artifacts = keep_artifacts
self.no_judge = no_judge
self.verbose = verbose
self.results: List[TestResult] = []
def run(self) -> TestSummary:
"""
Run all tests from CSV.
Returns:
TestSummary with all results
"""
start_time = time.time()
# Parse CSV
test_cases = parse_csv(self.csv_path)
logger.info(f"Loaded {len(test_cases)} test cases from {self.csv_path}")
if self.verbose:
print(f"Running {len(test_cases)} tests from {self.csv_path}")
# Run each test
for i, test_case in enumerate(test_cases):
if self.verbose:
print(f"\n[{i+1}/{len(test_cases)}] Running: {test_case.name}")
# Check skip conditions
skip_reason = test_case.should_skip()
if skip_reason:
result = TestResult(
test_id=test_case.id,
test_name=test_case.name,
status="skipped",
duration=0.0,
skip_reason=skip_reason,
)
self.results.append(result)
if self.verbose:
print(f" Skipped: {skip_reason}")
continue
# Run test
result = self._run_single_test(test_case)
self.results.append(result)
if self.verbose:
status_icon = {"passed": "✓", "failed": "✗", "error": "!"}.get(result.status, "?")
print(f" {status_icon} {result.status} ({result.duration:.2f}s)")
if result.error:
print(f" Error: {result.error}")
# Fail fast
if self.fail_fast and result.status in ("failed", "error"):
logger.info(f"Stopping due to fail-fast: {test_case.id}")
break
# Create summary
summary = self._create_summary(time.time() - start_time)
return summary
def _run_single_test(self, test_case: TestCase) -> TestResult:
"""Run a single test case."""
import tempfile
start_time = time.time()
# Create workspace
if self.workspace:
test_workspace = self.workspace / test_case.id
else:
test_workspace = Path(tempfile.mkdtemp(prefix=f"praison_test_{test_case.id}_"))
# Create artifacts dir
if self.artifacts_dir:
test_artifacts = self.artifacts_dir / test_case.id
else:
test_artifacts = test_workspace / "artifacts"
try:
# Import harness (lazy)
from .interactive_test_harness import InteractiveTestHarness
# Create harness
harness = InteractiveTestHarness(
workspace=test_workspace,
artifacts_dir=test_artifacts,
keep_workspace=self.keep_artifacts,
)
# Setup workspace
harness.setup_workspace(test_case.workspace_fixture)
# Run prompts
exec_result = harness.run(
prompts=test_case.prompts,
model=self.model,
approval_mode="auto",
agents=test_case.agents if test_case.agents else None,
workflow=test_case.workflow if test_case.workflow else None,
)
duration = time.time() - start_time
# Get response
response = "\n".join(exec_result.responses) if exec_result.responses else ""
# Verify assertions
assertions = {}
# Tool call assertions
tool_result = harness.verify_tool_calls(
expected_tools=test_case.expected_tools,
forbidden_tools=test_case.forbidden_tools,
)
assertions["tools"] = tool_result["passed"]
# File assertions
if test_case.expected_files:
file_results = harness.verify_files(test_case.expected_files)
assertions["files"] = all(file_results.values())
else:
assertions["files"] = True
# Response assertion
if test_case.expected_response:
assertions["response"] = harness.verify_response(response, test_case.expected_response)
else:
assertions["response"] = True
# Judge evaluation
judge_score = None
judge_passed = None
judge_reasoning = ""
if test_case.judge_rubric and not self.no_judge:
judge_result = self._run_judge(
response=response,
rubric=test_case.judge_rubric,
threshold=test_case.judge_threshold,
model=test_case.judge_model or self.judge_model,
)
judge_score = judge_result.get("score")
judge_passed = judge_result.get("passed")
judge_reasoning = judge_result.get("reasoning", "")
assertions["judge"] = judge_passed if judge_passed is not None else True
harness.artifacts.judge_result = judge_result
# Determine overall status
all_passed = all(assertions.values()) and exec_result.success
status = "passed" if all_passed else "failed"
# Snapshot workspace if keeping artifacts
if self.keep_artifacts:
harness.snapshot_workspace()
# Save artifacts
artifacts_path = str(harness.save_artifacts(test_case.id))
return TestResult(
test_id=test_case.id,
test_name=test_case.name,
status=status,
duration=duration,
tool_calls=harness._executor.get_tools_called() if harness._executor else [],
response=response,
judge_score=judge_score,
judge_passed=judge_passed,
judge_reasoning=judge_reasoning,
artifacts_path=artifacts_path,
assertions=assertions,
)
except Exception as e:
logger.error(f"Test {test_case.id} error: {e}", exc_info=True)
return TestResult(
test_id=test_case.id,
test_name=test_case.name,
status="error",
duration=time.time() - start_time,
error=str(e),
)
finally:
if not self.keep_artifacts:
try:
import shutil
if test_workspace.exists() and not self.workspace:
shutil.rmtree(test_workspace)
except Exception:
pass
def _run_judge(
self,
response: str,
rubric: str,
threshold: float,
model: str,
) -> Dict[str, Any]:
"""
Run LLM judge on response using Agent class.
Args:
response: Response to evaluate
rubric: Evaluation rubric
threshold: Pass threshold (1-10)
model: Judge model
Returns:
Dict with score, passed, reasoning
"""
try:
from praisonaiagents import Agent
# Create judge agent
judge_agent = Agent(
name="Judge",
role="Evaluator",
goal="Evaluate response quality based on rubric",
instructions=f"""You are an expert evaluator. Your task is to evaluate a response based on the given rubric.
RUBRIC:
{rubric}
RESPONSE TO EVALUATE:
{response}
Provide your evaluation in the following format:
SCORE: [1-10]
REASONING: [Your detailed reasoning]
Be strict but fair. A score of 7+ means the response adequately meets the rubric criteria.""",
llm=model,
verbose=False,
)
# Get evaluation
eval_response = judge_agent.chat("Please evaluate the response based on the rubric.")
# Parse score from response
score = self._parse_judge_score(eval_response)
passed = score >= threshold if score is not None else None
return {
"score": score,
"passed": passed,
"reasoning": eval_response,
"threshold": threshold,
"model": model,
}
except Exception as e:
logger.warning(f"Judge evaluation failed: {e}")
return {
"score": None,
"passed": None,
"reasoning": f"Judge error: {e}",
"error": str(e),
}
def _parse_judge_score(self, response: str) -> Optional[float]:
"""Parse score from judge response."""
import re
# Try to find "SCORE: X" pattern
match = re.search(r"SCORE:\s*(\d+(?:\.\d+)?)", response, re.IGNORECASE)
if match:
try:
return float(match.group(1))
except ValueError:
pass
# Try to find standalone number at start
match = re.search(r"^(\d+(?:\.\d+)?)", response.strip())
if match:
try:
score = float(match.group(1))
if 1 <= score <= 10:
return score
except ValueError:
pass
return None
def _create_summary(self, total_duration: float) -> TestSummary:
"""Create test summary."""
summary = TestSummary(
total=len(self.results),
passed=sum(1 for r in self.results if r.status == "passed"),
failed=sum(1 for r in self.results if r.status == "failed"),
skipped=sum(1 for r in self.results if r.status == "skipped"),
errors=sum(1 for r in self.results if r.status == "error"),
duration=total_duration,
results=self.results,
)
# Calculate average judge score
judge_scores = [r.judge_score for r in self.results if r.judge_score is not None]
if judge_scores:
summary.judge_avg = sum(judge_scores) / len(judge_scores)
return summary
def generate_csv_template(output_path: Path = None) -> str:
"""
Generate a CSV template with all columns.
Args:
output_path: Path to write template (optional)
Returns:
CSV template string
"""
headers = list(CSV_SCHEMA.keys())
# Example rows
examples = [
{
"id": "smoke_01",
"name": "Basic Chat",
"prompts": "Hello, what is 2+2?",
"description": "Test basic chat response",
"mode": "headless",
"workspace_fixture": "empty",
"expected_tools": "",
"forbidden_tools": "",
"expected_files": "{}",
"expected_response": "4",
"judge_rubric": "Response contains the number 4",
"judge_threshold": "7.0",
"judge_model": "gpt-4o-mini",
"timeout": "60",
"retries": "0",
"skip_if": "",
"agents": "[]",
"workflow": "{}",
},
{
"id": "tools_01",
"name": "Create File",
"prompts": "Create a file called hello.py with print('hello')",
"description": "Test file creation",
"mode": "headless",
"workspace_fixture": "empty",
"expected_tools": "acp_create_file",
"forbidden_tools": "",
"expected_files": '{"hello.py": "print.*hello"}',
"expected_response": "",
"judge_rubric": "File was created successfully",
"judge_threshold": "7.0",
"judge_model": "gpt-4o-mini",
"timeout": "60",
"retries": "0",
"skip_if": "no_openai_key",
"agents": "[]",
"workflow": "{}",
},
{
"id": "multi_01",
"name": "Multi-step Edit",
"prompts": '["Create test.py with x=1", "Edit test.py to change x=1 to x=2"]',
"description": "Test multi-step editing",
"mode": "headless",
"workspace_fixture": "empty",
"expected_tools": "acp_create_file,acp_edit_file",
"forbidden_tools": "",
"expected_files": '{"test.py": "x.*=.*2"}',
"expected_response": "",
"judge_rubric": "",
"judge_threshold": "7.0",
"judge_model": "gpt-4o-mini",
"timeout": "120",
"retries": "0",
"skip_if": "no_openai_key",
"agents": "[]",
"workflow": "{}",
},
]
# Build CSV string
import io
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=headers)
writer.writeheader()
for example in examples:
writer.writerow(example)
csv_content = output.getvalue()
if output_path:
Path(output_path).write_text(csv_content)
logger.info(f"Generated CSV template at {output_path}")
return csv_content
@@ -0,0 +1,450 @@
"""
Headless Interactive Core Executor for PraisonAI.
This module provides a headless execution mode that uses the SAME interactive core
pipeline as the interactive TUI:
- InteractiveRuntime config
- get_interactive_tools() tool resolution
- ActionOrchestrator behavior (plan/approve/apply/verify)
- Tool call tracing
This is for tests and CI - no terminal/prompt_toolkit dependency.
"""
import functools
import logging
import os
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
logger = logging.getLogger(__name__)
@dataclass
class ToolCallTrace:
"""Record of a single tool call."""
tool_name: str
args: Tuple
kwargs: Dict[str, Any]
result: Any
success: bool
error: Optional[str]
duration: float
timestamp: str
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"tool_name": self.tool_name,
"args": _redact_sensitive(self.args),
"kwargs": _redact_sensitive(self.kwargs),
"result": _truncate(str(self.result), 500) if self.result else None,
"success": self.success,
"error": self.error,
"duration": self.duration,
"timestamp": self.timestamp,
}
@dataclass
class HeadlessExecutionResult:
"""Result of a headless execution."""
success: bool
responses: List[str]
tool_trace: List[ToolCallTrace]
duration: float
error: Optional[str] = None
transcript: List[Dict[str, str]] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"success": self.success,
"responses": self.responses,
"tool_trace": [t.to_dict() for t in self.tool_trace],
"duration": self.duration,
"error": self.error,
"transcript": self.transcript,
}
@dataclass
class AgentConfig:
"""Configuration for a single agent in headless mode."""
name: str = "HeadlessAgent"
instructions: str = "You are a helpful assistant with file and code tools."
role: str = "assistant"
llm: str = "gpt-4o-mini"
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"instructions": self.instructions,
"role": self.role,
"llm": self.llm,
}
@dataclass
class HeadlessConfig:
"""Configuration for headless interactive core execution."""
workspace: str = field(default_factory=os.getcwd)
model: str = "gpt-4o-mini"
approval_mode: str = "auto" # auto, manual, scoped
enable_acp: bool = True
enable_lsp: bool = True
enable_basic: bool = True
timeout: int = 60
verbose: bool = False
# Multi-agent support
agents: List[AgentConfig] = field(default_factory=list)
workflow: Optional[Dict[str, Any]] = None # Turn routing/handoffs
def __post_init__(self):
if not self.agents:
# Default single agent
self.agents = [AgentConfig(llm=self.model)]
def _redact_sensitive(obj: Any, sensitive_keys: set = None) -> Any:
"""Redact sensitive information from args/kwargs."""
if sensitive_keys is None:
sensitive_keys = {"api_key", "password", "secret", "token", "key"}
if isinstance(obj, dict):
return {
k: "[REDACTED]" if any(s in k.lower() for s in sensitive_keys) else _redact_sensitive(v, sensitive_keys)
for k, v in obj.items()
}
elif isinstance(obj, (list, tuple)):
return type(obj)(_redact_sensitive(item, sensitive_keys) for item in obj)
elif isinstance(obj, str) and len(obj) > 100:
return obj[:100] + "...[truncated]"
return obj
def _truncate(s: str, max_len: int) -> str:
"""Truncate string to max length."""
if len(s) <= max_len:
return s
return s[:max_len] + "...[truncated]"
class HeadlessInteractiveCore:
"""
Headless executor that uses the same interactive core pipeline as TUI.
This class reuses:
- InteractiveRuntime for ACP/LSP subsystems
- get_interactive_tools() for tool resolution
- ActionOrchestrator for plan/approve/apply/verify flow
Usage:
executor = HeadlessInteractiveCore(config)
result = executor.run(["Create a file hello.py with print('hello')"])
executor.cleanup() # IMPORTANT: Always call cleanup to stop LSP/ACP
Or use as context manager:
with HeadlessInteractiveCore(config) as executor:
result = executor.run(["Create a file"])
"""
def __init__(self, config: Optional[HeadlessConfig] = None):
self.config = config or HeadlessConfig()
self._runtime = None
self._tools: List[Callable] = []
self._tool_trace: List[ToolCallTrace] = []
self._agents: List[Any] = [] # Will hold Agent instances
self._initialized = False
self._cleanup_done = False
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit - ensures cleanup."""
self.cleanup()
return False
def __del__(self):
"""Destructor - attempt cleanup if not done."""
if not self._cleanup_done:
try:
self.cleanup()
except Exception:
pass # Best effort cleanup in destructor
def _wrap_tool_for_trace(self, tool: Callable) -> Callable:
"""Wrap a tool to capture calls in trace."""
@functools.wraps(tool)
def wrapper(*args, **kwargs):
start = time.time()
timestamp = datetime.utcnow().isoformat()
try:
result = tool(*args, **kwargs)
trace = ToolCallTrace(
tool_name=tool.__name__,
args=args,
kwargs=kwargs,
result=result,
success=True,
error=None,
duration=time.time() - start,
timestamp=timestamp,
)
self._tool_trace.append(trace)
logger.debug(f"Tool {tool.__name__} succeeded in {trace.duration:.3f}s")
return result
except Exception as e:
trace = ToolCallTrace(
tool_name=tool.__name__,
args=args,
kwargs=kwargs,
result=None,
success=False,
error=str(e),
duration=time.time() - start,
timestamp=timestamp,
)
self._tool_trace.append(trace)
logger.debug(f"Tool {tool.__name__} failed: {e}")
raise
return wrapper
def _initialize(self) -> None:
"""Initialize runtime and tools using interactive core path."""
if self._initialized:
return
logger.debug(f"Initializing headless core with workspace={self.config.workspace}")
# Use the SAME tool loading path as interactive TUI
# This is from: praisonai/cli/features/interactive_tools.py
from praisonai.cli.features.interactive_tools import (
get_interactive_tools,
ToolConfig,
)
# Create tool config matching interactive core
tool_config = ToolConfig(
workspace=self.config.workspace,
enable_acp=self.config.enable_acp,
enable_lsp=self.config.enable_lsp,
enable_basic=self.config.enable_basic,
approval_mode=self.config.approval_mode,
lsp_enabled=self.config.enable_lsp,
acp_enabled=self.config.enable_acp,
)
# Get tools using the SAME function as TUI
raw_tools = get_interactive_tools(config=tool_config)
# Wrap tools for tracing
self._tools = [self._wrap_tool_for_trace(t) for t in raw_tools]
logger.debug(f"Loaded {len(self._tools)} tools: {[t.__name__ for t in self._tools]}")
# Create agents using the SAME Agent class as TUI
from praisonaiagents import Agent
for agent_config in self.config.agents:
agent = Agent(
name=agent_config.name,
role=agent_config.role,
goal="Help the user with their requests",
instructions=agent_config.instructions,
llm=agent_config.llm,
tools=self._tools if self._tools else None,
)
self._agents.append(agent)
logger.debug(f"Created agent: {agent_config.name}")
self._initialized = True
def run(self, prompts: Union[str, List[str]]) -> HeadlessExecutionResult:
"""
Run prompts through headless interactive core.
Args:
prompts: Single prompt or list of prompts for multi-step execution
Returns:
HeadlessExecutionResult with responses, tool trace, and transcript
"""
if isinstance(prompts, str):
prompts = [prompts]
start_time = time.time()
self._tool_trace = [] # Reset trace
responses = []
transcript = []
try:
self._initialize()
# Execute prompts
for i, prompt in enumerate(prompts):
logger.debug(f"Executing prompt {i+1}/{len(prompts)}: {prompt[:50]}...")
transcript.append({"role": "user", "content": prompt})
# Determine which agent to use
agent_idx = self._get_agent_for_prompt(i, prompt)
agent = self._agents[agent_idx]
# Execute using the SAME chat method as TUI
response = agent.chat(prompt)
responses.append(response)
transcript.append({"role": "assistant", "content": response})
logger.debug(f"Got response: {response[:100] if response else 'None'}...")
return HeadlessExecutionResult(
success=True,
responses=responses,
tool_trace=self._tool_trace,
duration=time.time() - start_time,
transcript=transcript,
)
except Exception as e:
logger.error(f"Headless execution failed: {e}", exc_info=True)
return HeadlessExecutionResult(
success=False,
responses=responses,
tool_trace=self._tool_trace,
duration=time.time() - start_time,
error=str(e),
transcript=transcript,
)
def _get_agent_for_prompt(self, prompt_idx: int, prompt: str) -> int:
"""Determine which agent should handle a prompt (for multi-agent)."""
if len(self._agents) == 1:
return 0
# If workflow is defined, use it for routing
if self.config.workflow:
routing = self.config.workflow.get("routing", {})
# Simple round-robin or keyword-based routing
if "round_robin" in routing:
return prompt_idx % len(self._agents)
elif "keywords" in routing:
for i, agent_config in enumerate(self.config.agents):
keywords = routing["keywords"].get(agent_config.name, [])
if any(kw.lower() in prompt.lower() for kw in keywords):
return i
# Default: round-robin
return prompt_idx % len(self._agents)
def get_tool_trace(self) -> List[Dict[str, Any]]:
"""Get the tool call trace as list of dicts."""
return [t.to_dict() for t in self._tool_trace]
def get_tools_called(self) -> List[str]:
"""Get list of tool names that were called."""
return [t.tool_name for t in self._tool_trace]
def clear_trace(self) -> None:
"""Clear the tool trace."""
self._tool_trace = []
def cleanup(self) -> None:
"""
Clean up resources - MUST be called when done.
Stops LSP and ACP subsystems that were started during initialization.
This is critical to prevent resource leaks and orphaned processes.
"""
if self._cleanup_done:
return
logger.debug("Cleaning up HeadlessInteractiveCore resources...")
# Stop the runtime if it was created
if self._runtime is not None:
try:
import asyncio
# Get or create event loop
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
# Schedule the stop coroutine
asyncio.ensure_future(self._runtime.stop())
else:
# Create new loop and run
asyncio.run(self._runtime.stop())
logger.debug("Runtime stopped successfully")
except Exception as e:
logger.warning(f"Error stopping runtime: {e}")
finally:
self._runtime = None
# Clear agents
self._agents = []
self._tools = []
self._initialized = False
self._cleanup_done = True
logger.debug("HeadlessInteractiveCore cleanup complete")
def run_headless(
prompts: Union[str, List[str]],
workspace: Optional[str] = None,
model: str = "gpt-4o-mini",
approval_mode: str = "auto",
timeout: int = 60,
agents: Optional[List[Dict[str, Any]]] = None,
workflow: Optional[Dict[str, Any]] = None,
) -> HeadlessExecutionResult:
"""
Convenience function to run prompts through headless interactive core.
Args:
prompts: Single prompt or list of prompts
workspace: Working directory (default: cwd)
model: LLM model to use
approval_mode: Approval mode (auto, manual, scoped)
timeout: Timeout in seconds
agents: List of agent configs for multi-agent
workflow: Workflow config for turn routing
Returns:
HeadlessExecutionResult
Example:
result = run_headless("Create a file hello.py with print('hello')")
print(result.responses[0])
print(result.get_tools_called())
"""
agent_configs = []
if agents:
for a in agents:
agent_configs.append(AgentConfig(
name=a.get("name", "Agent"),
instructions=a.get("instructions", "You are a helpful assistant."),
role=a.get("role", "assistant"),
llm=a.get("llm", model),
))
config = HeadlessConfig(
workspace=workspace or os.getcwd(),
model=model,
approval_mode=approval_mode,
timeout=timeout,
agents=agent_configs if agent_configs else None,
workflow=workflow,
)
# Use context manager to ensure cleanup
with HeadlessInteractiveCore(config) as executor:
return executor.run(prompts)
@@ -40,7 +40,7 @@ class RuntimeConfig:
workspace: str = "."
lsp_enabled: bool = True
acp_enabled: bool = True
approval_mode: str = "manual" # manual, auto, scoped
approval_mode: str = "auto" # auto (full privileges), manual, scoped
trace_enabled: bool = False
trace_file: Optional[str] = None
json_output: bool = False
@@ -146,15 +146,21 @@ class InteractiveRuntime:
def read_only(self) -> bool:
"""Check if runtime is in read-only mode.
Note: When PRAISON_APPROVAL_MODE=auto, we allow writes even without ACP
to support non-interactive automation and testing scenarios.
Note: When approval_mode=auto (via config or env), we allow writes even
without ACP to support non-interactive automation and testing scenarios.
"""
# Auto-approval mode bypasses read-only for automation
import os as _os
approval_mode = _os.environ.get("PRAISON_APPROVAL_MODE", "").lower()
if approval_mode == "auto":
# Check config approval_mode first (takes precedence)
if self.config.approval_mode == "auto":
return False
return self._read_only or not self.acp_ready
# Then check environment variable as fallback
import os as _os
env_approval_mode = _os.environ.get("PRAISON_APPROVAL_MODE", "").lower()
if env_approval_mode == "auto":
return False
# Only enforce read-only if explicitly set or ACP not ready
return self._read_only
def get_status(self) -> Dict[str, Any]:
"""Get runtime status."""
@@ -0,0 +1,537 @@
"""
Interactive Test Harness for PraisonAI.
Provides workspace isolation, fixtures, tool trace wrappers, and artifact writing
for testing interactive mode through the headless core executor.
"""
import json
import logging
import re
import shutil
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Blocked commands for safe execution
BLOCKED_COMMANDS = {
"rm -rf /",
"rm -rf /*",
"sudo rm",
"mkfs",
"dd if=",
":(){:|:&};:", # Fork bomb
"chmod -R 777 /",
"curl | sh",
"wget | sh",
}
# Allowed commands (whitelist for strict mode)
SAFE_COMMANDS = {
"echo",
"cat",
"ls",
"pwd",
"head",
"tail",
"wc",
"grep",
"find",
"python",
"python3",
"pip",
"pip3",
"node",
"npm",
}
@dataclass
class WorkspaceFixture:
"""Definition of a workspace fixture."""
name: str
files: Dict[str, str] = field(default_factory=dict) # path -> content
directories: List[str] = field(default_factory=list)
@classmethod
def empty(cls) -> "WorkspaceFixture":
"""Create an empty workspace fixture."""
return cls(name="empty")
@classmethod
def seeded(cls) -> "WorkspaceFixture":
"""Create a seeded workspace with common files."""
return cls(
name="seeded",
files={
"README.md": "# Test Project\n\nThis is a test project.",
"main.py": "# Main entry point\nprint('Hello, World!')\n",
"config.json": '{"name": "test", "version": "1.0.0"}',
},
directories=["src", "tests"],
)
@classmethod
def git(cls) -> "WorkspaceFixture":
"""Create a git-initialized workspace."""
fixture = cls.seeded()
fixture.name = "git"
fixture.files[".gitignore"] = "__pycache__/\n*.pyc\n.env\n"
return fixture
@classmethod
def python_project(cls) -> "WorkspaceFixture":
"""Create a Python project workspace."""
return cls(
name="python_project",
files={
"README.md": "# Python Project\n",
"main.py": '''"""Main module."""
def main():
"""Main function."""
print("Hello from main!")
if __name__ == "__main__":
main()
''',
"utils.py": '''"""Utility functions."""
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
def multiply(a: int, b: int) -> int:
"""Multiply two numbers."""
return a * b
class Calculator:
"""Simple calculator class."""
def __init__(self):
self.result = 0
def add(self, x: int) -> int:
self.result += x
return self.result
''',
"requirements.txt": "# No dependencies\n",
},
directories=["tests"],
)
BUILTIN_FIXTURES = {
"empty": WorkspaceFixture.empty,
"seeded": WorkspaceFixture.seeded,
"git": WorkspaceFixture.git,
"python_project": WorkspaceFixture.python_project,
}
@dataclass
class TestArtifacts:
"""Container for test artifacts."""
transcript: List[Dict[str, str]] = field(default_factory=list)
tool_trace: List[Dict[str, Any]] = field(default_factory=list)
result: Dict[str, Any] = field(default_factory=dict)
workspace_snapshot: Optional[Dict[str, str]] = None
judge_result: Optional[Dict[str, Any]] = None
def save(self, artifacts_dir: Path) -> None:
"""Save artifacts to directory."""
artifacts_dir.mkdir(parents=True, exist_ok=True)
# Save transcript
transcript_path = artifacts_dir / "transcript.txt"
with open(transcript_path, "w") as f:
for entry in self.transcript:
role = entry.get("role", "unknown")
content = entry.get("content", "")
f.write(f"[{role.upper()}]\n{content}\n\n")
# Save tool trace as JSONL
trace_path = artifacts_dir / "tool_trace.jsonl"
with open(trace_path, "w") as f:
for trace in self.tool_trace:
f.write(json.dumps(trace) + "\n")
# Save result
result_path = artifacts_dir / "result.json"
with open(result_path, "w") as f:
json.dump(self.result, f, indent=2, default=str)
# Save workspace snapshot if present
if self.workspace_snapshot:
snapshot_dir = artifacts_dir / "workspace"
snapshot_dir.mkdir(exist_ok=True)
for rel_path, content in self.workspace_snapshot.items():
file_path = snapshot_dir / rel_path
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
# Save judge result if present
if self.judge_result:
judge_path = artifacts_dir / "judge_result.json"
with open(judge_path, "w") as f:
json.dump(self.judge_result, f, indent=2, default=str)
logger.debug(f"Saved artifacts to {artifacts_dir}")
class InteractiveTestHarness:
"""
Test harness for interactive mode testing.
Provides:
- Workspace isolation (temp directory)
- Fixture setup
- Tool trace capture
- Artifact generation
- File assertions
- Tool call assertions
"""
def __init__(
self,
workspace: Optional[Path] = None,
artifacts_dir: Optional[Path] = None,
keep_workspace: bool = False,
safe_commands: bool = True,
):
"""
Initialize test harness.
Args:
workspace: Workspace directory (created if None)
artifacts_dir: Directory for artifacts (created if None)
keep_workspace: Keep workspace after cleanup
safe_commands: Enforce safe command execution
"""
self._workspace_created = workspace is None
self.workspace = workspace or Path(tempfile.mkdtemp(prefix="praison_test_"))
self.artifacts_dir = artifacts_dir or self.workspace / "artifacts"
self.keep_workspace = keep_workspace
self.safe_commands = safe_commands
self.artifacts = TestArtifacts()
self._executor = None
def setup_workspace(self, fixture: str = "empty") -> None:
"""
Set up workspace with fixture.
Args:
fixture: Fixture name (empty, seeded, git, python_project)
"""
# Get fixture
if fixture in BUILTIN_FIXTURES:
fixture_obj = BUILTIN_FIXTURES[fixture]()
else:
logger.warning(f"Unknown fixture '{fixture}', using empty")
fixture_obj = WorkspaceFixture.empty()
# Create directories
self.workspace.mkdir(parents=True, exist_ok=True)
for dir_path in fixture_obj.directories:
(self.workspace / dir_path).mkdir(parents=True, exist_ok=True)
# Create files
for rel_path, content in fixture_obj.files.items():
file_path = self.workspace / rel_path
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
# Initialize git if needed
if fixture == "git":
import subprocess
try:
subprocess.run(
["git", "init"],
cwd=self.workspace,
capture_output=True,
timeout=10,
)
except Exception as e:
logger.debug(f"Git init failed: {e}")
logger.debug(f"Set up workspace with fixture '{fixture}' at {self.workspace}")
def create_executor(
self,
model: str = "gpt-4o-mini",
approval_mode: str = "auto",
agents: Optional[List[Dict[str, Any]]] = None,
workflow: Optional[Dict[str, Any]] = None,
):
"""
Create headless executor using interactive core.
Args:
model: LLM model
approval_mode: Approval mode (auto, manual, scoped)
agents: Multi-agent configs
workflow: Workflow routing config
Returns:
HeadlessInteractiveCore executor
"""
from .interactive_core_headless import (
HeadlessConfig,
HeadlessInteractiveCore,
AgentConfig,
)
agent_configs = []
if agents:
for a in agents:
agent_configs.append(AgentConfig(
name=a.get("name", "Agent"),
instructions=a.get("instructions", "You are a helpful assistant."),
role=a.get("role", "assistant"),
llm=a.get("llm", model),
))
config = HeadlessConfig(
workspace=str(self.workspace),
model=model,
approval_mode=approval_mode,
agents=agent_configs if agent_configs else None,
workflow=workflow,
)
self._executor = HeadlessInteractiveCore(config)
return self._executor
def run(
self,
prompts: List[str],
model: str = "gpt-4o-mini",
approval_mode: str = "auto",
agents: Optional[List[Dict[str, Any]]] = None,
workflow: Optional[Dict[str, Any]] = None,
):
"""
Run prompts through headless interactive core.
Args:
prompts: List of prompts to execute
model: LLM model
approval_mode: Approval mode
agents: Multi-agent configs
workflow: Workflow routing
Returns:
HeadlessExecutionResult
"""
if not self._executor:
self.create_executor(model, approval_mode, agents, workflow)
result = self._executor.run(prompts)
# Store in artifacts
self.artifacts.transcript = result.transcript
self.artifacts.tool_trace = self._executor.get_tool_trace()
self.artifacts.result = result.to_dict()
return result
def verify_files(self, expected_files: Dict[str, str]) -> Dict[str, bool]:
"""
Verify expected files exist and match content patterns.
Args:
expected_files: Dict of {relative_path: content_regex}
Returns:
Dict of {path: passed}
"""
results = {}
for rel_path, content_pattern in expected_files.items():
file_path = self.workspace / rel_path
if not file_path.exists():
logger.debug(f"File not found: {rel_path}")
results[rel_path] = False
continue
content = file_path.read_text()
if content_pattern:
try:
if re.search(content_pattern, content, re.MULTILINE | re.DOTALL):
results[rel_path] = True
else:
logger.debug(f"Content mismatch for {rel_path}: pattern '{content_pattern}' not found")
results[rel_path] = False
except re.error as e:
logger.warning(f"Invalid regex pattern for {rel_path}: {e}")
results[rel_path] = False
else:
# Just check existence
results[rel_path] = True
return results
def verify_tool_calls(
self,
expected_tools: Optional[List[str]] = None,
forbidden_tools: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
Verify tool calls match expectations.
Args:
expected_tools: Tools that MUST have been called
forbidden_tools: Tools that MUST NOT have been called
Returns:
Dict with verification results
"""
called_tools = set()
if self._executor:
called_tools = set(self._executor.get_tools_called())
results = {
"called_tools": list(called_tools),
"expected_passed": True,
"forbidden_passed": True,
"missing_tools": [],
"forbidden_called": [],
}
# Check expected tools
if expected_tools:
expected_set = set(expected_tools)
missing = expected_set - called_tools
if missing:
results["expected_passed"] = False
results["missing_tools"] = list(missing)
# Check forbidden tools
if forbidden_tools:
forbidden_set = set(forbidden_tools)
forbidden_called = forbidden_set & called_tools
if forbidden_called:
results["forbidden_passed"] = False
results["forbidden_called"] = list(forbidden_called)
results["passed"] = results["expected_passed"] and results["forbidden_passed"]
return results
def verify_response(self, response: str, pattern: str) -> bool:
"""
Verify response matches pattern.
Args:
response: Response text
pattern: Regex pattern
Returns:
True if matches
"""
if not pattern:
return True
try:
return bool(re.search(pattern, response, re.MULTILINE | re.DOTALL | re.IGNORECASE))
except re.error as e:
logger.warning(f"Invalid response pattern: {e}")
return False
def snapshot_workspace(self) -> Dict[str, str]:
"""
Create snapshot of workspace files.
Returns:
Dict of {relative_path: content}
"""
snapshot = {}
for file_path in self.workspace.rglob("*"):
if file_path.is_file():
rel_path = str(file_path.relative_to(self.workspace))
# Skip binary files and large files
if file_path.suffix in {".pyc", ".so", ".dll", ".exe", ".bin"}:
continue
if file_path.stat().st_size > 100_000: # 100KB limit
continue
try:
snapshot[rel_path] = file_path.read_text()
except Exception:
pass # Skip unreadable files
self.artifacts.workspace_snapshot = snapshot
return snapshot
def save_artifacts(self, test_id: str = "test") -> Path:
"""
Save all artifacts to directory.
Args:
test_id: Test identifier for subdirectory
Returns:
Path to artifacts directory
"""
test_artifacts_dir = self.artifacts_dir / test_id
self.artifacts.save(test_artifacts_dir)
return test_artifacts_dir
def cleanup(self) -> None:
"""Clean up workspace and executor resources."""
# Clean up executor (stops LSP/ACP subsystems)
if self._executor is not None:
try:
self._executor.cleanup()
logger.debug("Cleaned up executor resources")
except Exception as e:
logger.warning(f"Failed to clean up executor: {e}")
finally:
self._executor = None
# Clean up workspace
if self._workspace_created and not self.keep_workspace:
try:
shutil.rmtree(self.workspace)
logger.debug(f"Cleaned up workspace: {self.workspace}")
except Exception as e:
logger.warning(f"Failed to clean up workspace: {e}")
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.cleanup()
return False
def create_test_harness(
fixture: str = "empty",
workspace: Optional[Path] = None,
artifacts_dir: Optional[Path] = None,
keep_workspace: bool = False,
) -> InteractiveTestHarness:
"""
Create and set up a test harness.
Args:
fixture: Workspace fixture name
workspace: Workspace path (temp if None)
artifacts_dir: Artifacts directory
keep_workspace: Keep workspace after cleanup
Returns:
Configured InteractiveTestHarness
"""
harness = InteractiveTestHarness(
workspace=workspace,
artifacts_dir=artifacts_dir,
keep_workspace=keep_workspace,
)
harness.setup_workspace(fixture)
return harness
@@ -72,7 +72,7 @@ class ToolConfig:
enable_acp: bool = True
enable_lsp: bool = True
enable_basic: bool = True
approval_mode: str = "auto" # auto, manual, scoped
approval_mode: str = "auto" # auto (full privileges), manual, scoped
lsp_enabled: bool = True
acp_enabled: bool = True
File diff suppressed because it is too large. Load diff
@@ -50,8 +50,9 @@ class InteractiveConfig:
autonomy: bool = True # Enable autonomy by default for complex tasks
autonomy_config: Optional[dict] = None # Custom autonomy config
# Approval mode
approval_mode: Union[str, ApprovalMode] = "prompt"
# Approval mode - default to "auto" for full privileges in interactive mode
# Use "prompt" for safer mode that asks before each action
approval_mode: Union[str, ApprovalMode] = "auto"
# File attachments
files: List[str] = field(default_factory=list)
@@ -24,6 +24,9 @@ class InteractiveEventType(Enum):
TOOL_START = "tool.start"
TOOL_END = "tool.end"
# Stage transitions (PLAN → EXPLORE → BUILD → REVIEW)
STAGE_CHANGE = "stage.change"
# Approval/permission flow
APPROVAL_ASKED = "approval.asked"
APPROVAL_ANSWERED = "approval.answered"
@@ -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,456 @@
"""
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
# ============================================================================
# Branding - Import from unified source
# ============================================================================
from praisonai.cli.branding import get_logo, get_version
# ============================================================================
# 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,457 @@
"""
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
# ============================================================================
# Branding - Import from unified source
# ============================================================================
from praisonai.cli.branding import get_logo, get_version
# ============================================================================
# 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.8.0 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.8.0"
+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.6",
"python-dotenv>=0.19.0",
"instructor>=1.3.3",
"PyYAML>=6.0",
@@ -0,0 +1,11 @@
"""Live interactive mode smoke tests.
These tests verify PraisonAI's interactive mode using real API calls.
Tests use HeadlessInteractiveCore to execute prompts through the same
pipeline as the interactive TUI.
Environment variables:
- PRAISONAI_LIVE_INTERACTIVE=1 - Enable live interactive tests
- OPENAI_API_KEY - Required for live tests
- PRAISONAI_LIVE_MODEL - Override model (default: gpt-4o-mini)
"""
@@ -0,0 +1,40 @@
"""
Advanced Agent-Centric Test Scenarios.
These scenarios test the agent's ability to AUTONOMOUSLY complete complex tasks
using ONLY a single text prompt. The agent must figure out the approach itself.
Key Principles:
- ONE prompt per scenario (no step-by-step instructions)
- Prompt describes WHAT to achieve, not HOW
- Agent uses tools autonomously to complete the task
- Verification checks the END RESULT, not the process
"""
import os
def is_advanced_tests_enabled() -> bool:
"""Check if advanced tests are enabled."""
return os.environ.get("PRAISONAI_LIVE_INTERACTIVE", "0") == "1"
def has_api_key() -> bool:
"""Check if API key is available."""
return bool(os.environ.get("OPENAI_API_KEY"))
def can_run_advanced_tests() -> tuple:
"""Check if advanced tests can run."""
if not is_advanced_tests_enabled():
return False, "PRAISONAI_LIVE_INTERACTIVE=1 not set"
if not has_api_key():
return False, "OPENAI_API_KEY not set"
return True, "OK"
__all__ = [
"is_advanced_tests_enabled",
"has_api_key",
"can_run_advanced_tests",
]
@@ -0,0 +1,245 @@
"""
Advanced Agent-Centric Test Scenarios.
5 complex scenarios where the agent must AUTONOMOUSLY figure out how to complete
the task using ONLY a single text prompt. No step-by-step instructions.
Scenarios:
- ADV_01: Bug Fix & Testing - Find bug, write tests, fix it
- ADV_02: Data Pipeline - Create JSON to CSV transformer
- ADV_03: API Client - Build REST client with error handling
- ADV_04: Documentation - Analyze code and generate docs
- ADV_05: Package Creation - Create complete Python package
"""
from typing import List
from ..runner import InteractiveScenario
# =============================================================================
# ADV_01: BUG FIX & TESTING
# =============================================================================
SCENARIO_ADV_01 = InteractiveScenario(
id="adv_01",
name="Bug Fix & Testing",
description="Agent must find a bug, write a test that exposes it, fix it, and verify",
prompts=[
"""I have a Python file called calculator.py with the following buggy code:
```python
def add(a, b):
return a + b
def subtract(a, b):
return a + b # BUG: should be a - b
def multiply(a, b):
return a * b
def divide(a, b):
return a / b
```
Your task:
1. Create this calculator.py file
2. Create a test file test_calculator.py with tests for ALL functions
3. Run the tests to find which one fails
4. Fix the bug in calculator.py
5. Run tests again to verify the fix works
Show me the final working code and test results."""
],
expected_tools=["acp_create_file"],
expected_files={
"calculator.py": r"def subtract.*return a - b",
"test_calculator.py": r"def test_|import.*calculator",
},
timeout=180,
)
# =============================================================================
# ADV_02: DATA PIPELINE
# =============================================================================
SCENARIO_ADV_02 = InteractiveScenario(
id="adv_02",
name="Data Pipeline",
description="Agent must create a data transformation pipeline",
prompts=[
"""Create a complete data processing pipeline that:
1. Creates a sample data file called 'users.json' with this content:
[
{"name": "Alice", "age": 30, "city": "NYC"},
{"name": "Bob", "age": 25, "city": "LA"},
{"name": "Charlie", "age": 35, "city": "Chicago"}
]
2. Creates a Python script called 'transform.py' that:
- Reads the JSON file
- Filters users older than 28
- Converts to CSV format
- Saves to 'output.csv'
3. Run the script to generate output.csv
4. Show me the contents of output.csv
The agent should figure out the best approach to accomplish this."""
],
expected_tools=["acp_create_file"],
expected_files={
"users.json": r"Alice|Bob|Charlie",
"transform.py": r"import json|csv|open",
},
expected_response=r"Alice|Charlie|csv|output",
timeout=180,
)
# =============================================================================
# ADV_03: API CLIENT
# =============================================================================
SCENARIO_ADV_03 = InteractiveScenario(
id="adv_03",
name="API Client",
description="Agent must build a REST API client with proper error handling",
prompts=[
"""Create a Python module for making HTTP requests with proper error handling.
Requirements:
1. Create a file called 'api_client.py' with a class called 'APIClient' that has:
- __init__(self, base_url) - stores the base URL
- get(self, endpoint) - makes GET request, returns JSON response
- post(self, endpoint, data) - makes POST request with JSON body
- Proper error handling for network errors and HTTP errors
- A timeout parameter (default 30 seconds)
2. Create a file called 'test_api_client.py' with unit tests that:
- Test the class can be instantiated
- Test error handling works (mock or check exception types)
3. Show me the complete implementation
The code should be production-quality with docstrings and type hints."""
],
expected_tools=["acp_create_file"],
expected_files={
"api_client.py": r"class APIClient|def get|def post|except|timeout",
"test_api_client.py": r"def test_|APIClient",
},
timeout=180,
)
# =============================================================================
# ADV_04: DOCUMENTATION GENERATOR
# =============================================================================
SCENARIO_ADV_04 = InteractiveScenario(
id="adv_04",
name="Documentation Generator",
description="Agent must analyze code and generate comprehensive documentation",
prompts=[
"""I need you to create a Python module and then generate documentation for it.
Step 1: Create a file called 'utils.py' with these utility functions:
- format_date(date_obj) - formats a date to 'YYYY-MM-DD'
- parse_csv_line(line) - splits a CSV line handling quoted fields
- slugify(text) - converts text to URL-friendly slug
- truncate(text, max_length) - truncates text with ellipsis
Each function should have proper implementation (not just pass).
Step 2: Create a file called 'README.md' that documents:
- Module overview
- Each function with description, parameters, return value, and example usage
- Installation/usage instructions
Make the documentation clear and professional."""
],
expected_tools=["acp_create_file"],
expected_files={
"utils.py": r"def format_date|def parse_csv|def slugify|def truncate",
"README.md": r"format_date|parse_csv|slugify|truncate|##|Usage",
},
timeout=180,
)
# =============================================================================
# ADV_05: COMPLETE PACKAGE
# =============================================================================
SCENARIO_ADV_05 = InteractiveScenario(
id="adv_05",
name="Complete Package",
description="Agent must create a complete Python package structure",
prompts=[
"""Create a complete Python package called 'mathtools' with proper structure.
The package should include:
1. Package structure:
- mathtools/__init__.py (exports main functions)
- mathtools/operations.py (basic math: add, subtract, multiply, divide)
- mathtools/statistics.py (mean, median, mode functions)
- tests/test_operations.py (tests for operations)
- tests/test_statistics.py (tests for statistics)
- pyproject.toml (modern Python packaging)
- README.md (package documentation)
2. All functions should have:
- Type hints
- Docstrings
- Proper implementation (not stubs)
3. Tests should be runnable with pytest
Create all files and show me the package structure."""
],
expected_tools=["acp_create_file"],
expected_files={
"mathtools/__init__.py": r"from.*import|__all__",
"mathtools/operations.py": r"def add|def subtract|def multiply|def divide",
"mathtools/statistics.py": r"def mean|def median",
"pyproject.toml": r"name.*=.*mathtools|project",
},
timeout=240,
)
# =============================================================================
# ALL SCENARIOS
# =============================================================================
ALL_ADVANCED_SCENARIOS: List[InteractiveScenario] = [
SCENARIO_ADV_01,
SCENARIO_ADV_02,
SCENARIO_ADV_03,
SCENARIO_ADV_04,
SCENARIO_ADV_05,
]
def get_scenario_by_id(scenario_id: str) -> InteractiveScenario:
"""Get a scenario by its ID."""
for s in ALL_ADVANCED_SCENARIOS:
if s.id == scenario_id:
return s
raise ValueError(f"Scenario {scenario_id} not found")
__all__ = [
"SCENARIO_ADV_01",
"SCENARIO_ADV_02",
"SCENARIO_ADV_03",
"SCENARIO_ADV_04",
"SCENARIO_ADV_05",
"ALL_ADVANCED_SCENARIOS",
"get_scenario_by_id",
]
@@ -0,0 +1,64 @@
"""
Advanced Agent-Centric Test Suite.
Tests the agent's ability to autonomously complete complex tasks
using ONLY a single text prompt per scenario.
Run with: PRAISONAI_LIVE_INTERACTIVE=1 pytest tests/live/interactive/advanced/test_advanced.py -v
"""
import pytest
from ..runner import LiveInteractiveRunner
from . import can_run_advanced_tests
from .scenarios import ALL_ADVANCED_SCENARIOS
pytestmark = pytest.mark.skipif(
not can_run_advanced_tests()[0],
reason=f"Advanced tests disabled: {can_run_advanced_tests()[1]}"
)
@pytest.fixture(scope="module")
def runner():
"""Create a shared runner for all tests."""
return LiveInteractiveRunner(verbose=True)
class TestAdvancedScenarios:
"""Advanced agent-centric test scenarios."""
@pytest.mark.parametrize("scenario", ALL_ADVANCED_SCENARIOS, ids=lambda s: s.id)
def test_scenario(self, scenario, runner):
"""Run a single advanced scenario with retry."""
result = runner._run_with_retry(scenario)
if not result.passed:
print(f"\n{'='*60}")
print(f"SCENARIO FAILED: {scenario.id} - {scenario.name}")
print(f"{'='*60}")
print(f"Description: {scenario.description}")
print(f"Prompt: {scenario.prompts[0][:200]}...")
print(f"Attempts: {result.attempts}")
print(f"Assertions: {result.assertions}")
print(f"Tool calls: {result.tool_calls}")
print(f"Error: {result.error}")
print(f"\nResponse (last 500 chars):\n{result.response[-500:] if result.response else 'None'}")
print(f"{'='*60}\n")
assert result.passed, f"Scenario {scenario.id} failed after {result.attempts} attempts: {result.error}"
def run_all_and_report():
"""Run all scenarios and print report (for direct execution)."""
runner = LiveInteractiveRunner(verbose=True)
summary = runner.run_all(ALL_ADVANCED_SCENARIOS)
summary.print_summary()
return summary.pass_rate == 1.0
if __name__ == "__main__":
import sys
success = run_all_and_report()
sys.exit(0 if success else 1)
@@ -0,0 +1,387 @@
"""Live interactive mode test runner.
This runner executes tests using HeadlessInteractiveCore - the same pipeline
as the interactive TUI. Tests run with real API calls and retry on failure.
Environment variables:
- PRAISONAI_LIVE_INTERACTIVE=1 - Enable live interactive tests
- OPENAI_API_KEY - Required for live tests
- PRAISONAI_LIVE_MODEL - Override model (default: gpt-4o-mini)
- PRAISON_APPROVAL_MODE - Auto-set to 'auto' for non-interactive
"""
import json
import os
import shutil
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
DEFAULT_MODEL = os.environ.get("PRAISONAI_LIVE_MODEL", "gpt-4o-mini")
MAX_RETRIES = 3
DEFAULT_TIMEOUT = 60
def is_live_interactive_enabled() -> bool:
"""Check if live interactive tests are enabled."""
return os.environ.get("PRAISONAI_LIVE_INTERACTIVE", "0") == "1"
def has_api_key() -> bool:
"""Check if OpenAI API key is available."""
return bool(os.environ.get("OPENAI_API_KEY"))
@dataclass
class ScenarioResult:
"""Result of a scenario execution."""
scenario_id: str
name: str
passed: bool
attempts: int
duration: float
response: str = ""
tool_calls: List[str] = field(default_factory=list)
error: Optional[str] = None
assertions: Dict[str, bool] = field(default_factory=dict)
artifacts_path: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"scenario_id": self.scenario_id,
"name": self.name,
"passed": self.passed,
"attempts": self.attempts,
"duration": self.duration,
"response": self.response[:500] if self.response else "",
"tool_calls": self.tool_calls,
"error": self.error,
"assertions": self.assertions,
"artifacts_path": self.artifacts_path,
}
@dataclass
class InteractiveScenario:
"""A test scenario for interactive mode verification."""
id: str
name: str
description: str
prompts: List[str]
expected_tools: List[str] = field(default_factory=list)
forbidden_tools: List[str] = field(default_factory=list)
expected_files: Dict[str, str] = field(default_factory=dict)
expected_response: str = ""
workspace_fixture: str = "empty"
max_retries: int = MAX_RETRIES
timeout: int = DEFAULT_TIMEOUT
model: str = ""
@dataclass
class RunSummary:
"""Summary of all scenario runs."""
total: int = 0
passed: int = 0
failed: int = 0
duration: float = 0.0
results: List[ScenarioResult] = field(default_factory=list)
@property
def pass_rate(self) -> float:
return self.passed / self.total if self.total > 0 else 0.0
def to_dict(self) -> Dict[str, Any]:
return {
"total": self.total,
"passed": self.passed,
"failed": self.failed,
"pass_rate": self.pass_rate,
"duration": self.duration,
"results": [r.to_dict() for r in self.results],
}
def print_summary(self) -> None:
"""Print summary to console."""
try:
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Live Interactive Test Results")
table.add_column("ID", style="cyan")
table.add_column("Name", style="white")
table.add_column("Status")
table.add_column("Attempts", justify="right")
table.add_column("Duration", justify="right")
for r in self.results:
status = "[green]✓ PASS[/green]" if r.passed else "[red]✗ FAIL[/red]"
table.add_row(
r.scenario_id,
r.name[:30],
status,
str(r.attempts),
f"{r.duration:.2f}s",
)
console.print(table)
console.print()
console.print(f"[bold]Total:[/bold] {self.total} | "
f"[green]Passed:[/green] {self.passed} | "
f"[red]Failed:[/red] {self.failed} | "
f"[bold]Pass Rate:[/bold] {self.pass_rate:.1%}")
console.print(f"[bold]Total Duration:[/bold] {self.duration:.2f}s")
except ImportError:
print("\n=== Live Interactive Test Results ===")
for r in self.results:
status = "✓" if r.passed else "✗"
print(f" {status} {r.scenario_id}: {r.name} ({r.attempts} attempts, {r.duration:.2f}s)")
print(f"\nTotal: {self.total} | Passed: {self.passed} | Failed: {self.failed}")
print(f"Pass Rate: {self.pass_rate:.1%} | Duration: {self.duration:.2f}s")
class LiveInteractiveRunner:
"""
Live test runner for interactive mode using HeadlessInteractiveCore.
Runs scenarios with retry logic until they pass or max retries reached.
"""
def __init__(
self,
model: str = "",
artifacts_dir: Optional[Path] = None,
keep_artifacts: bool = True,
verbose: bool = True,
):
self.model = model or DEFAULT_MODEL
self.artifacts_dir = artifacts_dir or Path(tempfile.mkdtemp(prefix="praison_live_"))
self.keep_artifacts = keep_artifacts
self.verbose = verbose
self.results: List[ScenarioResult] = []
# Ensure approval mode is auto for non-interactive
os.environ["PRAISON_APPROVAL_MODE"] = "auto"
def run_all(self, scenarios: List[InteractiveScenario]) -> RunSummary:
"""
Run all scenarios, retrying failures until satisfied.
Args:
scenarios: List of scenarios to run
Returns:
RunSummary with all results
"""
start_time = time.time()
self.results = []
if self.verbose:
print(f"\n{'='*60}")
print(f"Running {len(scenarios)} live interactive tests")
print(f"Model: {self.model}")
print(f"Artifacts: {self.artifacts_dir}")
print(f"{'='*60}\n")
for i, scenario in enumerate(scenarios):
if self.verbose:
print(f"\n[{i+1}/{len(scenarios)}] {scenario.id}: {scenario.name}")
print(f" Description: {scenario.description}")
result = self._run_with_retry(scenario)
self.results.append(result)
if self.verbose:
status = "✓ PASS" if result.passed else "✗ FAIL"
print(f" Result: {status} (attempts: {result.attempts}, {result.duration:.2f}s)")
if result.error:
print(f" Error: {result.error}")
summary = RunSummary(
total=len(self.results),
passed=sum(1 for r in self.results if r.passed),
failed=sum(1 for r in self.results if not r.passed),
duration=time.time() - start_time,
results=self.results,
)
# Save summary
summary_path = self.artifacts_dir / "summary.json"
with open(summary_path, "w") as f:
json.dump(summary.to_dict(), f, indent=2)
return summary
def _run_with_retry(self, scenario: InteractiveScenario) -> ScenarioResult:
"""Run a scenario with retry logic."""
start_time = time.time()
last_error = None
last_response = ""
last_tool_calls = []
last_assertions = {}
for attempt in range(1, scenario.max_retries + 1):
if self.verbose and attempt > 1:
print(f" Retry {attempt}/{scenario.max_retries}...")
try:
result = self._run_single(scenario, attempt)
if result.passed:
return result
last_error = result.error
last_response = result.response
last_tool_calls = result.tool_calls
last_assertions = result.assertions
except Exception as e:
last_error = str(e)
if self.verbose:
print(f" Attempt {attempt} error: {e}")
# All retries exhausted
return ScenarioResult(
scenario_id=scenario.id,
name=scenario.name,
passed=False,
attempts=scenario.max_retries,
duration=time.time() - start_time,
response=last_response,
tool_calls=last_tool_calls,
error=last_error or "Max retries exhausted",
assertions=last_assertions,
artifacts_path=str(self.artifacts_dir / scenario.id),
)
def _run_single(self, scenario: InteractiveScenario, attempt: int) -> ScenarioResult:
"""Run a single attempt of a scenario."""
start_time = time.time()
# Create workspace
workspace = Path(tempfile.mkdtemp(prefix=f"praison_{scenario.id}_"))
artifacts_path = self.artifacts_dir / scenario.id / f"attempt_{attempt}"
try:
# Import harness (lazy)
from praisonai.cli.features.interactive_test_harness import (
InteractiveTestHarness,
)
# Create harness
harness = InteractiveTestHarness(
workspace=workspace,
artifacts_dir=artifacts_path,
keep_workspace=self.keep_artifacts,
)
# Setup workspace
harness.setup_workspace(scenario.workspace_fixture)
# Run prompts
exec_result = harness.run(
prompts=scenario.prompts,
model=scenario.model or self.model,
approval_mode="auto",
)
duration = time.time() - start_time
# Get response
response = "\n".join(exec_result.responses) if exec_result.responses else ""
# Verify assertions
assertions = {}
# Tool call assertions
tool_result = harness.verify_tool_calls(
expected_tools=scenario.expected_tools,
forbidden_tools=scenario.forbidden_tools,
)
assertions["tools"] = tool_result["passed"]
# File assertions
if scenario.expected_files:
file_results = harness.verify_files(scenario.expected_files)
assertions["files"] = all(file_results.values())
else:
assertions["files"] = True
# Response assertion
if scenario.expected_response:
assertions["response"] = harness.verify_response(response, scenario.expected_response)
else:
assertions["response"] = True
# Execution success
assertions["execution"] = exec_result.success
# Determine overall pass
all_passed = all(assertions.values())
# Snapshot workspace if keeping artifacts
if self.keep_artifacts:
harness.snapshot_workspace()
# Save artifacts
harness.save_artifacts(f"attempt_{attempt}")
return ScenarioResult(
scenario_id=scenario.id,
name=scenario.name,
passed=all_passed,
attempts=attempt,
duration=duration,
response=response,
tool_calls=harness._executor.get_tools_called() if harness._executor else [],
error=exec_result.error if not exec_result.success else None,
assertions=assertions,
artifacts_path=str(artifacts_path),
)
except Exception as e:
return ScenarioResult(
scenario_id=scenario.id,
name=scenario.name,
passed=False,
attempts=attempt,
duration=time.time() - start_time,
error=str(e),
)
finally:
if not self.keep_artifacts:
try:
shutil.rmtree(workspace)
except Exception:
pass
def run_live_interactive_tests(
scenarios: List[InteractiveScenario],
model: str = "",
artifacts_dir: Optional[Path] = None,
verbose: bool = True,
) -> RunSummary:
"""
Convenience function to run live interactive tests.
Args:
scenarios: List of scenarios to run
model: LLM model (default: gpt-4o-mini)
artifacts_dir: Directory for artifacts
verbose: Print progress
Returns:
RunSummary with all results
"""
runner = LiveInteractiveRunner(
model=model,
artifacts_dir=artifacts_dir,
verbose=verbose,
)
return runner.run_all(scenarios)
@@ -0,0 +1,328 @@
"""Interactive mode test scenarios.
20 scenarios covering all interactive mode capabilities:
- Basic chat (3)
- File operations (5)
- Code intelligence (4)
- Multi-step workflows (4)
- Multi-agent (2)
- Edge cases (2)
"""
from typing import List
from .runner import InteractiveScenario
# =============================================================================
# BASIC CHAT SCENARIOS (3)
# =============================================================================
SCENARIO_01_HELLO = InteractiveScenario(
id="int_01",
name="Basic Hello",
description="Test basic chat response without tools",
prompts=["Hello! What is 2 + 2?"],
expected_tools=[],
expected_response=r"4",
timeout=30,
)
SCENARIO_02_REASONING = InteractiveScenario(
id="int_02",
name="Simple Reasoning",
description="Test reasoning capability",
prompts=["If I have 3 apples and give away 1, how many do I have left?"],
expected_tools=[],
expected_response=r"2",
timeout=30,
)
SCENARIO_03_EXPLAIN = InteractiveScenario(
id="int_03",
name="Code Explanation",
description="Test code explanation without file access",
prompts=["Explain what this Python code does: print('Hello, World!')"],
expected_tools=[],
expected_response=r"print|Hello|output",
timeout=30,
)
# =============================================================================
# FILE OPERATIONS SCENARIOS (5)
# =============================================================================
SCENARIO_04_CREATE_FILE = InteractiveScenario(
id="int_04",
name="Create File",
description="Test creating a new file with content",
prompts=["Create a file called hello.py with the content: print('hello world')"],
expected_tools=["acp_create_file"],
expected_files={"hello.py": r"print.*hello"},
timeout=60,
)
SCENARIO_05_READ_FILE = InteractiveScenario(
id="int_05",
name="Read File",
description="Test reading an existing file",
prompts=["Read the file README.md and tell me what it says"],
expected_tools=["read_file"],
expected_response=r"Test Project|README",
workspace_fixture="seeded",
timeout=60,
)
SCENARIO_06_LIST_FILES = InteractiveScenario(
id="int_06",
name="List Files",
description="Test listing files in directory",
prompts=["List all files in the current directory"],
expected_tools=["list_files"],
expected_response=r"README|main\.py|config",
workspace_fixture="seeded",
timeout=60,
)
SCENARIO_07_EDIT_FILE = InteractiveScenario(
id="int_07",
name="Edit File",
description="Test editing an existing file",
prompts=[
"Create a file called counter.py with: count = 1",
"Edit counter.py to change count = 1 to count = 10",
],
expected_tools=["acp_create_file", "acp_edit_file"],
expected_files={"counter.py": r"count\s*=\s*10"},
timeout=90,
)
SCENARIO_08_DELETE_FILE = InteractiveScenario(
id="int_08",
name="Delete File",
description="Test deleting a file",
prompts=[
"Create a file called temp.txt with content: temporary",
"Delete the file temp.txt",
],
expected_tools=["acp_create_file", "acp_delete_file"],
expected_response=r"deleted|removed",
timeout=60,
)
# =============================================================================
# CODE INTELLIGENCE SCENARIOS (4)
# =============================================================================
SCENARIO_09_LIST_SYMBOLS = InteractiveScenario(
id="int_09",
name="List Symbols",
description="Test listing symbols in a Python file",
prompts=[
"Create a file calc.py with:\ndef add(a, b):\n return a + b\n\ndef subtract(a, b):\n return a - b",
"List all functions in calc.py",
],
expected_tools=["acp_create_file", "lsp_list_symbols"],
expected_response=r"add|subtract",
timeout=90,
)
SCENARIO_10_FIND_DEFINITION = InteractiveScenario(
id="int_10",
name="Find Definition",
description="Test finding symbol definition",
prompts=["Find where the function 'main' is defined in main.py"],
expected_tools=["lsp_find_definition"],
workspace_fixture="python_project",
timeout=60,
)
SCENARIO_11_FIND_REFERENCES = InteractiveScenario(
id="int_11",
name="Find References",
description="Test finding symbol references",
prompts=["Find all references to the 'add' function in utils.py"],
expected_tools=["lsp_find_references"],
workspace_fixture="python_project",
timeout=60,
)
SCENARIO_12_EXECUTE_COMMAND = InteractiveScenario(
id="int_12",
name="Execute Command",
description="Test executing a shell command",
prompts=["Run the command: echo 'Hello from shell'"],
expected_tools=[], # May use acp_execute_command or execute_command
expected_response=r"Hello.*shell|echo",
timeout=30,
)
# =============================================================================
# MULTI-STEP WORKFLOW SCENARIOS (4)
# =============================================================================
SCENARIO_13_CREATE_AND_READ = InteractiveScenario(
id="int_13",
name="Create and Read",
description="Test creating a file then reading it back",
prompts=[
"Create a file called greeting.txt with: Hello, PraisonAI!",
"Read the file greeting.txt and confirm its contents",
],
expected_tools=["acp_create_file", "read_file"],
expected_files={"greeting.txt": r"Hello.*PraisonAI"},
expected_response=r"Hello.*PraisonAI",
timeout=90,
)
SCENARIO_14_REFACTOR_WORKFLOW = InteractiveScenario(
id="int_14",
name="Refactor Workflow",
description="Test a refactoring workflow: read, analyze, edit",
prompts=[
"Read main.py and tell me what it does",
"Add a docstring to the main function in main.py",
],
expected_tools=["read_file"], # Edit tool varies
expected_files={"main.py": r'""".*"""|docstring|Main'},
workspace_fixture="python_project",
timeout=120,
)
SCENARIO_15_COPY_FILE = InteractiveScenario(
id="int_15",
name="Copy File Content",
description="Test reading one file and creating another with same content",
prompts=[
"Read the file main.py and show me its content",
"Now create a new file called backup.py with a simple print statement",
],
expected_tools=["read_file"], # Create tool varies
expected_files={"backup.py": r"print"},
workspace_fixture="python_project",
timeout=90,
)
SCENARIO_16_ANALYZE_AND_FIX = InteractiveScenario(
id="int_16",
name="Analyze and Fix",
description="Test analyzing code and making improvements",
prompts=[
"Create a file buggy.py with:\ndef divide(a, b):\n return a / b",
"Fix buggy.py to handle division by zero by raising ValueError",
],
expected_tools=["acp_create_file", "acp_edit_file"],
expected_files={"buggy.py": r"ValueError|zero|if.*b.*==.*0"},
timeout=90,
)
# =============================================================================
# MULTI-AGENT SCENARIOS (2)
# =============================================================================
SCENARIO_17_SIMPLE_HANDOFF = InteractiveScenario(
id="int_17",
name="Simple Task Delegation",
description="Test handling a task that requires multiple steps",
prompts=[
"First, create a file called task.py with: # TODO: implement",
"Then, edit task.py to add a function called process() that returns 'done'",
],
expected_tools=["acp_create_file", "acp_edit_file"],
expected_files={"task.py": r"def process.*return.*done"},
timeout=120,
)
SCENARIO_18_RESEARCH_AND_CODE = InteractiveScenario(
id="int_18",
name="Research and Code",
description="Test combining information gathering with code generation",
prompts=[
"List the files in the current directory",
"Based on what you see, create a new file called summary.txt describing the project structure",
],
expected_tools=["list_files", "acp_create_file"],
expected_files={"summary.txt": r"file|directory|project"},
workspace_fixture="seeded",
timeout=90,
)
# =============================================================================
# EDGE CASE SCENARIOS (2)
# =============================================================================
SCENARIO_19_EMPTY_RESPONSE = InteractiveScenario(
id="int_19",
name="Handle Empty Input",
description="Test handling minimal input gracefully",
prompts=["Hi"],
expected_tools=[],
timeout=30,
)
SCENARIO_20_COMPLEX_PROMPT = InteractiveScenario(
id="int_20",
name="Complex Multi-Part Request",
description="Test handling a complex request with multiple parts",
prompts=[
"Create a Python module called mathops.py with two functions: add(a, b) that returns a+b, and multiply(a, b) that returns a*b. Include docstrings for each function.",
],
expected_tools=["acp_create_file"],
expected_files={"mathops.py": r"def add.*def multiply|def multiply.*def add"},
timeout=90,
)
# =============================================================================
# ALL SCENARIOS
# =============================================================================
ALL_SCENARIOS: List[InteractiveScenario] = [
# Basic Chat (3)
SCENARIO_01_HELLO,
SCENARIO_02_REASONING,
SCENARIO_03_EXPLAIN,
# File Operations (5)
SCENARIO_04_CREATE_FILE,
SCENARIO_05_READ_FILE,
SCENARIO_06_LIST_FILES,
SCENARIO_07_EDIT_FILE,
SCENARIO_08_DELETE_FILE,
# Code Intelligence (4)
SCENARIO_09_LIST_SYMBOLS,
SCENARIO_10_FIND_DEFINITION,
SCENARIO_11_FIND_REFERENCES,
SCENARIO_12_EXECUTE_COMMAND,
# Multi-step Workflows (4)
SCENARIO_13_CREATE_AND_READ,
SCENARIO_14_REFACTOR_WORKFLOW,
SCENARIO_15_COPY_FILE,
SCENARIO_16_ANALYZE_AND_FIX,
# Multi-agent (2)
SCENARIO_17_SIMPLE_HANDOFF,
SCENARIO_18_RESEARCH_AND_CODE,
# Edge Cases (2)
SCENARIO_19_EMPTY_RESPONSE,
SCENARIO_20_COMPLEX_PROMPT,
]
def get_scenarios_by_category(category: str) -> List[InteractiveScenario]:
"""Get scenarios by category."""
categories = {
"basic": [SCENARIO_01_HELLO, SCENARIO_02_REASONING, SCENARIO_03_EXPLAIN],
"files": [SCENARIO_04_CREATE_FILE, SCENARIO_05_READ_FILE, SCENARIO_06_LIST_FILES,
SCENARIO_07_EDIT_FILE, SCENARIO_08_DELETE_FILE],
"code": [SCENARIO_09_LIST_SYMBOLS, SCENARIO_10_FIND_DEFINITION,
SCENARIO_11_FIND_REFERENCES, SCENARIO_12_EXECUTE_COMMAND],
"workflow": [SCENARIO_13_CREATE_AND_READ, SCENARIO_14_REFACTOR_WORKFLOW,
SCENARIO_15_COPY_FILE, SCENARIO_16_ANALYZE_AND_FIX],
"multi": [SCENARIO_17_SIMPLE_HANDOFF, SCENARIO_18_RESEARCH_AND_CODE],
"edge": [SCENARIO_19_EMPTY_RESPONSE, SCENARIO_20_COMPLEX_PROMPT],
}
return categories.get(category, [])
@@ -0,0 +1,130 @@
"""Live smoke tests for interactive mode.
These tests verify PraisonAI's interactive mode using real API calls.
Tests use HeadlessInteractiveCore to execute prompts through the same
pipeline as the interactive TUI.
Run with: PRAISONAI_LIVE_INTERACTIVE=1 pytest tests/live/interactive/test_smoke.py -v
"""
import pytest
from .runner import (
is_live_interactive_enabled,
has_api_key,
LiveInteractiveRunner,
)
from .scenarios import ALL_SCENARIOS, get_scenarios_by_category
pytestmark = pytest.mark.skipif(
not is_live_interactive_enabled() or not has_api_key(),
reason="Live interactive tests disabled (set PRAISONAI_LIVE_INTERACTIVE=1 and OPENAI_API_KEY)"
)
@pytest.fixture(scope="module")
def runner():
"""Create a shared runner for all tests."""
return LiveInteractiveRunner(verbose=True)
class TestLiveInteractiveSmoke:
"""Live smoke tests for interactive mode scenarios."""
@pytest.mark.parametrize("scenario", ALL_SCENARIOS, ids=lambda s: s.id)
def test_scenario(self, scenario, runner):
"""Run a single interactive scenario with retry."""
result = runner._run_with_retry(scenario)
if not result.passed:
print(f"\n{'='*60}")
print(f"SCENARIO FAILED: {scenario.id} - {scenario.name}")
print(f"{'='*60}")
print(f"Description: {scenario.description}")
print(f"Prompts: {scenario.prompts}")
print(f"Attempts: {result.attempts}")
print(f"Assertions: {result.assertions}")
print(f"Tool calls: {result.tool_calls}")
print(f"Error: {result.error}")
print(f"\nResponse (last 500 chars):\n{result.response[-500:] if result.response else 'None'}")
print(f"{'='*60}\n")
assert result.passed, f"Scenario {scenario.id} failed after {result.attempts} attempts: {result.error}"
class TestBasicChat:
"""Basic chat scenarios."""
@pytest.mark.parametrize("scenario", get_scenarios_by_category("basic"), ids=lambda s: s.id)
def test_basic(self, scenario, runner):
"""Test basic chat scenarios."""
result = runner._run_with_retry(scenario)
assert result.passed, f"Failed: {result.error}"
class TestFileOperations:
"""File operation scenarios."""
@pytest.mark.parametrize("scenario", get_scenarios_by_category("files"), ids=lambda s: s.id)
def test_files(self, scenario, runner):
"""Test file operation scenarios."""
result = runner._run_with_retry(scenario)
assert result.passed, f"Failed: {result.error}"
class TestCodeIntelligence:
"""Code intelligence scenarios."""
@pytest.mark.parametrize("scenario", get_scenarios_by_category("code"), ids=lambda s: s.id)
def test_code(self, scenario, runner):
"""Test code intelligence scenarios."""
result = runner._run_with_retry(scenario)
assert result.passed, f"Failed: {result.error}"
class TestWorkflows:
"""Multi-step workflow scenarios."""
@pytest.mark.parametrize("scenario", get_scenarios_by_category("workflow"), ids=lambda s: s.id)
def test_workflow(self, scenario, runner):
"""Test workflow scenarios."""
result = runner._run_with_retry(scenario)
assert result.passed, f"Failed: {result.error}"
class TestMultiAgent:
"""Multi-agent scenarios."""
@pytest.mark.parametrize("scenario", get_scenarios_by_category("multi"), ids=lambda s: s.id)
def test_multi(self, scenario, runner):
"""Test multi-agent scenarios."""
result = runner._run_with_retry(scenario)
assert result.passed, f"Failed: {result.error}"
class TestEdgeCases:
"""Edge case scenarios."""
@pytest.mark.parametrize("scenario", get_scenarios_by_category("edge"), ids=lambda s: s.id)
def test_edge(self, scenario, runner):
"""Test edge case scenarios."""
result = runner._run_with_retry(scenario)
assert result.passed, f"Failed: {result.error}"
def run_all_and_report():
"""Run all scenarios and print report (for direct execution)."""
from .scenarios import ALL_SCENARIOS
runner = LiveInteractiveRunner(verbose=True)
summary = runner.run_all(ALL_SCENARIOS)
summary.print_summary()
return summary.pass_rate == 1.0
if __name__ == "__main__":
import sys
success = run_all_and_report()
sys.exit(0 if success else 1)
@@ -0,0 +1,273 @@
"""
Tests for Approval Mode Matrix.
Tests that approval modes (auto, manual, scoped) work correctly
with the interactive runtime and tool execution.
"""
import os
import tempfile
class TestRuntimeConfigApprovalMode:
"""Tests for RuntimeConfig approval_mode setting."""
def test_default_approval_mode(self):
"""Test default approval mode is auto."""
from praisonai.cli.features.interactive_runtime import RuntimeConfig
config = RuntimeConfig()
assert config.approval_mode == "auto"
def test_custom_approval_mode(self):
"""Test setting custom approval mode."""
from praisonai.cli.features.interactive_runtime import RuntimeConfig
config = RuntimeConfig(approval_mode="manual")
assert config.approval_mode == "manual"
config = RuntimeConfig(approval_mode="scoped")
assert config.approval_mode == "scoped"
class TestInteractiveRuntimeReadOnly:
"""Tests for InteractiveRuntime read_only property."""
def test_auto_mode_not_read_only(self):
"""Test that auto mode is not read-only."""
from praisonai.cli.features.interactive_runtime import (
InteractiveRuntime,
RuntimeConfig,
)
config = RuntimeConfig(
workspace="/tmp/test",
approval_mode="auto",
)
runtime = InteractiveRuntime(config)
# Auto mode should NOT be read-only
assert runtime.read_only is False
def test_auto_mode_writable_without_acp(self):
"""Test that auto mode allows writes even without ACP."""
from praisonai.cli.features.interactive_runtime import (
InteractiveRuntime,
RuntimeConfig,
)
config = RuntimeConfig(
workspace="/tmp/test",
approval_mode="auto",
acp_enabled=False, # ACP disabled
)
runtime = InteractiveRuntime(config)
runtime._read_only = True # Simulate ACP not ready
# Auto mode should still NOT be read-only
assert runtime.read_only is False
def test_manual_mode_read_only_without_acp(self):
"""Test that manual mode is read-only without ACP."""
from praisonai.cli.features.interactive_runtime import (
InteractiveRuntime,
RuntimeConfig,
)
config = RuntimeConfig(
workspace="/tmp/test",
approval_mode="manual",
)
runtime = InteractiveRuntime(config)
runtime._read_only = True # Simulate ACP not ready
# Manual mode should be read-only when ACP not ready
assert runtime.read_only is True
def test_scoped_mode_read_only_without_acp(self):
"""Test that scoped mode is read-only without ACP."""
from praisonai.cli.features.interactive_runtime import (
InteractiveRuntime,
RuntimeConfig,
)
config = RuntimeConfig(
workspace="/tmp/test",
approval_mode="scoped",
)
runtime = InteractiveRuntime(config)
runtime._read_only = True # Simulate ACP not ready
# Scoped mode should be read-only when ACP not ready
assert runtime.read_only is True
def test_env_var_auto_mode_override(self):
"""Test that PRAISON_APPROVAL_MODE env var can override to auto mode."""
from praisonai.cli.features.interactive_runtime import (
InteractiveRuntime,
RuntimeConfig,
)
# Save and clear env var first
original = os.environ.get("PRAISON_APPROVAL_MODE")
if "PRAISON_APPROVAL_MODE" in os.environ:
del os.environ["PRAISON_APPROVAL_MODE"]
try:
# Without env var, manual mode should be read-only when _read_only=True
config = RuntimeConfig(
workspace="/tmp/test",
approval_mode="manual",
)
runtime = InteractiveRuntime(config)
runtime._read_only = True
# Manual mode without env override should be read-only
assert runtime.read_only is True
# Now set env var to auto - this should override
os.environ["PRAISON_APPROVAL_MODE"] = "auto"
# Create new runtime with manual config but env=auto
config2 = RuntimeConfig(
workspace="/tmp/test",
approval_mode="manual",
)
runtime2 = InteractiveRuntime(config2)
runtime2._read_only = True
# The read_only property checks config first, then env
# Config is manual, so it checks env var next
# Env var is auto, so it returns False (not read-only)
assert runtime2.read_only is False
finally:
if original:
os.environ["PRAISON_APPROVAL_MODE"] = original
elif "PRAISON_APPROVAL_MODE" in os.environ:
del os.environ["PRAISON_APPROVAL_MODE"]
class TestToolConfigApprovalMode:
"""Tests for ToolConfig approval_mode setting."""
def test_default_approval_mode(self):
"""Test default approval mode in ToolConfig."""
from praisonai.cli.features.interactive_tools import ToolConfig
config = ToolConfig()
assert config.approval_mode == "auto"
def test_from_env_approval_mode(self):
"""Test ToolConfig.from_env reads approval mode."""
from praisonai.cli.features.interactive_tools import ToolConfig
original = os.environ.get("PRAISON_APPROVAL_MODE")
os.environ["PRAISON_APPROVAL_MODE"] = "manual"
try:
config = ToolConfig.from_env()
assert config.approval_mode == "manual"
finally:
if original:
os.environ["PRAISON_APPROVAL_MODE"] = original
elif "PRAISON_APPROVAL_MODE" in os.environ:
del os.environ["PRAISON_APPROVAL_MODE"]
class TestHeadlessConfigApprovalMode:
"""Tests for HeadlessConfig approval_mode setting."""
def test_default_approval_mode(self):
"""Test default approval mode in HeadlessConfig."""
from praisonai.cli.features.interactive_core_headless import HeadlessConfig
config = HeadlessConfig()
assert config.approval_mode == "auto"
def test_custom_approval_mode(self):
"""Test custom approval mode in HeadlessConfig."""
from praisonai.cli.features.interactive_core_headless import HeadlessConfig
config = HeadlessConfig(approval_mode="manual")
assert config.approval_mode == "manual"
class TestApprovalModeIntegration:
"""Integration tests for approval mode across components."""
def test_auto_mode_allows_file_creation_via_tools(self):
"""Test that auto mode allows file creation through tools."""
from praisonai.cli.features.interactive_tools import (
get_interactive_tools,
ToolConfig,
)
with tempfile.TemporaryDirectory() as tmpdir:
config = ToolConfig(
workspace=tmpdir,
approval_mode="auto",
enable_acp=True,
enable_lsp=False,
enable_basic=True,
)
# Get tools - this should work without error
tools = get_interactive_tools(config=config)
# Should have ACP tools
tool_names = [t.__name__ for t in tools]
assert "acp_create_file" in tool_names or len(tools) > 0
def test_tool_config_propagates_to_runtime(self):
"""Test that ToolConfig approval_mode propagates to runtime."""
from praisonai.cli.features.interactive_tools import ToolConfig
from praisonai.cli.features.interactive_runtime import RuntimeConfig
tool_config = ToolConfig(approval_mode="scoped")
# When creating RuntimeConfig from ToolConfig values
runtime_config = RuntimeConfig(
workspace=tool_config.workspace,
approval_mode=tool_config.approval_mode,
)
assert runtime_config.approval_mode == "scoped"
class TestApprovalModeMatrix:
"""Matrix tests for all approval mode combinations."""
def test_approval_mode_matrix(self):
"""Test all approval mode combinations."""
from praisonai.cli.features.interactive_runtime import (
InteractiveRuntime,
RuntimeConfig,
)
# Matrix: (approval_mode, acp_ready, expected_read_only)
test_cases = [
# Auto mode is never read-only
("auto", True, False),
("auto", False, False),
# Manual mode depends on _read_only flag
("manual", True, False), # ACP ready, not read-only
("manual", False, True), # ACP not ready, read-only
# Scoped mode depends on _read_only flag
("scoped", True, False),
("scoped", False, True),
]
for approval_mode, acp_ready, expected_read_only in test_cases:
config = RuntimeConfig(
workspace="/tmp/test",
approval_mode=approval_mode,
)
runtime = InteractiveRuntime(config)
runtime._read_only = not acp_ready
actual = runtime.read_only
assert actual == expected_read_only, (
f"Failed for approval_mode={approval_mode}, acp_ready={acp_ready}: "
f"expected read_only={expected_read_only}, got {actual}"
)
@@ -0,0 +1,848 @@
"""
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.branding import get_logo, LOGO_LARGE
logo = get_logo(100)
assert logo == LOGO_LARGE
def test_logo_medium_terminal(self):
"""Test logo for medium terminal."""
from praisonai.cli.branding import get_logo, LOGO_MEDIUM
logo = get_logo(50)
assert logo == LOGO_MEDIUM
def test_logo_narrow_terminal(self):
"""Test logo for narrow terminal."""
from praisonai.cli.branding import get_logo, LOGO_SMALL
logo = get_logo(30)
assert logo == LOGO_SMALL
def test_logo_contains_praison_ai(self):
"""Test that logo contains 'Praison AI' branding."""
from praisonai.cli.branding import LOGO_SMALL
assert "Praison AI" in LOGO_SMALL
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.branding import LOGO_LARGE
assert len(LOGO_LARGE) > 0
assert "██" in LOGO_LARGE
def test_logo_small_exists(self):
"""Test LOGO_SMALL constant exists."""
from praisonai.cli.branding import LOGO_SMALL
assert len(LOGO_SMALL) > 0
def test_logo_minimal_branding(self):
"""Test LOGO_MINIMAL has correct branding."""
from praisonai.cli.branding 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)
class TestLogoDisplay:
"""Tests for logo display functionality."""
def test_logo_always_shows_when_enabled(self):
"""Test logo shows even when messages exist."""
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig, ChatMessage
config = AsyncTUIConfig(show_logo=True)
tui = AsyncTUI(config=config)
# Add a message
tui.messages.append(ChatMessage(role="system", content="Test message"))
# Logo should still be in output
output = tui._format_output()
assert "Praison" in output or "██" in output # Logo contains these
def test_logo_hidden_when_disabled(self):
"""Test logo is hidden when show_logo=False."""
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig
config = AsyncTUIConfig(show_logo=False)
tui = AsyncTUI(config=config)
output = tui._format_output()
# Should not contain the large logo characters
assert "██████" not in output
def test_tips_hidden_after_first_message(self):
"""Test tips are hidden after first message but logo remains."""
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig, ChatMessage
config = AsyncTUIConfig(show_logo=True)
tui = AsyncTUI(config=config)
# Before messages - tips should show
output_before = tui._format_output()
assert "Type your message" in output_before
# Add a message
tui.messages.append(ChatMessage(role="user", content="Hello"))
# After messages - tips should be hidden but logo remains
output_after = tui._format_output()
assert "Type your message" not in output_after
# Logo should still be there
assert "Model:" in output_after
class TestToolCallVisibility:
"""Tests for tool call visibility."""
def test_tool_role_in_format_output(self):
"""Test tool role messages are formatted correctly."""
from praisonai.cli.interactive.async_tui import AsyncTUI, ChatMessage
tui = AsyncTUI()
tui.messages.append(ChatMessage(role="tool", content="read_file completed"))
output = tui._format_output()
assert "⚙" in output
assert "read_file completed" in output
class TestDebugMode:
"""Tests for debug mode (/debug command and --debug flag)."""
def test_debug_command_toggles_mode(self):
"""Test /debug command toggles debug mode."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert tui.config.debug is False
result = tui._handle_command("/debug")
assert result is True
assert tui.config.debug is True
assert "enabled" in tui.messages[0].content.lower()
assert "async_tui_debug.log" in tui.messages[0].content
def test_debug_flag_in_config(self):
"""Test debug flag can be set via config."""
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig
config = AsyncTUIConfig(debug=True)
tui = AsyncTUI(config=config)
assert tui.config.debug is True
class TestPlanningIntegration:
"""Tests for planning integration (/plan command)."""
def test_plan_command_no_args(self):
"""Test /plan command without arguments shows usage."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/plan")
assert result is True
assert len(tui.messages) == 1
assert "Usage:" in tui.messages[0].content
assert "/plan" in tui.messages[0].content
def test_plan_command_with_task(self):
"""Test /plan command with a task description."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
# Mock _queue_or_execute to prevent actual execution
executed_prompts = []
tui._queue_or_execute = lambda p: executed_prompts.append(p)
result = tui._handle_command("/plan refactor the auth module")
assert result is True
assert len(tui.messages) == 1
assert "Creating plan" in tui.messages[0].content
assert len(executed_prompts) == 1
assert "refactor the auth module" in executed_prompts[0]
class TestHandoffSubagents:
"""Tests for handoff/subagents (/handoff command)."""
def test_handoff_command_no_args(self):
"""Test /handoff command without arguments shows usage."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/handoff")
assert result is True
assert len(tui.messages) == 1
assert "Usage:" in tui.messages[0].content
assert "code" in tui.messages[0].content
assert "research" in tui.messages[0].content
def test_handoff_command_missing_task(self):
"""Test /handoff command with only agent type."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/handoff code")
assert result is True
assert "specify both" in tui.messages[0].content.lower()
def test_handoff_command_code_agent(self):
"""Test /handoff code command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
executed_prompts = []
tui._queue_or_execute = lambda p: executed_prompts.append(p)
result = tui._handle_command('/handoff code "fix the bug"')
assert result is True
assert "Handing off" in tui.messages[0].content
assert len(executed_prompts) == 1
assert "code" in executed_prompts[0].lower()
def test_handoff_command_research_agent(self):
"""Test /handoff research command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
executed_prompts = []
tui._queue_or_execute = lambda p: executed_prompts.append(p)
result = tui._handle_command('/handoff research "find best practices"')
assert result is True
assert len(executed_prompts) == 1
assert "research" in executed_prompts[0].lower()
def test_handoff_command_unknown_agent(self):
"""Test /handoff with unknown agent type."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command('/handoff unknown "some task"')
assert result is True
assert "Unknown agent type" in tui.messages[0].content
class TestInteractiveRuntimeReadOnly:
"""Tests for InteractiveRuntime read_only property."""
def test_read_only_false_when_config_approval_auto(self):
"""Test read_only is False when config.approval_mode is 'auto'."""
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
config = RuntimeConfig(approval_mode="auto")
runtime = InteractiveRuntime(config)
# Even without ACP ready, should not be read-only in auto mode
assert runtime.read_only is False
def test_read_only_false_when_env_approval_auto(self):
"""Test read_only is False when PRAISON_APPROVAL_MODE env is 'auto'."""
import os
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
# Set env var
old_val = os.environ.get("PRAISON_APPROVAL_MODE")
os.environ["PRAISON_APPROVAL_MODE"] = "auto"
try:
config = RuntimeConfig(approval_mode="manual") # Config says manual
runtime = InteractiveRuntime(config)
# Config takes precedence, so this should be read-only
# But env var should also work as fallback
# Actually config.approval_mode="manual" so env var is checked
# Wait, config says manual, so it won't return False on first check
# Then env var check should return False
assert runtime.read_only is False
finally:
if old_val is None:
os.environ.pop("PRAISON_APPROVAL_MODE", None)
else:
os.environ["PRAISON_APPROVAL_MODE"] = old_val
def test_read_only_true_when_manual_mode(self):
"""Test read_only respects _read_only flag in manual mode."""
import os
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
# Clear env var
old_val = os.environ.get("PRAISON_APPROVAL_MODE")
os.environ.pop("PRAISON_APPROVAL_MODE", None)
try:
config = RuntimeConfig(approval_mode="manual")
runtime = InteractiveRuntime(config)
runtime._read_only = True # Explicitly set read-only
assert runtime.read_only is True
finally:
if old_val is not None:
os.environ["PRAISON_APPROVAL_MODE"] = old_val
@@ -0,0 +1,117 @@
"""
Tests for the unified branding module.
This module provides the single source of truth for logo/version/branding
across all interactive UIs.
"""
import pytest
class TestBrandingConstants:
"""Tests for branding constants."""
def test_product_name(self):
"""Test PRODUCT_NAME constant."""
from praisonai.cli.branding import PRODUCT_NAME
assert PRODUCT_NAME == "Praison AI"
def test_logo_large_exists(self):
"""Test LOGO_LARGE constant exists and has ASCII art."""
from praisonai.cli.branding import LOGO_LARGE
assert len(LOGO_LARGE) > 0
assert "██" in LOGO_LARGE
def test_logo_medium_exists(self):
"""Test LOGO_MEDIUM constant exists."""
from praisonai.cli.branding import LOGO_MEDIUM
assert len(LOGO_MEDIUM) > 0
def test_logo_small_exists(self):
"""Test LOGO_SMALL constant exists and has branding."""
from praisonai.cli.branding import LOGO_SMALL
assert len(LOGO_SMALL) > 0
assert "Praison AI" in LOGO_SMALL
def test_logo_minimal_exists(self):
"""Test LOGO_MINIMAL constant exists and has branding."""
from praisonai.cli.branding import LOGO_MINIMAL
assert LOGO_MINIMAL == "Praison AI"
class TestGetLogo:
"""Tests for get_logo function."""
def test_get_logo_wide_terminal(self):
"""Test get_logo returns LOGO_LARGE for wide terminals."""
from praisonai.cli.branding import get_logo, LOGO_LARGE
assert get_logo(100) == LOGO_LARGE
assert get_logo(80) == LOGO_LARGE
assert get_logo(75) == LOGO_LARGE
def test_get_logo_medium_terminal(self):
"""Test get_logo returns LOGO_MEDIUM for medium terminals."""
from praisonai.cli.branding import get_logo, LOGO_MEDIUM
assert get_logo(74) == LOGO_MEDIUM
assert get_logo(50) == LOGO_MEDIUM
assert get_logo(40) == LOGO_MEDIUM
def test_get_logo_narrow_terminal(self):
"""Test get_logo returns LOGO_SMALL for narrow terminals."""
from praisonai.cli.branding import get_logo, LOGO_SMALL
assert get_logo(39) == LOGO_SMALL
assert get_logo(30) == LOGO_SMALL
assert get_logo(20) == LOGO_SMALL
class TestGetVersion:
"""Tests for get_version function."""
def test_get_version_returns_string(self):
"""Test get_version returns a string."""
from praisonai.cli.branding import get_version
version = get_version()
assert isinstance(version, str)
assert len(version) > 0
def test_get_version_format(self):
"""Test get_version returns a valid version format."""
from praisonai.cli.branding import get_version
version = get_version()
# Should be in format X.Y.Z or similar
parts = version.split(".")
assert len(parts) >= 2
class TestGetBanner:
"""Tests for get_banner function."""
def test_get_banner_includes_logo(self):
"""Test get_banner includes logo."""
from praisonai.cli.branding import get_banner
banner = get_banner(100)
assert "██" in banner or "Praison" in banner
def test_get_banner_includes_version(self):
"""Test get_banner includes version when requested."""
from praisonai.cli.branding import get_banner, get_version
banner = get_banner(100, show_version=True)
version = get_version()
assert version in banner
def test_get_banner_includes_model(self):
"""Test get_banner includes model when provided."""
from praisonai.cli.branding import get_banner
banner = get_banner(100, model="gpt-4o")
assert "gpt-4o" in banner
class TestGetWelcomeTips:
"""Tests for get_welcome_tips function."""
def test_get_welcome_tips_content(self):
"""Test get_welcome_tips returns helpful tips."""
from praisonai.cli.branding import get_welcome_tips
tips = get_welcome_tips()
assert "Enter" in tips or "help" in tips
assert len(tips) > 0
@@ -0,0 +1,343 @@
"""
Tests for CSV Test Runner.
Tests CSV parsing, test execution, assertions, and reporting.
"""
import tempfile
from pathlib import Path
class TestCSVSchema:
"""Tests for CSV schema definition."""
def test_schema_has_required_fields(self):
"""Test that schema defines required fields."""
from praisonai.cli.features.csv_test_runner import CSV_SCHEMA
assert "id" in CSV_SCHEMA
assert "name" in CSV_SCHEMA
assert "prompts" in CSV_SCHEMA
assert CSV_SCHEMA["id"]["required"] is True
assert CSV_SCHEMA["name"]["required"] is True
assert CSV_SCHEMA["prompts"]["required"] is True
def test_schema_has_optional_fields(self):
"""Test that schema defines optional fields."""
from praisonai.cli.features.csv_test_runner import CSV_SCHEMA
optional_fields = [
"description", "mode", "workspace_fixture",
"expected_tools", "forbidden_tools", "expected_files",
"expected_response", "judge_rubric", "judge_threshold",
"judge_model", "timeout", "retries", "skip_if",
"agents", "workflow",
]
for field in optional_fields:
assert field in CSV_SCHEMA
assert CSV_SCHEMA[field]["required"] is False
class TestTestCase:
"""Tests for TestCase dataclass."""
def test_default_values(self):
"""Test default test case values."""
from praisonai.cli.features.csv_test_runner import TestCase
tc = TestCase(id="test1", name="Test 1", prompts=["Hello"])
assert tc.mode == "headless"
assert tc.workspace_fixture == "empty"
assert tc.expected_tools == []
assert tc.forbidden_tools == []
assert tc.judge_threshold == 7.0
assert tc.timeout == 60
def test_should_skip_no_condition(self):
"""Test should_skip with no condition."""
from praisonai.cli.features.csv_test_runner import TestCase
tc = TestCase(id="test1", name="Test 1", prompts=["Hello"])
assert tc.should_skip() is None
def test_should_skip_no_openai_key(self):
"""Test should_skip with no_openai_key condition."""
import os
from praisonai.cli.features.csv_test_runner import TestCase
tc = TestCase(
id="test1", name="Test 1", prompts=["Hello"],
skip_if="no_openai_key"
)
# Save and clear key
original = os.environ.get("OPENAI_API_KEY")
if "OPENAI_API_KEY" in os.environ:
del os.environ["OPENAI_API_KEY"]
try:
result = tc.should_skip()
assert result is not None
assert "OPENAI_API_KEY" in result
finally:
if original:
os.environ["OPENAI_API_KEY"] = original
class TestTestResult:
"""Tests for TestResult dataclass."""
def test_to_dict(self):
"""Test result serialization."""
from praisonai.cli.features.csv_test_runner import TestResult
result = TestResult(
test_id="test1",
test_name="Test 1",
status="passed",
duration=1.5,
tool_calls=["read_file"],
response="Hello",
)
d = result.to_dict()
assert d["test_id"] == "test1"
assert d["status"] == "passed"
assert d["duration"] == 1.5
assert d["tool_calls"] == ["read_file"]
class TestTestSummary:
"""Tests for TestSummary dataclass."""
def test_to_dict(self):
"""Test summary serialization."""
from praisonai.cli.features.csv_test_runner import TestSummary
summary = TestSummary(
total=10,
passed=8,
failed=1,
skipped=1,
errors=0,
duration=30.0,
)
d = summary.to_dict()
assert d["total"] == 10
assert d["passed"] == 8
assert d["pass_rate"] == 0.8
def test_pass_rate_zero_total(self):
"""Test pass rate with zero total."""
from praisonai.cli.features.csv_test_runner import TestSummary
summary = TestSummary(total=0)
d = summary.to_dict()
assert d["pass_rate"] == 0
class TestParseCSV:
"""Tests for parse_csv function."""
def test_parse_simple_csv(self):
"""Test parsing simple CSV."""
from praisonai.cli.features.csv_test_runner import parse_csv
csv_content = """id,name,prompts
test1,Test One,Hello world
test2,Test Two,Goodbye world"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
f.write(csv_content)
f.flush()
test_cases = parse_csv(Path(f.name))
assert len(test_cases) == 2
assert test_cases[0].id == "test1"
assert test_cases[0].name == "Test One"
assert test_cases[0].prompts == ["Hello world"]
def test_parse_json_prompts(self):
"""Test parsing CSV with JSON prompts array."""
from praisonai.cli.features.csv_test_runner import parse_csv
csv_content = '''id,name,prompts
test1,Multi-step,"[""Step 1"", ""Step 2""]"'''
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
f.write(csv_content)
f.flush()
test_cases = parse_csv(Path(f.name))
assert len(test_cases) == 1
assert test_cases[0].prompts == ["Step 1", "Step 2"]
def test_parse_expected_tools(self):
"""Test parsing expected_tools column."""
from praisonai.cli.features.csv_test_runner import parse_csv
csv_content = """id,name,prompts,expected_tools
test1,Tool Test,Hello,"read_file,write_file"
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
f.write(csv_content)
f.flush()
test_cases = parse_csv(Path(f.name))
assert test_cases[0].expected_tools == ["read_file", "write_file"]
def test_parse_expected_files_json(self):
"""Test parsing expected_files JSON column."""
from praisonai.cli.features.csv_test_runner import parse_csv
csv_content = '''id,name,prompts,expected_files
test1,File Test,Hello,"{""test.py"": ""print""}"
'''
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
f.write(csv_content)
f.flush()
test_cases = parse_csv(Path(f.name))
assert test_cases[0].expected_files == {"test.py": "print"}
def test_parse_agents_json(self):
"""Test parsing agents JSON column."""
from praisonai.cli.features.csv_test_runner import parse_csv
csv_content = '''id,name,prompts,agents
test1,Multi-agent,Hello,"[{""name"": ""Agent1""}]"
'''
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
f.write(csv_content)
f.flush()
test_cases = parse_csv(Path(f.name))
assert test_cases[0].agents == [{"name": "Agent1"}]
def test_parse_numeric_fields(self):
"""Test parsing numeric fields."""
from praisonai.cli.features.csv_test_runner import parse_csv
csv_content = """id,name,prompts,judge_threshold,timeout,retries
test1,Numeric,Hello,8.5,120,2
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
f.write(csv_content)
f.flush()
test_cases = parse_csv(Path(f.name))
assert test_cases[0].judge_threshold == 8.5
assert test_cases[0].timeout == 120
assert test_cases[0].retries == 2
class TestGenerateCSVTemplate:
"""Tests for generate_csv_template function."""
def test_generates_valid_csv(self):
"""Test that template generates valid CSV."""
from praisonai.cli.features.csv_test_runner import generate_csv_template
csv_content = generate_csv_template()
assert "id,name,prompts" in csv_content
assert "smoke_01" in csv_content
assert "tools_01" in csv_content
def test_writes_to_file(self):
"""Test that template can be written to file."""
from praisonai.cli.features.csv_test_runner import generate_csv_template
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
output_path = Path(f.name)
generate_csv_template(output_path)
assert output_path.exists()
content = output_path.read_text()
assert "id,name,prompts" in content
class TestCSVTestRunner:
"""Tests for CSVTestRunner class."""
def test_initialization(self):
"""Test runner initialization."""
from praisonai.cli.features.csv_test_runner import CSVTestRunner
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f:
f.write(b"id,name,prompts\n")
csv_path = Path(f.name)
runner = CSVTestRunner(
csv_path=csv_path,
model="gpt-4o",
judge_model="gpt-4o-mini",
fail_fast=True,
no_judge=True,
)
assert runner.model == "gpt-4o"
assert runner.judge_model == "gpt-4o-mini"
assert runner.fail_fast is True
assert runner.no_judge is True
def test_parse_judge_score_standard(self):
"""Test parsing standard judge score format."""
from praisonai.cli.features.csv_test_runner import CSVTestRunner
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f:
f.write(b"id,name,prompts\n")
csv_path = Path(f.name)
runner = CSVTestRunner(csv_path=csv_path)
response = "SCORE: 8\nREASONING: Good response"
score = runner._parse_judge_score(response)
assert score == 8.0
def test_parse_judge_score_decimal(self):
"""Test parsing decimal judge score."""
from praisonai.cli.features.csv_test_runner import CSVTestRunner
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f:
f.write(b"id,name,prompts\n")
csv_path = Path(f.name)
runner = CSVTestRunner(csv_path=csv_path)
response = "SCORE: 7.5\nREASONING: Decent response"
score = runner._parse_judge_score(response)
assert score == 7.5
def test_parse_judge_score_no_match(self):
"""Test parsing when no score found."""
from praisonai.cli.features.csv_test_runner import CSVTestRunner
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f:
f.write(b"id,name,prompts\n")
csv_path = Path(f.name)
runner = CSVTestRunner(csv_path=csv_path)
response = "This response has no score"
score = runner._parse_judge_score(response)
assert score is None
@@ -0,0 +1,309 @@
"""
Tests for Headless Interactive Core Executor.
Tests that the headless executor uses the same interactive core pipeline
as the TUI (InteractiveRuntime, get_interactive_tools, etc.).
"""
from unittest.mock import MagicMock
import pytest
class TestHeadlessConfig:
"""Tests for HeadlessConfig dataclass."""
def test_default_config(self):
"""Test default configuration values."""
from praisonai.cli.features.interactive_core_headless import HeadlessConfig
config = HeadlessConfig()
assert config.model == "gpt-4o-mini"
assert config.approval_mode == "auto"
assert config.enable_acp is True
assert config.enable_lsp is True
assert config.enable_basic is True
assert config.timeout == 60
def test_custom_config(self):
"""Test custom configuration values."""
from praisonai.cli.features.interactive_core_headless import HeadlessConfig
config = HeadlessConfig(
workspace="/tmp/test",
model="gpt-4o",
approval_mode="manual",
timeout=120,
)
assert config.workspace == "/tmp/test"
assert config.model == "gpt-4o"
assert config.approval_mode == "manual"
assert config.timeout == 120
def test_default_agent_created(self):
"""Test that default agent is created if none provided."""
from praisonai.cli.features.interactive_core_headless import HeadlessConfig
config = HeadlessConfig()
assert len(config.agents) == 1
assert config.agents[0].name == "HeadlessAgent"
class TestAgentConfig:
"""Tests for AgentConfig dataclass."""
def test_default_values(self):
"""Test default agent config values."""
from praisonai.cli.features.interactive_core_headless import AgentConfig
config = AgentConfig()
assert config.name == "HeadlessAgent"
assert config.role == "assistant"
assert config.llm == "gpt-4o-mini"
def test_custom_values(self):
"""Test custom agent config values."""
from praisonai.cli.features.interactive_core_headless import AgentConfig
config = AgentConfig(
name="TestAgent",
instructions="Test instructions",
role="tester",
llm="gpt-4o",
)
assert config.name == "TestAgent"
assert config.instructions == "Test instructions"
assert config.role == "tester"
assert config.llm == "gpt-4o"
class TestToolCallTrace:
"""Tests for ToolCallTrace dataclass."""
def test_to_dict(self):
"""Test trace serialization."""
from praisonai.cli.features.interactive_core_headless import ToolCallTrace
trace = ToolCallTrace(
tool_name="test_tool",
args=("arg1",),
kwargs={"key": "value"},
result="result",
success=True,
error=None,
duration=0.5,
timestamp="2024-01-01T00:00:00",
)
d = trace.to_dict()
assert d["tool_name"] == "test_tool"
assert d["success"] is True
assert d["duration"] == 0.5
class TestHeadlessExecutionResult:
"""Tests for HeadlessExecutionResult dataclass."""
def test_to_dict(self):
"""Test result serialization."""
from praisonai.cli.features.interactive_core_headless import (
HeadlessExecutionResult,
)
result = HeadlessExecutionResult(
success=True,
responses=["Hello"],
tool_trace=[],
duration=1.0,
transcript=[{"role": "user", "content": "Hi"}],
)
d = result.to_dict()
assert d["success"] is True
assert d["responses"] == ["Hello"]
assert d["duration"] == 1.0
class TestHeadlessInteractiveCore:
"""Tests for HeadlessInteractiveCore class."""
def test_initialization(self):
"""Test executor initialization."""
from praisonai.cli.features.interactive_core_headless import (
HeadlessConfig,
HeadlessInteractiveCore,
)
config = HeadlessConfig(workspace="/tmp/test")
executor = HeadlessInteractiveCore(config)
assert executor.config.workspace == "/tmp/test"
assert executor._initialized is False
assert executor._tools == []
def test_wrap_tool_for_trace(self):
"""Test that tool wrapping captures traces."""
from praisonai.cli.features.interactive_core_headless import (
HeadlessConfig,
HeadlessInteractiveCore,
)
config = HeadlessConfig()
executor = HeadlessInteractiveCore(config)
def sample_tool(x):
return x * 2
wrapped = executor._wrap_tool_for_trace(sample_tool)
result = wrapped(5)
assert result == 10
assert len(executor._tool_trace) == 1
assert executor._tool_trace[0].tool_name == "sample_tool"
assert executor._tool_trace[0].success is True
def test_wrap_tool_captures_errors(self):
"""Test that tool wrapping captures errors."""
from praisonai.cli.features.interactive_core_headless import (
HeadlessConfig,
HeadlessInteractiveCore,
)
config = HeadlessConfig()
executor = HeadlessInteractiveCore(config)
def failing_tool():
raise ValueError("Test error")
wrapped = executor._wrap_tool_for_trace(failing_tool)
with pytest.raises(ValueError):
wrapped()
assert len(executor._tool_trace) == 1
assert executor._tool_trace[0].success is False
assert "Test error" in executor._tool_trace[0].error
def test_get_agent_for_prompt_single_agent(self):
"""Test agent selection with single agent."""
from praisonai.cli.features.interactive_core_headless import (
HeadlessConfig,
HeadlessInteractiveCore,
)
config = HeadlessConfig()
executor = HeadlessInteractiveCore(config)
executor._agents = [MagicMock()] # Single agent
assert executor._get_agent_for_prompt(0, "test") == 0
assert executor._get_agent_for_prompt(1, "test") == 0
def test_get_agent_for_prompt_round_robin(self):
"""Test round-robin agent selection."""
from praisonai.cli.features.interactive_core_headless import (
HeadlessConfig,
HeadlessInteractiveCore,
AgentConfig,
)
config = HeadlessConfig(
agents=[AgentConfig(name="A"), AgentConfig(name="B")],
workflow={"routing": "round_robin"},
)
executor = HeadlessInteractiveCore(config)
executor._agents = [MagicMock(), MagicMock()]
assert executor._get_agent_for_prompt(0, "test") == 0
assert executor._get_agent_for_prompt(1, "test") == 1
assert executor._get_agent_for_prompt(2, "test") == 0
def test_clear_trace(self):
"""Test clearing tool trace."""
from praisonai.cli.features.interactive_core_headless import (
HeadlessConfig,
HeadlessInteractiveCore,
ToolCallTrace,
)
config = HeadlessConfig()
executor = HeadlessInteractiveCore(config)
executor._tool_trace = [
ToolCallTrace("t", (), {}, None, True, None, 0, "")
]
executor.clear_trace()
assert executor._tool_trace == []
class TestRunHeadlessFunction:
"""Tests for run_headless convenience function."""
def test_function_creates_executor(self):
"""Test that run_headless creates executor with correct config."""
# We can't easily test the full run without mocking Agent
# Just verify the function exists and has correct signature
from praisonai.cli.features.interactive_core_headless import run_headless
import inspect
sig = inspect.signature(run_headless)
params = list(sig.parameters.keys())
assert "prompts" in params
assert "workspace" in params
assert "model" in params
assert "approval_mode" in params
assert "agents" in params
assert "workflow" in params
class TestRedactSensitive:
"""Tests for _redact_sensitive function."""
def test_redacts_api_key(self):
"""Test that API keys are redacted."""
from praisonai.cli.features.interactive_core_headless import _redact_sensitive
data = {"api_key": "secret123", "name": "test"}
result = _redact_sensitive(data)
assert result["api_key"] == "[REDACTED]"
assert result["name"] == "test"
def test_redacts_nested(self):
"""Test that nested sensitive data is redacted."""
from praisonai.cli.features.interactive_core_headless import _redact_sensitive
data = {"config": {"password": "secret", "user": "admin"}}
result = _redact_sensitive(data)
assert result["config"]["password"] == "[REDACTED]"
assert result["config"]["user"] == "admin"
def test_truncates_long_strings(self):
"""Test that long strings are truncated."""
from praisonai.cli.features.interactive_core_headless import _redact_sensitive
long_string = "x" * 200
result = _redact_sensitive(long_string)
assert len(result) < 200
assert "[truncated]" in result
class TestTruncate:
"""Tests for _truncate function."""
def test_short_string_unchanged(self):
"""Test that short strings are unchanged."""
from praisonai.cli.features.interactive_core_headless import _truncate
result = _truncate("hello", 100)
assert result == "hello"
def test_long_string_truncated(self):
"""Test that long strings are truncated."""
from praisonai.cli.features.interactive_core_headless import _truncate
result = _truncate("x" * 100, 50)
assert len(result) < 100
assert "[truncated]" in result
@@ -0,0 +1,344 @@
"""
Tests for Interactive Test Harness.
Tests workspace isolation, fixtures, tool trace, and artifact generation.
"""
import tempfile
from pathlib import Path
class TestWorkspaceFixture:
"""Tests for WorkspaceFixture dataclass."""
def test_empty_fixture(self):
"""Test empty workspace fixture."""
from praisonai.cli.features.interactive_test_harness import WorkspaceFixture
fixture = WorkspaceFixture.empty()
assert fixture.name == "empty"
assert fixture.files == {}
assert fixture.directories == []
def test_seeded_fixture(self):
"""Test seeded workspace fixture."""
from praisonai.cli.features.interactive_test_harness import WorkspaceFixture
fixture = WorkspaceFixture.seeded()
assert fixture.name == "seeded"
assert "README.md" in fixture.files
assert "main.py" in fixture.files
assert "src" in fixture.directories
def test_git_fixture(self):
"""Test git workspace fixture."""
from praisonai.cli.features.interactive_test_harness import WorkspaceFixture
fixture = WorkspaceFixture.git()
assert fixture.name == "git"
assert ".gitignore" in fixture.files
def test_python_project_fixture(self):
"""Test Python project workspace fixture."""
from praisonai.cli.features.interactive_test_harness import WorkspaceFixture
fixture = WorkspaceFixture.python_project()
assert fixture.name == "python_project"
assert "main.py" in fixture.files
assert "utils.py" in fixture.files
assert "def main" in fixture.files["main.py"]
class TestBuiltinFixtures:
"""Tests for BUILTIN_FIXTURES registry."""
def test_all_fixtures_registered(self):
"""Test that all fixtures are registered."""
from praisonai.cli.features.interactive_test_harness import BUILTIN_FIXTURES
assert "empty" in BUILTIN_FIXTURES
assert "seeded" in BUILTIN_FIXTURES
assert "git" in BUILTIN_FIXTURES
assert "python_project" in BUILTIN_FIXTURES
def test_fixtures_are_callable(self):
"""Test that fixtures are callable factories."""
from praisonai.cli.features.interactive_test_harness import BUILTIN_FIXTURES
for name, factory in BUILTIN_FIXTURES.items():
fixture = factory()
assert fixture.name == name
class TestTestArtifacts:
"""Tests for TestArtifacts dataclass."""
def test_save_creates_files(self):
"""Test that save creates artifact files."""
from praisonai.cli.features.interactive_test_harness import TestArtifacts
with tempfile.TemporaryDirectory() as tmpdir:
artifacts_dir = Path(tmpdir) / "artifacts"
artifacts = TestArtifacts(
transcript=[{"role": "user", "content": "Hello"}],
tool_trace=[{"tool": "test", "success": True}],
result={"status": "passed"},
)
artifacts.save(artifacts_dir)
assert (artifacts_dir / "transcript.txt").exists()
assert (artifacts_dir / "tool_trace.jsonl").exists()
assert (artifacts_dir / "result.json").exists()
def test_save_with_workspace_snapshot(self):
"""Test saving with workspace snapshot."""
from praisonai.cli.features.interactive_test_harness import TestArtifacts
with tempfile.TemporaryDirectory() as tmpdir:
artifacts_dir = Path(tmpdir) / "artifacts"
artifacts = TestArtifacts(
transcript=[],
tool_trace=[],
result={},
workspace_snapshot={"test.py": "print('hello')"},
)
artifacts.save(artifacts_dir)
snapshot_file = artifacts_dir / "workspace" / "test.py"
assert snapshot_file.exists()
assert snapshot_file.read_text() == "print('hello')"
def test_save_with_judge_result(self):
"""Test saving with judge result."""
from praisonai.cli.features.interactive_test_harness import TestArtifacts
with tempfile.TemporaryDirectory() as tmpdir:
artifacts_dir = Path(tmpdir) / "artifacts"
artifacts = TestArtifacts(
transcript=[],
tool_trace=[],
result={},
judge_result={"score": 8, "passed": True},
)
artifacts.save(artifacts_dir)
assert (artifacts_dir / "judge_result.json").exists()
class TestInteractiveTestHarness:
"""Tests for InteractiveTestHarness class."""
def test_initialization_creates_workspace(self):
"""Test that initialization creates workspace."""
from praisonai.cli.features.interactive_test_harness import InteractiveTestHarness
harness = InteractiveTestHarness()
assert harness.workspace.exists()
assert harness._workspace_created is True
harness.cleanup()
def test_initialization_with_custom_workspace(self):
"""Test initialization with custom workspace."""
from praisonai.cli.features.interactive_test_harness import InteractiveTestHarness
with tempfile.TemporaryDirectory() as tmpdir:
workspace = Path(tmpdir) / "custom"
workspace.mkdir()
harness = InteractiveTestHarness(workspace=workspace)
assert harness.workspace == workspace
assert harness._workspace_created is False
def test_setup_workspace_empty(self):
"""Test setting up empty workspace."""
from praisonai.cli.features.interactive_test_harness import InteractiveTestHarness
harness = InteractiveTestHarness()
harness.setup_workspace("empty")
assert harness.workspace.exists()
# Empty workspace should have no files
files = list(harness.workspace.iterdir())
assert len(files) == 0
harness.cleanup()
def test_setup_workspace_seeded(self):
"""Test setting up seeded workspace."""
from praisonai.cli.features.interactive_test_harness import InteractiveTestHarness
harness = InteractiveTestHarness()
harness.setup_workspace("seeded")
assert (harness.workspace / "README.md").exists()
assert (harness.workspace / "main.py").exists()
assert (harness.workspace / "src").is_dir()
harness.cleanup()
def test_verify_files_exists(self):
"""Test verifying file existence."""
from praisonai.cli.features.interactive_test_harness import InteractiveTestHarness
harness = InteractiveTestHarness()
harness.setup_workspace("empty")
# Create a test file
test_file = harness.workspace / "test.py"
test_file.write_text("print('hello')")
results = harness.verify_files({"test.py": ""})
assert results["test.py"] is True
results = harness.verify_files({"nonexistent.py": ""})
assert results["nonexistent.py"] is False
harness.cleanup()
def test_verify_files_content_pattern(self):
"""Test verifying file content with pattern."""
from praisonai.cli.features.interactive_test_harness import InteractiveTestHarness
harness = InteractiveTestHarness()
harness.setup_workspace("empty")
test_file = harness.workspace / "test.py"
test_file.write_text("x = 42\nprint(x)")
# Pattern matches
results = harness.verify_files({"test.py": "x.*=.*42"})
assert results["test.py"] is True
# Pattern doesn't match
results = harness.verify_files({"test.py": "y.*=.*99"})
assert results["test.py"] is False
harness.cleanup()
def test_verify_tool_calls_expected(self):
"""Test verifying expected tool calls."""
from praisonai.cli.features.interactive_test_harness import InteractiveTestHarness
from unittest.mock import MagicMock
harness = InteractiveTestHarness()
harness._executor = MagicMock()
harness._executor.get_tools_called.return_value = ["read_file", "write_file"]
results = harness.verify_tool_calls(expected_tools=["read_file"])
assert results["passed"] is True
assert results["expected_passed"] is True
results = harness.verify_tool_calls(expected_tools=["delete_file"])
assert results["passed"] is False
assert "delete_file" in results["missing_tools"]
harness.cleanup()
def test_verify_tool_calls_forbidden(self):
"""Test verifying forbidden tool calls."""
from praisonai.cli.features.interactive_test_harness import InteractiveTestHarness
from unittest.mock import MagicMock
harness = InteractiveTestHarness()
harness._executor = MagicMock()
harness._executor.get_tools_called.return_value = ["read_file", "delete_file"]
results = harness.verify_tool_calls(forbidden_tools=["delete_file"])
assert results["passed"] is False
assert results["forbidden_passed"] is False
assert "delete_file" in results["forbidden_called"]
harness.cleanup()
def test_verify_response_pattern(self):
"""Test verifying response with pattern."""
from praisonai.cli.features.interactive_test_harness import InteractiveTestHarness
harness = InteractiveTestHarness()
assert harness.verify_response("The answer is 42", "42") is True
assert harness.verify_response("The answer is 42", "99") is False
assert harness.verify_response("Hello World", "hello") is True # Case insensitive
harness.cleanup()
def test_snapshot_workspace(self):
"""Test creating workspace snapshot."""
from praisonai.cli.features.interactive_test_harness import InteractiveTestHarness
harness = InteractiveTestHarness()
harness.setup_workspace("seeded")
snapshot = harness.snapshot_workspace()
assert "README.md" in snapshot
assert "main.py" in snapshot
assert "Test Project" in snapshot["README.md"]
harness.cleanup()
def test_context_manager(self):
"""Test using harness as context manager."""
from praisonai.cli.features.interactive_test_harness import InteractiveTestHarness
with InteractiveTestHarness() as harness:
harness.setup_workspace("empty")
workspace_path = harness.workspace
assert workspace_path.exists()
# Workspace should be cleaned up
assert not workspace_path.exists()
def test_keep_workspace(self):
"""Test keeping workspace after cleanup."""
from praisonai.cli.features.interactive_test_harness import InteractiveTestHarness
harness = InteractiveTestHarness(keep_workspace=True)
harness.setup_workspace("empty")
workspace_path = harness.workspace
harness.cleanup()
# Workspace should still exist
assert workspace_path.exists()
# Manual cleanup
import shutil
shutil.rmtree(workspace_path)
class TestCreateTestHarness:
"""Tests for create_test_harness function."""
def test_creates_harness_with_fixture(self):
"""Test creating harness with fixture."""
from praisonai.cli.features.interactive_test_harness import create_test_harness
harness = create_test_harness(fixture="seeded")
assert (harness.workspace / "README.md").exists()
harness.cleanup()
def test_creates_harness_with_custom_workspace(self):
"""Test creating harness with custom workspace."""
from praisonai.cli.features.interactive_test_harness import create_test_harness
with tempfile.TemporaryDirectory() as tmpdir:
workspace = Path(tmpdir) / "custom"
harness = create_test_harness(
fixture="empty",
workspace=workspace,
)
assert harness.workspace == workspace
@@ -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.branding import get_logo, LOGO_LARGE
logo = get_logo(100)
assert logo == LOGO_LARGE
def test_logo_medium_terminal(self):
"""Test logo for medium terminal."""
from praisonai.cli.branding import get_logo, LOGO_MEDIUM
logo = get_logo(50)
assert logo == LOGO_MEDIUM
def test_logo_narrow_terminal(self):
"""Test logo for narrow terminal."""
from praisonai.cli.branding import get_logo, LOGO_SMALL
logo = get_logo(30)
assert logo == LOGO_SMALL
def test_logo_contains_praison_ai(self):
"""Test that logo contains 'Praison AI' branding."""
from praisonai.cli.branding import LOGO_SMALL
assert "Praison AI" in LOGO_SMALL
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.branding import LOGO_LARGE
assert len(LOGO_LARGE) > 0
assert "██" in LOGO_LARGE # Contains block characters
def test_logo_small_exists(self):
"""Test LOGO_SMALL constant exists."""
from praisonai.cli.branding import LOGO_SMALL
assert len(LOGO_SMALL) > 0
def test_logo_minimal_branding(self):
"""Test LOGO_MINIMAL has correct branding."""
from praisonai.cli.branding 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.branding import get_logo, LOGO_LARGE
logo = get_logo(100)
assert logo == LOGO_LARGE
def test_logo_medium_terminal(self):
"""Test logo for medium terminal."""
from praisonai.cli.branding import get_logo, LOGO_MEDIUM
logo = get_logo(65)
assert logo == LOGO_MEDIUM
def test_logo_narrow_terminal(self):
"""Test logo for narrow terminal."""
from praisonai.cli.branding import get_logo, LOGO_SMALL
logo = get_logo(30)
assert logo == LOGO_SMALL
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.branding 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.branding import LOGO_SMALL
assert len(LOGO_SMALL) > 0
def test_logo_tiny_exists(self):
"""Test LOGO_MEDIUM constant exists (was LOGO_TINY)."""
from praisonai.cli.branding import LOGO_MEDIUM
assert len(LOGO_MEDIUM) > 0
def test_logo_minimal_exists(self):
"""Test LOGO_MINIMAL constant exists."""
from praisonai.cli.branding import LOGO_MINIMAL
assert "Praison AI" 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.6" },
{ 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.6"
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/e8/c1/a113cd884d9ca0747f31dc652740b5a859fdc301017f45d75382a0ceba4e/praisonaiagents-0.11.6.tar.gz", hash = "sha256:fb19b18cea0e2cccd7221e34b309dec43d6960463f39de922cd749670d1cae4f", size = 981805, upload-time = "2026-01-12T12:43:47.183Z" }
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/3d/b5/4129fd74320c367a3d6365fb4481da3a3f7117de36dbb38ea9215e760d1f/praisonaiagents-0.11.6-py3-none-any.whl", hash = "sha256:768288047146ba9431dc23c07b222ebb97d1e05c648f6f3a37cfa71d59a30cdb", size = 964812, upload-time = "2026-01-12T12:43:44.786Z" },
]
[[package]]