mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-09 03:39:37 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6df3e32640 | ||
|
|
fb36e1c50c | ||
|
|
ac5454cb00 | ||
|
|
bbe95795d1 | ||
|
|
bdaf5340f9 | ||
|
|
5579614001 | ||
|
|
2c4dd0e2d4 | ||
|
|
fd23d62841 | ||
|
|
5f3bcb78ac | ||
|
|
3c40c2278e | ||
|
|
1921b03aab | ||
|
|
f33bf57662 | ||
|
|
df21c419ea | ||
|
|
fe997d525c |
No files matched your search
@@ -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
@@ -83,4 +83,5 @@ issue_details.json
|
||||
# MCP Registry tokens
|
||||
.mcpregistry_*
|
||||
|
||||
*.log
|
||||
*.log
|
||||
.internal
|
||||
@@ -60,6 +60,7 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_skills_prompt</a>() -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to_async</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">iter_stream</a>(prompt: str, **kwargs)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">launch</a>(path: str = '/', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = 'http')</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_model</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>()</code>
|
||||
@@ -314,6 +315,8 @@ Methods:
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">compute_quality_score</a>(completeness: float, relevance: float, clarity: float, accuracy: float, weights: Dict[str, float] = None) -> float</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">finalize_task_output</a>(content: str, agent_name: str, quality_score: float, threshold: float = 0.7, metrics: Dict[str, Any] = None, task_id: str = None)</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_all_memories</a>() -> List[Dict[str, Any]]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_learn_context</a>() -> str</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">learn</a>()</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_all</a>()</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_entity_only</a>()</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_long_term</a>()</code>
|
||||
@@ -552,7 +555,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 +572,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)
|
||||
|
||||
@@ -614,8 +618,11 @@ Methods:
|
||||
* <code title="cli">praisonai context clear <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context compact <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context export <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context grep <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context list <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context show <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context stats <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context tail <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai debug acp <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
|
||||
* <code title="cli">praisonai debug debug-callback <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
|
||||
* <code title="cli">praisonai debug interactive <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
|
||||
@@ -670,14 +677,19 @@ Methods:
|
||||
* <code title="cli">praisonai lsp status <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
|
||||
* <code title="cli">praisonai lsp stop <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp add <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp describe <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp list <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp mcp-callback <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp remove <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp status <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp sync <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp test <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp tools <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai memory add <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
|
||||
* <code title="cli">praisonai memory clear <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
|
||||
* <code title="cli">praisonai memory search <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
|
||||
* <code title="cli">praisonai memory show <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
|
||||
* <code title="cli">praisonai memory status <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
|
||||
* <code title="cli">praisonai package install <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai package list <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai package uninstall <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
@@ -728,12 +740,19 @@ Methods:
|
||||
* <code title="cli">praisonai session resume <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
|
||||
* <code title="cli">praisonai session show <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
|
||||
* <code title="cli">praisonai skills create <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
|
||||
* <code title="cli">praisonai skills info <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
|
||||
* <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 search <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>
|
||||
* <code title="cli">praisonai test interactive <a href="./src/praisonai/praisonai/cli/commands/test.py">--help</a></code>
|
||||
* <code title="cli">praisonai test run <a href="./src/praisonai/praisonai/cli/commands/test.py">--help</a></code>
|
||||
* <code title="cli">praisonai todo add <a href="./src/praisonai/praisonai/cli/commands/todo.py">--help</a></code>
|
||||
* <code title="cli">praisonai todo done <a href="./src/praisonai/praisonai/cli/commands/todo.py">--help</a></code>
|
||||
|
||||
@@ -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.3" \
|
||||
"praisonai>=3.8.2" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=3.7.3" \
|
||||
"praisonai>=3.8.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=3.7.3" \
|
||||
"praisonai>=3.8.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
print("Creating agent...")
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant"
|
||||
)
|
||||
|
||||
print("Starting agent...")
|
||||
agent = Agent(instructions="You are a helpful assistant")
|
||||
result = agent.start("Why is the sky blue?")
|
||||
|
||||
print("Agent completed successfully!")
|
||||
print(f"Result: {result}")
|
||||
print(result)
|
||||
Whitespace-only changes.
@@ -704,4 +704,17 @@ __all__ = [
|
||||
'parse_policy_string',
|
||||
# Performance utilities
|
||||
'warmup',
|
||||
# Cortex - Continuous Improvement System
|
||||
'CortexConfig',
|
||||
'CaptureStore',
|
||||
'CaptureMode',
|
||||
'CaptureScope',
|
||||
'PersonaConfig',
|
||||
'InsightConfig',
|
||||
'ThreadConfig',
|
||||
'EntityConfig',
|
||||
'PatternConfig',
|
||||
'DecisionConfig',
|
||||
'FeedbackConfig',
|
||||
'ImprovementConfig',
|
||||
]
|
||||
@@ -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-4o-mini".
|
||||
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
|
||||
@@ -824,7 +844,7 @@ class Agent:
|
||||
self.llm_instance = LLM(**llm_config)
|
||||
else:
|
||||
# Create LLM with model string and base_url
|
||||
model_name = llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-5-nano')
|
||||
model_name = llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
|
||||
self.llm_instance = LLM(
|
||||
model=model_name,
|
||||
base_url=base_url,
|
||||
@@ -887,7 +907,7 @@ class Agent:
|
||||
) from e
|
||||
# Otherwise, fall back to OpenAI environment/name
|
||||
else:
|
||||
self.llm = llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-5-nano')
|
||||
self.llm = llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
|
||||
# Handle tools parameter - ensure it's always a list
|
||||
if callable(tools):
|
||||
# If a single function/callable is passed, wrap it in a list
|
||||
@@ -933,7 +953,7 @@ class Agent:
|
||||
self.min_reflect = min_reflect
|
||||
self.reflect_prompt = reflect_prompt
|
||||
# Use the same model selection logic for reflect_llm
|
||||
self.reflect_llm = reflect_llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-5-nano')
|
||||
self.reflect_llm = reflect_llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
|
||||
self._console = None # Lazy load console when needed
|
||||
|
||||
# Initialize system prompt
|
||||
@@ -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):
|
||||
@@ -1720,7 +1743,7 @@ Your Goal: {self.goal}
|
||||
elif hasattr(self, 'llm') and self.llm:
|
||||
model_name = self.llm
|
||||
else:
|
||||
model_name = "gpt-5-nano"
|
||||
model_name = "gpt-4o-mini"
|
||||
|
||||
return supports_web_search(model_name)
|
||||
|
||||
@@ -1742,7 +1765,7 @@ Your Goal: {self.goal}
|
||||
elif hasattr(self, 'llm') and self.llm:
|
||||
model_name = self.llm
|
||||
else:
|
||||
model_name = "gpt-5-nano"
|
||||
model_name = "gpt-4o-mini"
|
||||
|
||||
return supports_web_fetch(model_name)
|
||||
|
||||
@@ -1986,7 +2009,7 @@ Your Goal: {self.goal}
|
||||
|
||||
Returns:
|
||||
The LLM model/instance being used by this agent.
|
||||
- For standard models: returns the model string (e.g., "gpt-5-nano")
|
||||
- For standard models: returns the model string (e.g., "gpt-4o-mini")
|
||||
- For custom LLM instances: returns the LLM instance object
|
||||
- For provider models: returns the LLM instance object
|
||||
"""
|
||||
@@ -1996,7 +2019,7 @@ Your Goal: {self.goal}
|
||||
return self.llm
|
||||
else:
|
||||
# Default fallback
|
||||
return "gpt-5-nano"
|
||||
return "gpt-4o-mini"
|
||||
|
||||
def _ensure_knowledge_processed(self):
|
||||
"""Ensure knowledge is initialized and processed when first accessed."""
|
||||
@@ -3696,7 +3719,7 @@ Your Goal: {self.goal}"""
|
||||
if self.verbose:
|
||||
logging.debug(f"Agent {self.name} final response: {response_text}")
|
||||
# Return only reasoning content if reasoning_steps is True
|
||||
if reasoning_steps and hasattr(response.choices[0].message, 'reasoning_content'):
|
||||
if reasoning_steps and hasattr(response.choices[0].message, 'reasoning_content') and response.choices[0].message.reasoning_content:
|
||||
# Apply guardrail to reasoning content
|
||||
try:
|
||||
validated_reasoning = self._apply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
@@ -4221,7 +4244,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
final_response = process_stream_chunks(chunks)
|
||||
# Return only reasoning content if reasoning_steps is True
|
||||
if reasoning_steps and hasattr(final_response.choices[0].message, 'reasoning_content'):
|
||||
if reasoning_steps and hasattr(final_response.choices[0].message, 'reasoning_content') and final_response.choices[0].message.reasoning_content:
|
||||
return final_response.choices[0].message.reasoning_content
|
||||
return final_response.choices[0].message.content if final_response else full_response_text
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -434,7 +434,7 @@ This report contains all agent interactions and outputs from a complete ContextA
|
||||
|
||||
Provide comprehensive analysis that follows the PRD template principles and enables
|
||||
AI assistants to implement features that perfectly match existing codebase patterns.""",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
|
||||
verbose=getattr(self, 'verbose', True)
|
||||
)
|
||||
|
||||
@@ -515,7 +515,7 @@ codebase style and architecture following PRD template principles."""
|
||||
role="Expert Manual Codebase Analysis Specialist",
|
||||
goal="Perform comprehensive manual codebase analysis following PRD methodology",
|
||||
instructions="""Analyze the codebase samples following PRD template methodology for complete understanding.""",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
|
||||
verbose=getattr(self, 'verbose', True)
|
||||
)
|
||||
|
||||
@@ -566,7 +566,7 @@ Analyze following PRD principles to extract patterns, conventions, and architect
|
||||
6. Design pattern implementations
|
||||
7. Code complexity metrics
|
||||
8. API and interface patterns""",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
|
||||
verbose=getattr(self, 'verbose', True)
|
||||
)
|
||||
|
||||
@@ -624,7 +624,7 @@ Extract comprehensive patterns that follow PRD template principles for implement
|
||||
|
||||
For each pattern, provide the pattern name, where it's used, and how to replicate it
|
||||
following PRD template principles.""",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
|
||||
verbose=getattr(self, 'verbose', True)
|
||||
)
|
||||
|
||||
@@ -674,7 +674,7 @@ patterns and best practices for first-try success."""
|
||||
goal="Analyze testing patterns for comprehensive validation framework design",
|
||||
instructions="""Analyze testing patterns to understand validation approaches and create
|
||||
comprehensive test frameworks following PRD methodology.""",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
|
||||
verbose=getattr(self, 'verbose', True)
|
||||
)
|
||||
|
||||
@@ -801,7 +801,7 @@ Extract testing patterns for validation framework creation following PRD princip
|
||||
Confidence level for one-pass implementation
|
||||
|
||||
Generate PRPs following this EXACT structure for first-try implementation success.""",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
|
||||
verbose=getattr(self, 'verbose', True)
|
||||
)
|
||||
|
||||
@@ -850,7 +850,7 @@ on the first try following PRD template principles."""
|
||||
6. CODE QUALITY: Complexity analysis, maintainability
|
||||
7. DOCUMENTATION VALIDATION: Documentation completeness
|
||||
8. DEPENDENCY VALIDATION: Dependency analysis and security""",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
|
||||
verbose=getattr(self, 'verbose', True)
|
||||
)
|
||||
|
||||
@@ -897,7 +897,7 @@ following PRD template principles."""
|
||||
instructions="""Compile all available documentation following PRD methodology including:
|
||||
README files, API documentation, setup guides, architecture docs, and any other
|
||||
relevant documentation that provides context for implementation.""",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
|
||||
verbose=getattr(self, 'verbose', True)
|
||||
)
|
||||
|
||||
@@ -943,7 +943,7 @@ following PRD template principles."""
|
||||
instructions="""Analyze integration points following PRD methodology including:
|
||||
APIs, databases, external services, configuration points, and any other
|
||||
integration requirements that affect implementation.""",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
|
||||
verbose=getattr(self, 'verbose', True)
|
||||
)
|
||||
|
||||
@@ -1004,7 +1004,7 @@ following PRD template principles."""
|
||||
8. DOCUMENTATION UPDATES: Documentation to create/update
|
||||
9. INTEGRATION STEPS: How to integrate with existing systems
|
||||
10. VALIDATION CHECKPOINTS: Validation steps at each phase""",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
|
||||
verbose=getattr(self, 'verbose', True)
|
||||
)
|
||||
|
||||
@@ -1302,7 +1302,7 @@ Every agent interaction has been saved for full audit trail and reproducibility.
|
||||
GOAL: [extracted implementation goal]
|
||||
|
||||
Be precise and extract only what is explicitly mentioned or clearly implied.""",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
|
||||
verbose=getattr(self, 'verbose', True)
|
||||
)
|
||||
|
||||
@@ -1536,7 +1536,7 @@ Note: Detailed function/class metadata not available due to content access limit
|
||||
5. Documentation topics
|
||||
|
||||
Make the output easy for a file selection agent to understand which files contain what functionality.""",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
|
||||
verbose=getattr(self, 'verbose', True)
|
||||
)
|
||||
|
||||
@@ -1770,7 +1770,7 @@ Focus on creating clear, structured metadata that will help with intelligent fil
|
||||
["README.md", "src/auth/login.py", "config/settings.py", ...]
|
||||
|
||||
Maximum 50 files for efficient analysis.""",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
|
||||
verbose=getattr(self, 'verbose', True)
|
||||
)
|
||||
|
||||
@@ -1923,7 +1923,7 @@ Maximum 50 files.""".format(goal=goal)
|
||||
8. EXAMPLES: Similar features that can guide {goal} implementation
|
||||
|
||||
Since these files were pre-selected for relevance, provide deep analysis of how each contributes to implementing: {goal}""",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
|
||||
verbose=getattr(self, 'verbose', True)
|
||||
)
|
||||
|
||||
@@ -2024,7 +2024,7 @@ Since these files were pre-selected for relevance, explain how each contributes
|
||||
- Success criteria for {goal}
|
||||
|
||||
Focus everything on successfully implementing: {goal}""",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
|
||||
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
|
||||
verbose=getattr(self, 'verbose', True)
|
||||
)
|
||||
|
||||
@@ -2303,13 +2303,13 @@ def create_context_agent(llm: Optional[Union[str, Any]] = None, **kwargs) -> Con
|
||||
Factory function to create a ContextAgent following Context Engineering and PRD methodology.
|
||||
|
||||
Args:
|
||||
llm: Language model to use (e.g., "gpt-5-nano", "claude-3-haiku")
|
||||
llm: Language model to use (e.g., "gpt-4o-mini", "claude-3-haiku")
|
||||
**kwargs: Additional arguments to pass to ContextAgent constructor
|
||||
|
||||
Returns:
|
||||
ContextAgent: Configured ContextAgent for comprehensive context generation following PRD principles
|
||||
"""
|
||||
if llm is None:
|
||||
llm = "gpt-5-nano"
|
||||
llm = "gpt-4o-mini"
|
||||
|
||||
return ContextAgent(llm=llm, **kwargs)
|
||||
@@ -44,7 +44,7 @@ class RouterAgent(Agent):
|
||||
# Initialize model router
|
||||
self.model_router = model_router or ModelRouter()
|
||||
self.routing_strategy = routing_strategy
|
||||
self.fallback_model = fallback_model or os.getenv('OPENAI_MODEL_NAME', 'gpt-5-nano')
|
||||
self.fallback_model = fallback_model or os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
|
||||
|
||||
# Process models configuration
|
||||
self.available_models = self._process_models_config(models)
|
||||
|
||||
@@ -363,7 +363,7 @@ class Agents:
|
||||
self.variables = variables if variables else {}
|
||||
|
||||
# Check for manager_llm in environment variable if not provided
|
||||
self.manager_llm = manager_llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-5-nano')
|
||||
self.manager_llm = manager_llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
|
||||
|
||||
# Set logger level based on verbose
|
||||
if _verbose >= 5:
|
||||
|
||||
@@ -103,7 +103,7 @@ class AutoAgents(Agents):
|
||||
self.self_reflect = self_reflect
|
||||
self.max_reflect = max_reflect
|
||||
self.min_reflect = min_reflect
|
||||
self.llm = llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-5-nano')
|
||||
self.llm = llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
|
||||
self.function_calling_llm = function_calling_llm
|
||||
self.code_execution_mode = code_execution_mode
|
||||
self.embedder_config = embedder_config
|
||||
|
||||
@@ -44,12 +44,79 @@ class MemoryBackend(str, Enum):
|
||||
MONGODB = "mongodb"
|
||||
|
||||
|
||||
class LearnScope(str, Enum):
|
||||
"""Scope for learning data visibility.
|
||||
|
||||
PRIVATE: Learning data is private to this user/agent (default, safest)
|
||||
SHARED: Learning data is shared with all agents
|
||||
"""
|
||||
PRIVATE = "private" # Private to this user/agent
|
||||
SHARED = "shared" # Shared with all agents
|
||||
|
||||
|
||||
@dataclass
|
||||
class LearnConfig:
|
||||
"""
|
||||
Configuration for continuous learning within memory system.
|
||||
|
||||
Learning captures patterns, preferences, and insights from agent interactions
|
||||
to improve future responses. All learning data is stored within the memory system.
|
||||
|
||||
Usage:
|
||||
# Simple enable
|
||||
Agent(memory=MemoryConfig(learn=True))
|
||||
|
||||
# With specific capabilities
|
||||
Agent(memory=MemoryConfig(
|
||||
learn=LearnConfig(
|
||||
persona=True, # User preferences
|
||||
insights=True, # Observations
|
||||
patterns=True, # Reusable knowledge
|
||||
)
|
||||
))
|
||||
"""
|
||||
# Learning capabilities
|
||||
persona: bool = True # User preferences and profile
|
||||
insights: bool = True # Observations and learnings
|
||||
thread: bool = True # Session/conversation context
|
||||
patterns: bool = False # Reusable knowledge patterns
|
||||
decisions: bool = False # Decision logging
|
||||
feedback: bool = False # Outcome signals
|
||||
improvements: bool = False # Self-improvement proposals
|
||||
|
||||
# Scope configuration
|
||||
scope: Union[str, LearnScope] = LearnScope.PRIVATE
|
||||
|
||||
# Storage configuration
|
||||
store_path: Optional[str] = None # Custom storage path
|
||||
|
||||
# Maintenance settings
|
||||
auto_consolidate: bool = True # Auto-consolidate learnings
|
||||
retention_days: Optional[int] = None # Days to retain (None = forever)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"persona": self.persona,
|
||||
"insights": self.insights,
|
||||
"thread": self.thread,
|
||||
"patterns": self.patterns,
|
||||
"decisions": self.decisions,
|
||||
"feedback": self.feedback,
|
||||
"improvements": self.improvements,
|
||||
"scope": self.scope.value if isinstance(self.scope, LearnScope) else self.scope,
|
||||
"store_path": self.store_path,
|
||||
"auto_consolidate": self.auto_consolidate,
|
||||
"retention_days": self.retention_days,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryConfig:
|
||||
"""
|
||||
Configuration for agent memory and session management.
|
||||
|
||||
Consolidates: memory, auto_memory, claude_memory, user_id, session_id, db
|
||||
Consolidates: memory, auto_memory, claude_memory, user_id, session_id, db, learn
|
||||
|
||||
Usage:
|
||||
# Simple enable (uses FileMemory)
|
||||
@@ -58,12 +125,22 @@ class MemoryConfig:
|
||||
# With backend
|
||||
Agent(memory=MemoryConfig(backend="redis"))
|
||||
|
||||
# Full config
|
||||
# Full config with learning
|
||||
Agent(memory=MemoryConfig(
|
||||
backend="sqlite",
|
||||
user_id="user123",
|
||||
session_id="session456",
|
||||
auto_memory=True,
|
||||
learn=True, # Enable continuous learning
|
||||
))
|
||||
|
||||
# With detailed learn config
|
||||
Agent(memory=MemoryConfig(
|
||||
learn=LearnConfig(
|
||||
persona=True,
|
||||
insights=True,
|
||||
patterns=True,
|
||||
)
|
||||
))
|
||||
"""
|
||||
# Backend selection
|
||||
@@ -85,8 +162,17 @@ class MemoryConfig:
|
||||
# Memory configuration dict (for provider-specific settings)
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Continuous learning configuration
|
||||
learn: Optional[Union[bool, LearnConfig]] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
learn_dict = None
|
||||
if self.learn is True:
|
||||
learn_dict = LearnConfig().to_dict()
|
||||
elif isinstance(self.learn, LearnConfig):
|
||||
learn_dict = self.learn.to_dict()
|
||||
|
||||
return {
|
||||
"backend": self.backend.value if isinstance(self.backend, MemoryBackend) else self.backend,
|
||||
"user_id": self.user_id,
|
||||
@@ -94,6 +180,7 @@ class MemoryConfig:
|
||||
"auto_memory": self.auto_memory,
|
||||
"claude_memory": self.claude_memory,
|
||||
"config": self.config,
|
||||
"learn": learn_dict,
|
||||
}
|
||||
|
||||
|
||||
@@ -391,10 +478,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 +502,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 +520,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 +536,8 @@ class OutputConfig:
|
||||
"stream": self.stream,
|
||||
"metrics": self.metrics,
|
||||
"reasoning_steps": self.reasoning_steps,
|
||||
"actions_trace": self.actions_trace,
|
||||
"json_output": self.json_output,
|
||||
}
|
||||
|
||||
|
||||
@@ -857,9 +965,259 @@ SkillsParam = Union[List[str], SkillsConfig]
|
||||
AutonomyParam = Union[bool, Dict[str, Any], AutonomyConfig]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PRECEDENCE LADDER RESOLVERS
|
||||
# =============================================================================
|
||||
# Precedence: Instance > Config > Array > Dict > String > Bool > Default
|
||||
#
|
||||
# These resolvers normalize various input types into the canonical Config form.
|
||||
# This allows users to pass simplified inputs while maintaining type safety.
|
||||
|
||||
|
||||
def resolve_memory(value: MemoryParam) -> Optional[MemoryConfig]:
|
||||
"""
|
||||
Resolve memory= parameter following precedence ladder.
|
||||
|
||||
Precedence: Instance > Config > Dict > String > Bool > Default
|
||||
|
||||
Args:
|
||||
value: Memory parameter in any supported form
|
||||
|
||||
Returns:
|
||||
MemoryConfig if enabled, None if disabled
|
||||
|
||||
Examples:
|
||||
>>> resolve_memory(None) # Default: disabled
|
||||
None
|
||||
>>> resolve_memory(False) # Explicit disable
|
||||
None
|
||||
>>> resolve_memory(True) # Enable with defaults
|
||||
MemoryConfig(backend='file')
|
||||
>>> resolve_memory("redis") # String shorthand
|
||||
MemoryConfig(backend='redis')
|
||||
>>> resolve_memory({"backend": "sqlite", "user_id": "alice"}) # Dict
|
||||
MemoryConfig(backend='sqlite', user_id='alice')
|
||||
>>> resolve_memory(MemoryConfig(backend="postgres")) # Config passthrough
|
||||
MemoryConfig(backend='postgres')
|
||||
"""
|
||||
# Default: disabled
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
# Bool: False = disabled, True = defaults
|
||||
if value is False:
|
||||
return None
|
||||
if value is True:
|
||||
return MemoryConfig()
|
||||
|
||||
# String: backend shorthand
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
backend = MemoryBackend(value.lower())
|
||||
except ValueError:
|
||||
backend = value # Allow custom backend strings
|
||||
return MemoryConfig(backend=backend)
|
||||
|
||||
# Dict: expand to config
|
||||
if isinstance(value, dict):
|
||||
# Handle backend enum conversion
|
||||
backend = value.get("backend", MemoryBackend.FILE)
|
||||
if isinstance(backend, str):
|
||||
try:
|
||||
backend = MemoryBackend(backend.lower())
|
||||
except ValueError:
|
||||
pass # Keep as string for custom backends
|
||||
return MemoryConfig(
|
||||
backend=backend,
|
||||
user_id=value.get("user_id"),
|
||||
session_id=value.get("session_id"),
|
||||
auto_memory=value.get("auto_memory", False),
|
||||
claude_memory=value.get("claude_memory", False),
|
||||
db=value.get("db"),
|
||||
config=value.get("config"),
|
||||
)
|
||||
|
||||
# Config: passthrough
|
||||
if isinstance(value, MemoryConfig):
|
||||
return value
|
||||
|
||||
# Instance (MemoryManager): return as-is (caller handles)
|
||||
# This is the highest precedence - user provided a pre-configured instance
|
||||
return value
|
||||
|
||||
|
||||
def resolve_knowledge(value: KnowledgeParam) -> Optional[KnowledgeConfig]:
|
||||
"""
|
||||
Resolve knowledge= parameter following precedence ladder.
|
||||
|
||||
Precedence: Instance > Config > Array > Dict > String > Bool > Default
|
||||
|
||||
Args:
|
||||
value: Knowledge parameter in any supported form
|
||||
|
||||
Returns:
|
||||
KnowledgeConfig if enabled, None if disabled
|
||||
"""
|
||||
# Default: disabled
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
# Bool: False = disabled, True = defaults
|
||||
if value is False:
|
||||
return None
|
||||
if value is True:
|
||||
return KnowledgeConfig()
|
||||
|
||||
# String: single source
|
||||
if isinstance(value, str):
|
||||
return KnowledgeConfig(sources=[value])
|
||||
|
||||
# Array: list of sources
|
||||
if isinstance(value, list):
|
||||
return KnowledgeConfig(sources=value)
|
||||
|
||||
# Dict: expand to config
|
||||
if isinstance(value, dict):
|
||||
return KnowledgeConfig(
|
||||
sources=value.get("sources", []),
|
||||
embedder=value.get("embedder", "openai"),
|
||||
embedder_config=value.get("embedder_config"),
|
||||
chunking_strategy=value.get("chunking_strategy", ChunkingStrategy.SEMANTIC),
|
||||
chunk_size=value.get("chunk_size", 1000),
|
||||
chunk_overlap=value.get("chunk_overlap", 200),
|
||||
retrieval_k=value.get("retrieval_k", 5),
|
||||
retrieval_threshold=value.get("retrieval_threshold", 0.0),
|
||||
rerank=value.get("rerank", False),
|
||||
rerank_model=value.get("rerank_model"),
|
||||
auto_retrieve=value.get("auto_retrieve", True),
|
||||
)
|
||||
|
||||
# Config: passthrough
|
||||
if isinstance(value, KnowledgeConfig):
|
||||
return value
|
||||
|
||||
# Instance: return as-is
|
||||
return value
|
||||
|
||||
|
||||
def resolve_planning(value: PlanningParam) -> Optional[PlanningConfig]:
|
||||
"""Resolve planning= parameter following precedence ladder."""
|
||||
if value is None or value is False:
|
||||
return None
|
||||
if value is True:
|
||||
return PlanningConfig()
|
||||
if isinstance(value, dict):
|
||||
return PlanningConfig(**value)
|
||||
if isinstance(value, PlanningConfig):
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def resolve_reflection(value: ReflectionParam) -> Optional[ReflectionConfig]:
|
||||
"""Resolve reflection= parameter following precedence ladder."""
|
||||
if value is None or value is False:
|
||||
return None
|
||||
if value is True:
|
||||
return ReflectionConfig()
|
||||
if isinstance(value, dict):
|
||||
return ReflectionConfig(**value)
|
||||
if isinstance(value, ReflectionConfig):
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def resolve_guardrails(value: GuardrailParam) -> Optional[GuardrailConfig]:
|
||||
"""Resolve guardrails= parameter following precedence ladder."""
|
||||
if value is None or value is False:
|
||||
return None
|
||||
if value is True:
|
||||
return GuardrailConfig()
|
||||
if callable(value):
|
||||
return GuardrailConfig(validator=value)
|
||||
if isinstance(value, dict):
|
||||
return GuardrailConfig(**value)
|
||||
if isinstance(value, GuardrailConfig):
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def resolve_web(value: WebParam) -> Optional[WebConfig]:
|
||||
"""Resolve web= parameter following precedence ladder."""
|
||||
if value is None or value is False:
|
||||
return None
|
||||
if value is True:
|
||||
return WebConfig()
|
||||
if isinstance(value, dict):
|
||||
return WebConfig(**value)
|
||||
if isinstance(value, WebConfig):
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def resolve_output(value: OutputParam) -> Optional[OutputConfig]:
|
||||
"""Resolve output= parameter following precedence ladder."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
preset = OutputPreset(value.lower())
|
||||
return OutputConfig.from_preset(preset)
|
||||
except ValueError:
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
return OutputConfig(**value)
|
||||
if isinstance(value, OutputConfig):
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def resolve_execution(value: ExecutionParam) -> Optional[ExecutionConfig]:
|
||||
"""Resolve execution= parameter following precedence ladder."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
preset = ExecutionPreset(value.lower())
|
||||
return ExecutionConfig.from_preset(preset)
|
||||
except ValueError:
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
return ExecutionConfig(**value)
|
||||
if isinstance(value, ExecutionConfig):
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def resolve_caching(value: CachingParam) -> Optional[CachingConfig]:
|
||||
"""Resolve caching= parameter following precedence ladder."""
|
||||
if value is None or value is False:
|
||||
return None
|
||||
if value is True:
|
||||
return CachingConfig()
|
||||
if isinstance(value, dict):
|
||||
return CachingConfig(**value)
|
||||
if isinstance(value, CachingConfig):
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def resolve_autonomy(value: AutonomyParam) -> Optional[AutonomyConfig]:
|
||||
"""Resolve autonomy= parameter following precedence ladder."""
|
||||
if value is None or value is False:
|
||||
return None
|
||||
if value is True:
|
||||
return AutonomyConfig()
|
||||
if isinstance(value, dict):
|
||||
return AutonomyConfig(**value)
|
||||
if isinstance(value, AutonomyConfig):
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Enums
|
||||
"MemoryBackend",
|
||||
"LearnScope",
|
||||
"ChunkingStrategy",
|
||||
"GuardrailAction",
|
||||
"WebSearchProvider",
|
||||
@@ -867,6 +1225,7 @@ __all__ = [
|
||||
"ExecutionPreset",
|
||||
"AutonomyLevel",
|
||||
# Config classes (Agent)
|
||||
"LearnConfig",
|
||||
"MemoryConfig",
|
||||
"KnowledgeConfig",
|
||||
"PlanningConfig",
|
||||
@@ -900,4 +1259,15 @@ __all__ = [
|
||||
"HooksParam",
|
||||
"SkillsParam",
|
||||
"AutonomyParam",
|
||||
# Precedence ladder resolvers
|
||||
"resolve_memory",
|
||||
"resolve_knowledge",
|
||||
"resolve_planning",
|
||||
"resolve_reflection",
|
||||
"resolve_guardrails",
|
||||
"resolve_web",
|
||||
"resolve_output",
|
||||
"resolve_execution",
|
||||
"resolve_caching",
|
||||
"resolve_autonomy",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -157,6 +157,16 @@ __all__ = [
|
||||
"FastContextResult",
|
||||
"FileMatch",
|
||||
"LineRange",
|
||||
# Artifacts (Dynamic Context Discovery)
|
||||
"ArtifactRef",
|
||||
"ArtifactMetadata",
|
||||
"GrepMatch",
|
||||
"QueueConfig",
|
||||
"ArtifactStoreProtocol",
|
||||
"HistoryStoreProtocol",
|
||||
"TerminalLoggerProtocol",
|
||||
"compute_checksum",
|
||||
"generate_summary",
|
||||
]
|
||||
|
||||
|
||||
@@ -252,4 +262,11 @@ def __getattr__(name: str):
|
||||
from praisonaiagents.context.fast.result import LineRange
|
||||
return LineRange
|
||||
|
||||
# Artifacts (Dynamic Context Discovery)
|
||||
if name in ("ArtifactRef", "ArtifactMetadata", "GrepMatch", "QueueConfig",
|
||||
"ArtifactStoreProtocol", "HistoryStoreProtocol", "TerminalLoggerProtocol",
|
||||
"compute_checksum", "generate_summary"):
|
||||
from . import artifacts
|
||||
return getattr(artifacts, name)
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,580 @@
|
||||
"""
|
||||
Artifact Types and Protocols for Dynamic Context Discovery.
|
||||
|
||||
This module provides lightweight types and protocols for artifact-based
|
||||
context management. Implementations live in the wrapper (praisonai).
|
||||
|
||||
Zero Performance Impact:
|
||||
- Pure dataclasses and protocols
|
||||
- No heavy imports
|
||||
- No I/O at import time
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.context.artifacts import ArtifactRef, ArtifactStoreProtocol
|
||||
|
||||
# Create a reference to a stored artifact
|
||||
ref = ArtifactRef(
|
||||
path="/path/to/artifact.json",
|
||||
summary="API response with 50,000 records",
|
||||
size_bytes=1_200_000,
|
||||
mime_type="application/json",
|
||||
)
|
||||
|
||||
# Implement the protocol in wrapper
|
||||
class FileSystemArtifactStore:
|
||||
def store(self, content, metadata): ...
|
||||
def load(self, ref): ...
|
||||
"""
|
||||
|
||||
import time
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Protocol, Union, runtime_checkable
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArtifactRef:
|
||||
"""
|
||||
Reference to a stored artifact.
|
||||
|
||||
Used to replace large tool outputs in context with a lightweight pointer.
|
||||
The agent can then use tail/grep/load operations to access content on demand.
|
||||
|
||||
Attributes:
|
||||
path: Absolute path to the artifact file
|
||||
summary: Brief description or first ~200 chars of content
|
||||
size_bytes: Original content size in bytes
|
||||
mime_type: Content type (e.g., "application/json", "text/plain")
|
||||
checksum: SHA256 hash for integrity verification
|
||||
created_at: Unix timestamp when artifact was created
|
||||
artifact_id: Unique identifier for the artifact
|
||||
agent_id: ID of the agent that created this artifact
|
||||
run_id: ID of the run/session this artifact belongs to
|
||||
tool_name: Name of the tool that produced this output (if applicable)
|
||||
turn_id: Conversation turn number when artifact was created
|
||||
"""
|
||||
path: str
|
||||
summary: str
|
||||
size_bytes: int
|
||||
mime_type: str = "application/octet-stream"
|
||||
checksum: str = ""
|
||||
created_at: float = field(default_factory=time.time)
|
||||
artifact_id: str = ""
|
||||
agent_id: str = ""
|
||||
run_id: str = ""
|
||||
tool_name: Optional[str] = None
|
||||
turn_id: int = 0
|
||||
|
||||
def to_inline(self) -> str:
|
||||
"""
|
||||
Generate inline representation for context.
|
||||
|
||||
Returns a compact string that can be included in the conversation
|
||||
context, allowing the agent to reference the artifact.
|
||||
"""
|
||||
size_str = self._format_size(self.size_bytes)
|
||||
tool_info = f" from {self.tool_name}" if self.tool_name else ""
|
||||
return f"[Artifact{tool_info}: {self.path} ({size_str}) - {self.summary}]"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"path": self.path,
|
||||
"summary": self.summary,
|
||||
"size_bytes": self.size_bytes,
|
||||
"mime_type": self.mime_type,
|
||||
"checksum": self.checksum,
|
||||
"created_at": self.created_at,
|
||||
"artifact_id": self.artifact_id,
|
||||
"agent_id": self.agent_id,
|
||||
"run_id": self.run_id,
|
||||
"tool_name": self.tool_name,
|
||||
"turn_id": self.turn_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ArtifactRef":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
path=data.get("path", ""),
|
||||
summary=data.get("summary", ""),
|
||||
size_bytes=data.get("size_bytes", 0),
|
||||
mime_type=data.get("mime_type", "application/octet-stream"),
|
||||
checksum=data.get("checksum", ""),
|
||||
created_at=data.get("created_at", time.time()),
|
||||
artifact_id=data.get("artifact_id", ""),
|
||||
agent_id=data.get("agent_id", ""),
|
||||
run_id=data.get("run_id", ""),
|
||||
tool_name=data.get("tool_name"),
|
||||
turn_id=data.get("turn_id", 0),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_size(size_bytes: int) -> str:
|
||||
"""Format size in human-readable form."""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.1f} KB"
|
||||
elif size_bytes < 1024 * 1024 * 1024:
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArtifactMetadata:
|
||||
"""
|
||||
Metadata for artifact creation and tracking.
|
||||
|
||||
Passed to ArtifactStore.store() to provide context about the artifact.
|
||||
"""
|
||||
agent_id: str = ""
|
||||
run_id: str = ""
|
||||
tool_name: Optional[str] = None
|
||||
turn_id: int = 0
|
||||
content_type: str = "auto" # auto, json, text, binary
|
||||
tags: List[str] = field(default_factory=list)
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"agent_id": self.agent_id,
|
||||
"run_id": self.run_id,
|
||||
"tool_name": self.tool_name,
|
||||
"turn_id": self.turn_id,
|
||||
"content_type": self.content_type,
|
||||
"tags": self.tags,
|
||||
"extra": self.extra,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GrepMatch:
|
||||
"""A single match from grep operation."""
|
||||
line_number: int
|
||||
line_content: str
|
||||
context_before: List[str] = field(default_factory=list)
|
||||
context_after: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"line_number": self.line_number,
|
||||
"line_content": self.line_content,
|
||||
"context_before": self.context_before,
|
||||
"context_after": self.context_after,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ArtifactStoreProtocol(Protocol):
|
||||
"""
|
||||
Protocol for artifact storage backends.
|
||||
|
||||
Implementations should handle:
|
||||
- Storing content to persistent storage
|
||||
- Loading content back
|
||||
- Efficient tail/grep operations for large files
|
||||
- Checksum verification
|
||||
- Secret redaction (if enabled)
|
||||
|
||||
Example implementation in wrapper:
|
||||
class FileSystemArtifactStore:
|
||||
def store(self, content, metadata):
|
||||
# Write to ~/.praison/runs/{run_id}/artifacts/
|
||||
...
|
||||
"""
|
||||
|
||||
def store(
|
||||
self,
|
||||
content: Any,
|
||||
metadata: ArtifactMetadata,
|
||||
) -> ArtifactRef:
|
||||
"""
|
||||
Store content as an artifact.
|
||||
|
||||
Args:
|
||||
content: The content to store (will be serialized)
|
||||
metadata: Metadata about the artifact
|
||||
|
||||
Returns:
|
||||
ArtifactRef pointing to the stored artifact
|
||||
"""
|
||||
...
|
||||
|
||||
def load(self, ref: ArtifactRef) -> Any:
|
||||
"""
|
||||
Load full content from an artifact.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
|
||||
Returns:
|
||||
The deserialized content
|
||||
"""
|
||||
...
|
||||
|
||||
def tail(self, ref: ArtifactRef, lines: int = 50) -> str:
|
||||
"""
|
||||
Get the last N lines of an artifact.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
lines: Number of lines to return
|
||||
|
||||
Returns:
|
||||
String containing the last N lines
|
||||
"""
|
||||
...
|
||||
|
||||
def head(self, ref: ArtifactRef, lines: int = 50) -> str:
|
||||
"""
|
||||
Get the first N lines of an artifact.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
lines: Number of lines to return
|
||||
|
||||
Returns:
|
||||
String containing the first N lines
|
||||
"""
|
||||
...
|
||||
|
||||
def grep(
|
||||
self,
|
||||
ref: ArtifactRef,
|
||||
pattern: str,
|
||||
context_lines: int = 2,
|
||||
max_matches: int = 50,
|
||||
) -> List[GrepMatch]:
|
||||
"""
|
||||
Search for pattern in artifact content.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
pattern: Regex pattern to search for
|
||||
context_lines: Number of context lines before/after match
|
||||
max_matches: Maximum number of matches to return
|
||||
|
||||
Returns:
|
||||
List of GrepMatch objects
|
||||
"""
|
||||
...
|
||||
|
||||
def chunk(
|
||||
self,
|
||||
ref: ArtifactRef,
|
||||
start_line: int = 1,
|
||||
end_line: Optional[int] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get a chunk of lines from an artifact.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
start_line: Starting line number (1-indexed)
|
||||
end_line: Ending line number (inclusive), None for end of file
|
||||
|
||||
Returns:
|
||||
String containing the requested lines
|
||||
"""
|
||||
...
|
||||
|
||||
def delete(self, ref: ArtifactRef) -> bool:
|
||||
"""
|
||||
Delete an artifact.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
"""
|
||||
...
|
||||
|
||||
def list_artifacts(
|
||||
self,
|
||||
run_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
tool_name: Optional[str] = None,
|
||||
) -> List[ArtifactRef]:
|
||||
"""
|
||||
List artifacts matching filters.
|
||||
|
||||
Args:
|
||||
run_id: Filter by run ID
|
||||
agent_id: Filter by agent ID
|
||||
tool_name: Filter by tool name
|
||||
|
||||
Returns:
|
||||
List of matching ArtifactRef objects
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class HistoryStoreProtocol(Protocol):
|
||||
"""
|
||||
Protocol for conversation history storage.
|
||||
|
||||
Enables loss recovery during summarization by persisting
|
||||
full conversation history to artifacts.
|
||||
"""
|
||||
|
||||
def append(
|
||||
self,
|
||||
message: Dict[str, Any],
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
) -> None:
|
||||
"""
|
||||
Append a message to history.
|
||||
|
||||
Args:
|
||||
message: The message dict (role, content, etc.)
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
"""
|
||||
...
|
||||
|
||||
def get_ref(self, agent_id: str, run_id: str) -> Optional[ArtifactRef]:
|
||||
"""
|
||||
Get artifact reference for history file.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
|
||||
Returns:
|
||||
ArtifactRef if history exists, None otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
max_results: int = 20,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search history for matching messages.
|
||||
|
||||
Args:
|
||||
query: Search query (supports regex)
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
max_results: Maximum results to return
|
||||
|
||||
Returns:
|
||||
List of matching message dicts
|
||||
"""
|
||||
...
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
start_turn: int = 0,
|
||||
end_turn: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get messages from history.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
start_turn: Starting turn number
|
||||
end_turn: Ending turn number (None for all)
|
||||
|
||||
Returns:
|
||||
List of message dicts
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TerminalLoggerProtocol(Protocol):
|
||||
"""
|
||||
Protocol for terminal session logging.
|
||||
|
||||
Captures shell command outputs to artifacts for later retrieval.
|
||||
"""
|
||||
|
||||
def log_command(
|
||||
self,
|
||||
command: str,
|
||||
output: str,
|
||||
exit_code: int,
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
) -> ArtifactRef:
|
||||
"""
|
||||
Log a command execution.
|
||||
|
||||
Args:
|
||||
command: The command that was executed
|
||||
output: Combined stdout/stderr output
|
||||
exit_code: Command exit code
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
|
||||
Returns:
|
||||
ArtifactRef to the logged output
|
||||
"""
|
||||
...
|
||||
|
||||
def get_session_ref(self, agent_id: str, run_id: str) -> Optional[ArtifactRef]:
|
||||
"""
|
||||
Get artifact reference for terminal session log.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
|
||||
Returns:
|
||||
ArtifactRef if session exists, None otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
def tail_session(
|
||||
self,
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
lines: int = 100,
|
||||
) -> str:
|
||||
"""
|
||||
Get last N lines from terminal session.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
lines: Number of lines to return
|
||||
|
||||
Returns:
|
||||
String containing the last N lines
|
||||
"""
|
||||
...
|
||||
|
||||
def grep_session(
|
||||
self,
|
||||
pattern: str,
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
max_matches: int = 50,
|
||||
) -> List[GrepMatch]:
|
||||
"""
|
||||
Search terminal session for pattern.
|
||||
|
||||
Args:
|
||||
pattern: Regex pattern to search for
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
max_matches: Maximum matches to return
|
||||
|
||||
Returns:
|
||||
List of GrepMatch objects
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueueConfig:
|
||||
"""
|
||||
Configuration for tool output queuing.
|
||||
|
||||
Controls when and how large tool outputs are queued to artifacts
|
||||
instead of being included inline in the context.
|
||||
"""
|
||||
enabled: bool = True
|
||||
inline_max_bytes: int = 32 * 1024 # 32KB default
|
||||
redact_secrets: bool = True
|
||||
summary_max_chars: int = 200
|
||||
checksum_algorithm: str = "sha256"
|
||||
|
||||
# Secret patterns to redact (regex)
|
||||
secret_patterns: List[str] = field(default_factory=lambda: [
|
||||
r'(?i)(api[_-]?key|apikey)["\']?\s*[:=]\s*["\']?[\w\-]+',
|
||||
r'(?i)(secret|password|passwd|pwd)["\']?\s*[:=]\s*["\']?[\w\-]+',
|
||||
r'(?i)(token|bearer)["\']?\s*[:=]\s*["\']?[\w\-]+',
|
||||
r'(?i)(auth|authorization)["\']?\s*[:=]\s*["\']?[\w\-]+',
|
||||
r'sk-[a-zA-Z0-9]{20,}', # OpenAI keys
|
||||
r'ghp_[a-zA-Z0-9]{36}', # GitHub tokens
|
||||
r'gho_[a-zA-Z0-9]{36}', # GitHub OAuth
|
||||
r'xox[baprs]-[a-zA-Z0-9\-]+', # Slack tokens
|
||||
])
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"inline_max_bytes": self.inline_max_bytes,
|
||||
"redact_secrets": self.redact_secrets,
|
||||
"summary_max_chars": self.summary_max_chars,
|
||||
"checksum_algorithm": self.checksum_algorithm,
|
||||
"secret_patterns": self.secret_patterns,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "QueueConfig":
|
||||
return cls(
|
||||
enabled=data.get("enabled", True),
|
||||
inline_max_bytes=data.get("inline_max_bytes", 32 * 1024),
|
||||
redact_secrets=data.get("redact_secrets", True),
|
||||
summary_max_chars=data.get("summary_max_chars", 200),
|
||||
checksum_algorithm=data.get("checksum_algorithm", "sha256"),
|
||||
secret_patterns=data.get("secret_patterns", cls().secret_patterns),
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def compute_checksum(content: Union[str, bytes], algorithm: str = "sha256") -> str:
|
||||
"""
|
||||
Compute checksum of content.
|
||||
|
||||
Args:
|
||||
content: String or bytes to hash
|
||||
algorithm: Hash algorithm (sha256, md5, etc.)
|
||||
|
||||
Returns:
|
||||
Hex digest of the hash
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
content = content.encode("utf-8")
|
||||
|
||||
hasher = hashlib.new(algorithm)
|
||||
hasher.update(content)
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
def generate_summary(content: Any, max_chars: int = 200) -> str:
|
||||
"""
|
||||
Generate a brief summary of content.
|
||||
|
||||
Args:
|
||||
content: Content to summarize
|
||||
max_chars: Maximum characters in summary
|
||||
|
||||
Returns:
|
||||
Brief summary string
|
||||
"""
|
||||
if isinstance(content, (dict, list)):
|
||||
# For structured data, describe the structure
|
||||
if isinstance(content, dict):
|
||||
keys = list(content.keys())[:5]
|
||||
key_str = ", ".join(str(k) for k in keys)
|
||||
if len(content) > 5:
|
||||
key_str += f", ... ({len(content)} total keys)"
|
||||
return f"Dict with keys: {key_str}"[:max_chars]
|
||||
else:
|
||||
if len(content) > 0:
|
||||
first_type = type(content[0]).__name__
|
||||
return f"List of {len(content)} {first_type} items"[:max_chars]
|
||||
return "Empty list"
|
||||
|
||||
# For strings, take first N chars
|
||||
text = str(content)
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
return text[:max_chars - 3] + "..."
|
||||
@@ -68,8 +68,8 @@ class LLM:
|
||||
MODEL_WINDOWS = {
|
||||
# OpenAI
|
||||
"gpt-4": 6144, # 8,192 actual
|
||||
"gpt-5-nano": 96000, # 128,000 actual
|
||||
"gpt-5-nano": 96000, # 128,000 actual
|
||||
"gpt-4o-mini": 96000, # 128,000 actual
|
||||
"gpt-4o-mini": 96000, # 128,000 actual
|
||||
"gpt-4-turbo": 96000, # 128,000 actual
|
||||
"o1-preview": 96000, # 128,000 actual
|
||||
"o1-mini": 96000, # 128,000 actual
|
||||
|
||||
@@ -51,7 +51,7 @@ class ModelRouter:
|
||||
DEFAULT_MODELS = [
|
||||
# Lightweight/cheap models for simple tasks
|
||||
ModelProfile(
|
||||
name="gpt-5-nano",
|
||||
name="gpt-4o-mini",
|
||||
provider="openai",
|
||||
complexity_range=(TaskComplexity.SIMPLE, TaskComplexity.MODERATE),
|
||||
cost_per_1k_tokens=0.00075, # Average of $0.00015 input, $0.0006 output
|
||||
@@ -80,7 +80,7 @@ class ModelRouter:
|
||||
|
||||
# Mid-tier models for moderate complexity
|
||||
ModelProfile(
|
||||
name="gpt-5-nano",
|
||||
name="gpt-4o-mini",
|
||||
provider="openai",
|
||||
complexity_range=(TaskComplexity.MODERATE, TaskComplexity.COMPLEX),
|
||||
cost_per_1k_tokens=0.0075, # Average of $0.0025 input, $0.01 output
|
||||
@@ -145,7 +145,7 @@ class ModelRouter:
|
||||
preferred_providers: List of preferred providers in order
|
||||
"""
|
||||
self.models = models or self.DEFAULT_MODELS
|
||||
self.default_model = default_model or os.getenv('OPENAI_MODEL_NAME', 'gpt-5-nano')
|
||||
self.default_model = default_model or os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
|
||||
self.cost_threshold = cost_threshold
|
||||
self.preferred_providers = preferred_providers or []
|
||||
|
||||
|
||||
@@ -649,6 +649,7 @@ class OpenAIClient:
|
||||
chunks = []
|
||||
first_token_emitted = False
|
||||
last_content_time = None
|
||||
ttft_logged = False
|
||||
|
||||
# If display function provided, use Live display
|
||||
if display_fn:
|
||||
@@ -669,6 +670,12 @@ class OpenAIClient:
|
||||
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(
|
||||
@@ -715,11 +722,17 @@ class OpenAIClient:
|
||||
for chunk in response_stream:
|
||||
chunks.append(chunk)
|
||||
|
||||
if _emit and chunk.choices and chunk.choices[0].delta.content:
|
||||
if 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:
|
||||
# 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,
|
||||
@@ -975,7 +988,7 @@ class OpenAIClient:
|
||||
def create_completion(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
model: str = "gpt-5-nano",
|
||||
model: str = "gpt-4o-mini",
|
||||
temperature: float = 1.0,
|
||||
stream: bool = False,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
@@ -1020,7 +1033,7 @@ class OpenAIClient:
|
||||
async def acreate_completion(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
model: str = "gpt-5-nano",
|
||||
model: str = "gpt-4o-mini",
|
||||
temperature: float = 1.0,
|
||||
stream: bool = False,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
@@ -1065,7 +1078,7 @@ class OpenAIClient:
|
||||
def chat_completion_with_tools(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
model: str = "gpt-5-nano",
|
||||
model: str = "gpt-4o-mini",
|
||||
temperature: float = 1.0,
|
||||
tools: Optional[List[Any]] = None,
|
||||
execute_tool_fn: Optional[Callable] = None,
|
||||
@@ -1130,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,
|
||||
@@ -1148,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))
|
||||
|
||||
@@ -1246,7 +1266,7 @@ class OpenAIClient:
|
||||
async def achat_completion_with_tools(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
model: str = "gpt-5-nano",
|
||||
model: str = "gpt-4o-mini",
|
||||
temperature: float = 1.0,
|
||||
tools: Optional[List[Any]] = None,
|
||||
execute_tool_fn: Optional[Callable] = None,
|
||||
@@ -1430,7 +1450,7 @@ class OpenAIClient:
|
||||
def chat_completion_with_tools_stream(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
model: str = "gpt-5-nano",
|
||||
model: str = "gpt-4o-mini",
|
||||
temperature: float = 1.0,
|
||||
tools: Optional[List[Any]] = None,
|
||||
execute_tool_fn: Optional[Callable] = None,
|
||||
@@ -1582,7 +1602,7 @@ class OpenAIClient:
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
response_format: BaseModel,
|
||||
model: str = "gpt-5-nano",
|
||||
model: str = "gpt-4o-mini",
|
||||
temperature: float = 1.0,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
@@ -1616,7 +1636,7 @@ class OpenAIClient:
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
response_format: BaseModel,
|
||||
model: str = "gpt-5-nano",
|
||||
model: str = "gpt-4o-mini",
|
||||
temperature: float = 1.0,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
|
||||
@@ -162,7 +162,7 @@ class MCP:
|
||||
# Method 1: Using command and args separately
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant...",
|
||||
llm="gpt-5-nano",
|
||||
llm="gpt-4o-mini",
|
||||
tools=MCP(
|
||||
command="/path/to/python",
|
||||
args=["/path/to/app.py"]
|
||||
@@ -172,14 +172,14 @@ class MCP:
|
||||
# Method 2: Using a single command string
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant...",
|
||||
llm="gpt-5-nano",
|
||||
llm="gpt-4o-mini",
|
||||
tools=MCP("/path/to/python /path/to/app.py")
|
||||
)
|
||||
|
||||
# Method 3: Using an SSE endpoint
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant...",
|
||||
llm="gpt-5-nano",
|
||||
llm="gpt-4o-mini",
|
||||
tools=MCP("http://localhost:8080/sse")
|
||||
)
|
||||
|
||||
@@ -548,7 +548,7 @@ class MCP:
|
||||
"""Convert the MCP tool to an OpenAI-compatible tool definition.
|
||||
|
||||
This method is specifically invoked by the Agent class when using
|
||||
provider/model format (e.g., "openai/gpt-5-nano").
|
||||
provider/model format (e.g., "openai/gpt-4o-mini").
|
||||
|
||||
Returns:
|
||||
dict or list: OpenAI-compatible tool definition(s)
|
||||
|
||||
@@ -110,6 +110,31 @@ def __getattr__(name):
|
||||
if name == "create_hooks_manager":
|
||||
from .hooks import create_hooks_manager
|
||||
return create_hooks_manager
|
||||
# Learn module
|
||||
if name == "LearnManager":
|
||||
from .learn import LearnManager
|
||||
return LearnManager
|
||||
if name == "PersonaStore":
|
||||
from .learn import PersonaStore
|
||||
return PersonaStore
|
||||
if name == "InsightStore":
|
||||
from .learn import InsightStore
|
||||
return InsightStore
|
||||
if name == "ThreadStore":
|
||||
from .learn import ThreadStore
|
||||
return ThreadStore
|
||||
if name == "PatternStore":
|
||||
from .learn import PatternStore
|
||||
return PatternStore
|
||||
if name == "DecisionStore":
|
||||
from .learn import DecisionStore
|
||||
return DecisionStore
|
||||
if name == "FeedbackStore":
|
||||
from .learn import FeedbackStore
|
||||
return FeedbackStore
|
||||
if name == "ImprovementStore":
|
||||
from .learn import ImprovementStore
|
||||
return ImprovementStore
|
||||
# DocsManager and MCPConfigManager are already imported at module level
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -153,4 +178,13 @@ __all__ = [
|
||||
# MCP config management
|
||||
"MCPConfigManager",
|
||||
"MCPConfig",
|
||||
# Learn module
|
||||
"LearnManager",
|
||||
"PersonaStore",
|
||||
"InsightStore",
|
||||
"ThreadStore",
|
||||
"PatternStore",
|
||||
"DecisionStore",
|
||||
"FeedbackStore",
|
||||
"ImprovementStore",
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Learn Module - Continuous Learning within Memory System.
|
||||
|
||||
Provides learning capabilities for agents to capture patterns, preferences,
|
||||
and insights from interactions to improve future responses.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import Agent, MemoryConfig, LearnConfig
|
||||
|
||||
# Simple enable
|
||||
agent = Agent(memory=MemoryConfig(learn=True))
|
||||
|
||||
# With specific capabilities
|
||||
agent = Agent(memory=MemoryConfig(
|
||||
learn=LearnConfig(
|
||||
persona=True,
|
||||
insights=True,
|
||||
patterns=True,
|
||||
)
|
||||
))
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"LearnManager",
|
||||
"PersonaStore",
|
||||
"InsightStore",
|
||||
"ThreadStore",
|
||||
"PatternStore",
|
||||
"DecisionStore",
|
||||
"FeedbackStore",
|
||||
"ImprovementStore",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy loading for learn module components."""
|
||||
if name == "LearnManager":
|
||||
from .manager import LearnManager
|
||||
return LearnManager
|
||||
if name == "PersonaStore":
|
||||
from .stores import PersonaStore
|
||||
return PersonaStore
|
||||
if name == "InsightStore":
|
||||
from .stores import InsightStore
|
||||
return InsightStore
|
||||
if name == "ThreadStore":
|
||||
from .stores import ThreadStore
|
||||
return ThreadStore
|
||||
if name == "PatternStore":
|
||||
from .stores import PatternStore
|
||||
return PatternStore
|
||||
if name == "DecisionStore":
|
||||
from .stores import DecisionStore
|
||||
return DecisionStore
|
||||
if name == "FeedbackStore":
|
||||
from .stores import FeedbackStore
|
||||
return FeedbackStore
|
||||
if name == "ImprovementStore":
|
||||
from .stores import ImprovementStore
|
||||
return ImprovementStore
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
LearnManager - Central manager for continuous learning capabilities.
|
||||
|
||||
Coordinates all learning stores and provides a unified interface for
|
||||
capturing and retrieving learned information.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ...config.feature_configs import LearnConfig, LearnScope
|
||||
from .stores import (
|
||||
PersonaStore,
|
||||
InsightStore,
|
||||
ThreadStore,
|
||||
PatternStore,
|
||||
DecisionStore,
|
||||
FeedbackStore,
|
||||
ImprovementStore,
|
||||
LearnEntry,
|
||||
)
|
||||
|
||||
|
||||
class LearnManager:
|
||||
"""
|
||||
Central manager for continuous learning within the memory system.
|
||||
|
||||
Coordinates all learning stores based on LearnConfig settings.
|
||||
|
||||
Usage:
|
||||
config = LearnConfig(persona=True, insights=True)
|
||||
manager = LearnManager(config, user_id="alice")
|
||||
|
||||
# Capture learning
|
||||
manager.capture_persona("User prefers concise responses")
|
||||
manager.capture_insight("User works in data science")
|
||||
|
||||
# Retrieve learnings
|
||||
context = manager.get_learning_context()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[LearnConfig] = None,
|
||||
user_id: Optional[str] = None,
|
||||
store_path: Optional[str] = None,
|
||||
):
|
||||
self.config = config or LearnConfig()
|
||||
self.user_id = user_id or "default"
|
||||
self.store_path = store_path or self.config.store_path
|
||||
|
||||
scope = self.config.scope
|
||||
if isinstance(scope, LearnScope):
|
||||
scope = scope.value
|
||||
|
||||
self._stores: Dict[str, Any] = {}
|
||||
|
||||
if self.config.persona:
|
||||
self._stores["persona"] = PersonaStore(
|
||||
store_path=self._get_store_path("persona"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
if self.config.insights:
|
||||
self._stores["insights"] = InsightStore(
|
||||
store_path=self._get_store_path("insights"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
if self.config.thread:
|
||||
self._stores["threads"] = ThreadStore(
|
||||
store_path=self._get_store_path("threads"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
if self.config.patterns:
|
||||
self._stores["patterns"] = PatternStore(
|
||||
store_path=self._get_store_path("patterns"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
if self.config.decisions:
|
||||
self._stores["decisions"] = DecisionStore(
|
||||
store_path=self._get_store_path("decisions"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
if self.config.feedback:
|
||||
self._stores["feedback"] = FeedbackStore(
|
||||
store_path=self._get_store_path("feedback"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
if self.config.improvements:
|
||||
self._stores["improvements"] = ImprovementStore(
|
||||
store_path=self._get_store_path("improvements"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
def _get_store_path(self, store_name: str) -> Optional[str]:
|
||||
"""Get path for a specific store."""
|
||||
if self.store_path:
|
||||
return f"{self.store_path}/{store_name}.json"
|
||||
return None
|
||||
|
||||
def capture_persona(self, content: str, category: str = "general") -> Optional[LearnEntry]:
|
||||
"""Capture user preference or profile information."""
|
||||
if "persona" in self._stores:
|
||||
return self._stores["persona"].add_preference(content, category)
|
||||
return None
|
||||
|
||||
def capture_insight(self, content: str, source: str = "interaction") -> Optional[LearnEntry]:
|
||||
"""Capture an observation or learning."""
|
||||
if "insights" in self._stores:
|
||||
return self._stores["insights"].add_insight(content, source)
|
||||
return None
|
||||
|
||||
def capture_thread(self, summary: str, thread_id: str) -> Optional[LearnEntry]:
|
||||
"""Capture thread/session context."""
|
||||
if "threads" in self._stores:
|
||||
return self._stores["threads"].add_thread_summary(summary, thread_id)
|
||||
return None
|
||||
|
||||
def capture_pattern(self, pattern: str, pattern_type: str = "general") -> Optional[LearnEntry]:
|
||||
"""Capture a reusable knowledge pattern."""
|
||||
if "patterns" in self._stores:
|
||||
return self._stores["patterns"].add_pattern(pattern, pattern_type)
|
||||
return None
|
||||
|
||||
def capture_decision(
|
||||
self,
|
||||
decision: str,
|
||||
reasoning: str = "",
|
||||
outcome: str = "",
|
||||
) -> Optional[LearnEntry]:
|
||||
"""Capture a decision record."""
|
||||
if "decisions" in self._stores:
|
||||
return self._stores["decisions"].add_decision(decision, reasoning, outcome)
|
||||
return None
|
||||
|
||||
def capture_feedback(
|
||||
self,
|
||||
feedback: str,
|
||||
rating: Optional[int] = None,
|
||||
source: str = "user",
|
||||
) -> Optional[LearnEntry]:
|
||||
"""Capture feedback."""
|
||||
if "feedback" in self._stores:
|
||||
return self._stores["feedback"].add_feedback(feedback, rating, source)
|
||||
return None
|
||||
|
||||
def capture_improvement(
|
||||
self,
|
||||
proposal: str,
|
||||
area: str = "general",
|
||||
priority: str = "medium",
|
||||
) -> Optional[LearnEntry]:
|
||||
"""Capture an improvement proposal."""
|
||||
if "improvements" in self._stores:
|
||||
return self._stores["improvements"].add_improvement(proposal, area, priority)
|
||||
return None
|
||||
|
||||
def get_learning_context(self, limit_per_store: int = 5) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""
|
||||
Get learning context from all enabled stores.
|
||||
|
||||
Returns a dictionary with entries from each store, suitable for
|
||||
injection into agent context.
|
||||
"""
|
||||
context = {}
|
||||
for store_name, store in self._stores.items():
|
||||
entries = store.list_all(limit=limit_per_store)
|
||||
context[store_name] = [e.to_dict() for e in entries]
|
||||
return context
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Search across all enabled stores."""
|
||||
results = {}
|
||||
for store_name, store in self._stores.items():
|
||||
entries = store.search(query, limit=limit)
|
||||
if entries:
|
||||
results[store_name] = [e.to_dict() for e in entries]
|
||||
return results
|
||||
|
||||
def get_persona_context(self, limit: int = 10) -> List[str]:
|
||||
"""Get persona entries as context strings."""
|
||||
if "persona" not in self._stores:
|
||||
return []
|
||||
entries = self._stores["persona"].list_all(limit=limit)
|
||||
return [e.content for e in entries]
|
||||
|
||||
def get_insights_context(self, limit: int = 10) -> List[str]:
|
||||
"""Get insight entries as context strings."""
|
||||
if "insights" not in self._stores:
|
||||
return []
|
||||
entries = self._stores["insights"].list_all(limit=limit)
|
||||
return [e.content for e in entries]
|
||||
|
||||
def clear_all(self) -> Dict[str, int]:
|
||||
"""Clear all stores."""
|
||||
cleared = {}
|
||||
for store_name, store in self._stores.items():
|
||||
cleared[store_name] = store.clear()
|
||||
return cleared
|
||||
|
||||
def get_stats(self) -> Dict[str, int]:
|
||||
"""Get entry counts for all stores."""
|
||||
return {
|
||||
store_name: len(store._entries)
|
||||
for store_name, store in self._stores.items()
|
||||
}
|
||||
|
||||
def to_system_prompt_context(self) -> str:
|
||||
"""
|
||||
Generate context suitable for injection into system prompt.
|
||||
|
||||
Returns a formatted string with relevant learnings.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
if "persona" in self._stores:
|
||||
personas = self.get_persona_context(limit=5)
|
||||
if personas:
|
||||
parts.append("User Preferences:")
|
||||
for p in personas:
|
||||
parts.append(f"- {p}")
|
||||
|
||||
if "insights" in self._stores:
|
||||
insights = self.get_insights_context(limit=5)
|
||||
if insights:
|
||||
parts.append("\nLearned Insights:")
|
||||
for i in insights:
|
||||
parts.append(f"- {i}")
|
||||
|
||||
return "\n".join(parts) if parts else ""
|
||||
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Learning Stores - Storage backends for different learning capabilities.
|
||||
|
||||
Each store handles a specific type of learning data:
|
||||
- PersonaStore: User preferences and profile
|
||||
- InsightStore: Observations and learnings
|
||||
- ThreadStore: Session/conversation context
|
||||
- PatternStore: Reusable knowledge patterns
|
||||
- DecisionStore: Decision logging
|
||||
- FeedbackStore: Outcome signals
|
||||
- ImprovementStore: Self-improvement proposals
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class LearnEntry:
|
||||
"""Base entry for all learning stores."""
|
||||
id: str
|
||||
content: str
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
updated_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"content": self.content,
|
||||
"metadata": self.metadata,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "LearnEntry":
|
||||
return cls(
|
||||
id=data["id"],
|
||||
content=data["content"],
|
||||
metadata=data.get("metadata", {}),
|
||||
created_at=data.get("created_at", datetime.utcnow().isoformat()),
|
||||
updated_at=data.get("updated_at", datetime.utcnow().isoformat()),
|
||||
)
|
||||
|
||||
|
||||
class BaseStore(ABC):
|
||||
"""Abstract base class for learning stores."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store_path: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
scope: str = "private",
|
||||
):
|
||||
self.user_id = user_id or "default"
|
||||
self.scope = scope
|
||||
self.store_path = store_path or self._default_path()
|
||||
self._entries: Dict[str, LearnEntry] = {}
|
||||
self._load()
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def store_name(self) -> str:
|
||||
"""Name of the store (used for file naming)."""
|
||||
pass
|
||||
|
||||
def _default_path(self) -> str:
|
||||
"""Default storage path."""
|
||||
base = Path.home() / ".praison" / "learn" / self.scope / self.user_id
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return str(base / f"{self.store_name}.json")
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load entries from storage."""
|
||||
if os.path.exists(self.store_path):
|
||||
try:
|
||||
with open(self.store_path, "r") as f:
|
||||
data = json.load(f)
|
||||
self._entries = {
|
||||
k: LearnEntry.from_dict(v) for k, v in data.items()
|
||||
}
|
||||
except (json.JSONDecodeError, IOError):
|
||||
self._entries = {}
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Save entries to storage."""
|
||||
os.makedirs(os.path.dirname(self.store_path), exist_ok=True)
|
||||
with open(self.store_path, "w") as f:
|
||||
json.dump(
|
||||
{k: v.to_dict() for k, v in self._entries.items()},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
def add(self, content: str, metadata: Optional[Dict[str, Any]] = None) -> LearnEntry:
|
||||
"""Add a new entry."""
|
||||
entry_id = f"{self.store_name}_{len(self._entries)}_{datetime.utcnow().timestamp()}"
|
||||
entry = LearnEntry(
|
||||
id=entry_id,
|
||||
content=content,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
self._entries[entry_id] = entry
|
||||
self._save()
|
||||
return entry
|
||||
|
||||
def get(self, entry_id: str) -> Optional[LearnEntry]:
|
||||
"""Get entry by ID."""
|
||||
return self._entries.get(entry_id)
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> List[LearnEntry]:
|
||||
"""Simple text search (can be overridden for semantic search)."""
|
||||
query_lower = query.lower()
|
||||
results = [
|
||||
entry for entry in self._entries.values()
|
||||
if query_lower in entry.content.lower()
|
||||
]
|
||||
return results[:limit]
|
||||
|
||||
def list_all(self, limit: int = 100) -> List[LearnEntry]:
|
||||
"""List all entries."""
|
||||
entries = list(self._entries.values())
|
||||
entries.sort(key=lambda x: x.updated_at, reverse=True)
|
||||
return entries[:limit]
|
||||
|
||||
def update(self, entry_id: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Optional[LearnEntry]:
|
||||
"""Update an existing entry."""
|
||||
if entry_id not in self._entries:
|
||||
return None
|
||||
entry = self._entries[entry_id]
|
||||
entry.content = content
|
||||
if metadata:
|
||||
entry.metadata.update(metadata)
|
||||
entry.updated_at = datetime.utcnow().isoformat()
|
||||
self._save()
|
||||
return entry
|
||||
|
||||
def delete(self, entry_id: str) -> bool:
|
||||
"""Delete an entry."""
|
||||
if entry_id in self._entries:
|
||||
del self._entries[entry_id]
|
||||
self._save()
|
||||
return True
|
||||
return False
|
||||
|
||||
def clear(self) -> int:
|
||||
"""Clear all entries."""
|
||||
count = len(self._entries)
|
||||
self._entries = {}
|
||||
self._save()
|
||||
return count
|
||||
|
||||
|
||||
class PersonaStore(BaseStore):
|
||||
"""Store for user preferences and profile information."""
|
||||
|
||||
@property
|
||||
def store_name(self) -> str:
|
||||
return "persona"
|
||||
|
||||
def add_preference(self, preference: str, category: str = "general") -> LearnEntry:
|
||||
"""Add a user preference."""
|
||||
return self.add(preference, {"category": category, "type": "preference"})
|
||||
|
||||
def add_profile(self, profile_data: str, aspect: str = "general") -> LearnEntry:
|
||||
"""Add profile information."""
|
||||
return self.add(profile_data, {"aspect": aspect, "type": "profile"})
|
||||
|
||||
|
||||
class InsightStore(BaseStore):
|
||||
"""Store for observations and learnings."""
|
||||
|
||||
@property
|
||||
def store_name(self) -> str:
|
||||
return "insights"
|
||||
|
||||
def add_insight(self, insight: str, source: str = "interaction") -> LearnEntry:
|
||||
"""Add an insight."""
|
||||
return self.add(insight, {"source": source, "type": "insight"})
|
||||
|
||||
def add_observation(self, observation: str, context: str = "") -> LearnEntry:
|
||||
"""Add an observation."""
|
||||
return self.add(observation, {"context": context, "type": "observation"})
|
||||
|
||||
|
||||
class ThreadStore(BaseStore):
|
||||
"""Store for session/conversation context."""
|
||||
|
||||
@property
|
||||
def store_name(self) -> str:
|
||||
return "threads"
|
||||
|
||||
def add_thread_summary(self, summary: str, thread_id: str) -> LearnEntry:
|
||||
"""Add a thread summary."""
|
||||
return self.add(summary, {"thread_id": thread_id, "type": "summary"})
|
||||
|
||||
def add_context(self, context: str, thread_id: str) -> LearnEntry:
|
||||
"""Add thread context."""
|
||||
return self.add(context, {"thread_id": thread_id, "type": "context"})
|
||||
|
||||
|
||||
class PatternStore(BaseStore):
|
||||
"""Store for reusable knowledge patterns."""
|
||||
|
||||
@property
|
||||
def store_name(self) -> str:
|
||||
return "patterns"
|
||||
|
||||
def add_pattern(self, pattern: str, pattern_type: str = "general") -> LearnEntry:
|
||||
"""Add a knowledge pattern."""
|
||||
return self.add(pattern, {"pattern_type": pattern_type, "type": "pattern"})
|
||||
|
||||
|
||||
class DecisionStore(BaseStore):
|
||||
"""Store for decision logging."""
|
||||
|
||||
@property
|
||||
def store_name(self) -> str:
|
||||
return "decisions"
|
||||
|
||||
def add_decision(
|
||||
self,
|
||||
decision: str,
|
||||
reasoning: str = "",
|
||||
outcome: str = "",
|
||||
) -> LearnEntry:
|
||||
"""Add a decision record."""
|
||||
return self.add(decision, {
|
||||
"reasoning": reasoning,
|
||||
"outcome": outcome,
|
||||
"type": "decision",
|
||||
})
|
||||
|
||||
|
||||
class FeedbackStore(BaseStore):
|
||||
"""Store for outcome signals and feedback."""
|
||||
|
||||
@property
|
||||
def store_name(self) -> str:
|
||||
return "feedback"
|
||||
|
||||
def add_feedback(
|
||||
self,
|
||||
feedback: str,
|
||||
rating: Optional[int] = None,
|
||||
source: str = "user",
|
||||
) -> LearnEntry:
|
||||
"""Add feedback."""
|
||||
metadata = {"source": source, "type": "feedback"}
|
||||
if rating is not None:
|
||||
metadata["rating"] = rating
|
||||
return self.add(feedback, metadata)
|
||||
|
||||
|
||||
class ImprovementStore(BaseStore):
|
||||
"""Store for self-improvement proposals."""
|
||||
|
||||
@property
|
||||
def store_name(self) -> str:
|
||||
return "improvements"
|
||||
|
||||
def add_improvement(
|
||||
self,
|
||||
proposal: str,
|
||||
area: str = "general",
|
||||
priority: str = "medium",
|
||||
) -> LearnEntry:
|
||||
"""Add an improvement proposal."""
|
||||
return self.add(proposal, {
|
||||
"area": area,
|
||||
"priority": priority,
|
||||
"type": "improvement",
|
||||
})
|
||||
@@ -109,7 +109,7 @@ class Memory:
|
||||
},
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {"model": "gpt-5-nano", "api_key": "..."}
|
||||
"config": {"model": "gpt-4o-mini", "api_key": "..."}
|
||||
},
|
||||
"embedder": {
|
||||
"provider": "openai",
|
||||
@@ -146,6 +146,8 @@ class Memory:
|
||||
self.use_rag = (self.provider.lower() == "rag") and CHROMADB_AVAILABLE and self.cfg.get("use_embedding", False)
|
||||
self.use_mongodb = (self.provider.lower() == "mongodb") and PYMONGO_AVAILABLE
|
||||
self.graph_enabled = False # Initialize graph support flag
|
||||
self._learn_manager = None # Lazy-loaded LearnManager
|
||||
self._learn_config = self.cfg.get("learn", None) # Learn configuration
|
||||
|
||||
# Extract embedding model from config
|
||||
self.embedder_config = self.cfg.get("embedder", {})
|
||||
@@ -1442,7 +1444,7 @@ class Memory:
|
||||
import litellm
|
||||
|
||||
# Convert model name if it's in litellm format
|
||||
model_name = llm or "gpt-5-nano"
|
||||
model_name = llm or "gpt-4o-mini"
|
||||
|
||||
response = litellm.completion(
|
||||
model=model_name,
|
||||
@@ -1459,7 +1461,7 @@ class Memory:
|
||||
client = OpenAI()
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=llm or "gpt-5-nano",
|
||||
model=llm or "gpt-4o-mini",
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": custom_prompt or default_prompt
|
||||
@@ -1599,3 +1601,50 @@ class Memory:
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error getting all memories: {e}", logging.ERROR)
|
||||
return []
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Learn Integration
|
||||
# -------------------------------------------------------------------------
|
||||
@property
|
||||
def learn(self):
|
||||
"""
|
||||
Get the LearnManager for continuous learning capabilities.
|
||||
|
||||
Returns None if learn is not enabled in config.
|
||||
|
||||
Usage:
|
||||
memory = Memory({"learn": True})
|
||||
memory.learn.capture_persona("User prefers concise responses")
|
||||
memory.learn.capture_insight("User works in data science")
|
||||
"""
|
||||
if self._learn_manager is not None:
|
||||
return self._learn_manager
|
||||
|
||||
if self._learn_config is None or self._learn_config is False:
|
||||
return None
|
||||
|
||||
from .learn import LearnManager
|
||||
from ..config.feature_configs import LearnConfig
|
||||
|
||||
if self._learn_config is True:
|
||||
config = LearnConfig()
|
||||
elif isinstance(self._learn_config, dict):
|
||||
config = LearnConfig(**self._learn_config)
|
||||
elif isinstance(self._learn_config, LearnConfig):
|
||||
config = self._learn_config
|
||||
else:
|
||||
return None
|
||||
|
||||
user_id = self.cfg.get("user_id", "default")
|
||||
self._learn_manager = LearnManager(config=config, user_id=user_id)
|
||||
return self._learn_manager
|
||||
|
||||
def get_learn_context(self) -> str:
|
||||
"""
|
||||
Get learning context suitable for injection into system prompt.
|
||||
|
||||
Returns empty string if learn is not enabled.
|
||||
"""
|
||||
if self.learn is None:
|
||||
return ""
|
||||
return self.learn.to_system_prompt_context()
|
||||
@@ -326,7 +326,7 @@ class Task:
|
||||
if hasattr(self.agent.llm_instance, 'model'):
|
||||
llm_model = self.agent.llm_instance.model
|
||||
else:
|
||||
llm_model = "gpt-5-nano" # Default fallback
|
||||
llm_model = "gpt-4o-mini" # Default fallback
|
||||
elif hasattr(self.agent, 'llm') and self.agent.llm:
|
||||
# For standard model strings
|
||||
llm_model = self.agent.llm
|
||||
|
||||
@@ -27,7 +27,7 @@ class GenerateCOT:
|
||||
def __init__(
|
||||
self,
|
||||
qa_pairs: Optional[Dict[str, str]] = None,
|
||||
model: str = "gpt-5-nano",
|
||||
model: str = "gpt-4o-mini",
|
||||
api_key: Optional[str] = None,
|
||||
max_attempts: int = 3,
|
||||
verbose: bool = True,
|
||||
|
||||
@@ -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)
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "0.11.2"
|
||||
version = "0.11.8"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -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,156 @@
|
||||
"""
|
||||
Unit tests for Agent output parameter with verbose preset.
|
||||
|
||||
Tests the fix for the bug where output="verbose" would return None
|
||||
because reasoning_steps=True in the verbose preset was causing the code
|
||||
to try to return reasoning_content, but this attribute exists as None
|
||||
for non-reasoning models.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
|
||||
|
||||
class TestReasoningContentLogic:
|
||||
"""Test the reasoning_content check logic directly."""
|
||||
|
||||
def test_condition_with_none_reasoning_content(self):
|
||||
"""Verify the fix: condition should be False when reasoning_content is None."""
|
||||
mock_message = Mock()
|
||||
mock_message.reasoning_content = None
|
||||
|
||||
reasoning_steps = True
|
||||
|
||||
# Old buggy condition:
|
||||
# if reasoning_steps and hasattr(mock_message, 'reasoning_content'):
|
||||
old_condition = reasoning_steps and hasattr(mock_message, 'reasoning_content')
|
||||
assert old_condition == True, "Old condition would be True (the bug)"
|
||||
|
||||
# Fixed condition:
|
||||
# if reasoning_steps and hasattr(mock_message, 'reasoning_content') and mock_message.reasoning_content:
|
||||
# The truthiness of the whole expression should be False
|
||||
fixed_condition = reasoning_steps and hasattr(mock_message, 'reasoning_content') and mock_message.reasoning_content
|
||||
assert not fixed_condition, "Fixed condition should be falsy when reasoning_content is None"
|
||||
|
||||
def test_condition_with_empty_string_reasoning_content(self):
|
||||
"""Verify the fix: condition should be False when reasoning_content is empty string."""
|
||||
mock_message = Mock()
|
||||
mock_message.reasoning_content = ""
|
||||
|
||||
reasoning_steps = True
|
||||
|
||||
# Old condition would True (bug)
|
||||
old_condition = reasoning_steps and hasattr(mock_message, 'reasoning_content')
|
||||
assert old_condition == True
|
||||
|
||||
# Fixed condition should be falsy
|
||||
fixed_condition = reasoning_steps and hasattr(mock_message, 'reasoning_content') and mock_message.reasoning_content
|
||||
assert not fixed_condition, "Fixed condition should be falsy when reasoning_content is empty string"
|
||||
|
||||
def test_condition_with_actual_reasoning_content(self):
|
||||
"""Verify the fix: condition should be True when reasoning_content has content."""
|
||||
mock_message = Mock()
|
||||
mock_message.reasoning_content = "This is my reasoning process..."
|
||||
|
||||
reasoning_steps = True
|
||||
|
||||
# Both old and new condition should be truthy
|
||||
old_condition = reasoning_steps and hasattr(mock_message, 'reasoning_content')
|
||||
assert old_condition
|
||||
|
||||
fixed_condition = reasoning_steps and hasattr(mock_message, 'reasoning_content') and mock_message.reasoning_content
|
||||
assert fixed_condition, "Fixed condition should be truthy when reasoning_content has content"
|
||||
|
||||
def test_condition_when_reasoning_steps_false(self):
|
||||
"""Verify that when reasoning_steps is False, condition is always False."""
|
||||
mock_message = Mock()
|
||||
mock_message.reasoning_content = "This is my reasoning process..."
|
||||
|
||||
reasoning_steps = False
|
||||
|
||||
fixed_condition = reasoning_steps and hasattr(mock_message, 'reasoning_content') and mock_message.reasoning_content
|
||||
assert not fixed_condition, "Condition should be falsy when reasoning_steps is False"
|
||||
|
||||
def test_if_statement_behavior_with_none(self):
|
||||
"""Verify that an if statement using the condition skips when reasoning_content is None."""
|
||||
mock_message = Mock()
|
||||
mock_message.reasoning_content = None
|
||||
|
||||
reasoning_steps = True
|
||||
execution_path = "not_taken"
|
||||
|
||||
# This is how the fixed code behaves
|
||||
if reasoning_steps and hasattr(mock_message, 'reasoning_content') and mock_message.reasoning_content:
|
||||
execution_path = "reasoning_path"
|
||||
else:
|
||||
execution_path = "regular_path"
|
||||
|
||||
assert execution_path == "regular_path", "Should take regular path when reasoning_content is None"
|
||||
|
||||
def test_if_statement_behavior_with_reasoning(self):
|
||||
"""Verify that an if statement using the condition enters when reasoning_content has value."""
|
||||
mock_message = Mock()
|
||||
mock_message.reasoning_content = "Reasoning steps here..."
|
||||
|
||||
reasoning_steps = True
|
||||
execution_path = "not_taken"
|
||||
|
||||
if reasoning_steps and hasattr(mock_message, 'reasoning_content') and mock_message.reasoning_content:
|
||||
execution_path = "reasoning_path"
|
||||
else:
|
||||
execution_path = "regular_path"
|
||||
|
||||
assert execution_path == "reasoning_path", "Should take reasoning path when reasoning_content has value"
|
||||
|
||||
|
||||
class TestVerbosePresetConfig:
|
||||
"""Test that the verbose preset has the expected configuration."""
|
||||
|
||||
def test_verbose_preset_has_reasoning_steps_true(self):
|
||||
"""Verify that the verbose preset enables reasoning_steps."""
|
||||
from praisonaiagents.config.presets import OUTPUT_PRESETS
|
||||
|
||||
verbose_preset = OUTPUT_PRESETS.get("verbose")
|
||||
assert verbose_preset is not None, "verbose preset should exist"
|
||||
assert verbose_preset.get("reasoning_steps") == True, "verbose preset should have reasoning_steps=True"
|
||||
|
||||
def test_verbose_preset_has_verbose_true(self):
|
||||
"""Verify that the verbose preset enables verbose output."""
|
||||
from praisonaiagents.config.presets import OUTPUT_PRESETS
|
||||
|
||||
verbose_preset = OUTPUT_PRESETS.get("verbose")
|
||||
assert verbose_preset.get("verbose") == True, "verbose preset should have verbose=True"
|
||||
|
||||
def test_verbose_preset_has_stream_false(self):
|
||||
"""Verify that the verbose preset does not enable streaming by default."""
|
||||
from praisonaiagents.config.presets import OUTPUT_PRESETS
|
||||
|
||||
verbose_preset = OUTPUT_PRESETS.get("verbose")
|
||||
assert verbose_preset.get("stream") == False, "verbose preset should have stream=False"
|
||||
|
||||
|
||||
class TestChatCompletionMessageDataclass:
|
||||
"""Test the ChatCompletionMessage dataclass has reasoning_content attribute."""
|
||||
|
||||
def test_chat_completion_message_has_reasoning_content(self):
|
||||
"""Verify ChatCompletionMessage has reasoning_content attribute that defaults to None."""
|
||||
from praisonaiagents.llm.openai_client import ChatCompletionMessage
|
||||
|
||||
# Create without reasoning_content
|
||||
message = ChatCompletionMessage(content="Test", role="assistant")
|
||||
|
||||
# Should have the attribute
|
||||
assert hasattr(message, 'reasoning_content')
|
||||
# Should default to None
|
||||
assert message.reasoning_content is None
|
||||
|
||||
def test_chat_completion_message_with_reasoning(self):
|
||||
"""Verify ChatCompletionMessage can have reasoning_content set."""
|
||||
from praisonaiagents.llm.openai_client import ChatCompletionMessage
|
||||
|
||||
message = ChatCompletionMessage(
|
||||
content="Test",
|
||||
role="assistant",
|
||||
reasoning_content="This is my reasoning..."
|
||||
)
|
||||
|
||||
assert message.reasoning_content == "This is my reasoning..."
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Tests for LearnScope enum - verifying PRIVATE/SHARED values only.
|
||||
|
||||
These tests verify that:
|
||||
1. LearnScope only has PRIVATE and SHARED values
|
||||
2. TEAM and ORG values do NOT exist
|
||||
3. LearnConfig defaults to PRIVATE scope
|
||||
4. BaseStore defaults to "private" scope
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestLearnScopeValues:
|
||||
"""Test LearnScope enum has exactly the right values."""
|
||||
|
||||
def test_learn_scope_has_private(self):
|
||||
"""LearnScope should have PRIVATE value."""
|
||||
from praisonaiagents.config.feature_configs import LearnScope
|
||||
|
||||
assert hasattr(LearnScope, 'PRIVATE')
|
||||
assert LearnScope.PRIVATE.value == "private"
|
||||
|
||||
def test_learn_scope_has_shared(self):
|
||||
"""LearnScope should have SHARED value."""
|
||||
from praisonaiagents.config.feature_configs import LearnScope
|
||||
|
||||
assert hasattr(LearnScope, 'SHARED')
|
||||
assert LearnScope.SHARED.value == "shared"
|
||||
|
||||
def test_learn_scope_does_not_have_team(self):
|
||||
"""LearnScope should NOT have TEAM value."""
|
||||
from praisonaiagents.config.feature_configs import LearnScope
|
||||
|
||||
assert not hasattr(LearnScope, 'TEAM'), "TEAM should be removed from LearnScope"
|
||||
|
||||
def test_learn_scope_does_not_have_org(self):
|
||||
"""LearnScope should NOT have ORG value."""
|
||||
from praisonaiagents.config.feature_configs import LearnScope
|
||||
|
||||
assert not hasattr(LearnScope, 'ORG'), "ORG should be removed from LearnScope"
|
||||
|
||||
def test_learn_scope_does_not_have_user(self):
|
||||
"""LearnScope should NOT have USER value (replaced by PRIVATE)."""
|
||||
from praisonaiagents.config.feature_configs import LearnScope
|
||||
|
||||
assert not hasattr(LearnScope, 'USER'), "USER should be replaced by PRIVATE"
|
||||
|
||||
def test_learn_scope_does_not_have_global(self):
|
||||
"""LearnScope should NOT have GLOBAL value (replaced by SHARED)."""
|
||||
from praisonaiagents.config.feature_configs import LearnScope
|
||||
|
||||
assert not hasattr(LearnScope, 'GLOBAL'), "GLOBAL should be replaced by SHARED"
|
||||
|
||||
def test_learn_scope_has_exactly_two_values(self):
|
||||
"""LearnScope should have exactly 2 values: PRIVATE and SHARED."""
|
||||
from praisonaiagents.config.feature_configs import LearnScope
|
||||
|
||||
all_values = list(LearnScope)
|
||||
assert len(all_values) == 2, f"Expected 2 values, got {len(all_values)}: {all_values}"
|
||||
|
||||
|
||||
class TestLearnConfigDefaults:
|
||||
"""Test LearnConfig uses correct default scope."""
|
||||
|
||||
def test_learn_config_default_scope_is_private(self):
|
||||
"""LearnConfig should default to PRIVATE scope."""
|
||||
from praisonaiagents.config.feature_configs import LearnConfig, LearnScope
|
||||
|
||||
config = LearnConfig()
|
||||
assert config.scope == LearnScope.PRIVATE
|
||||
|
||||
def test_learn_config_scope_can_be_set_to_shared(self):
|
||||
"""LearnConfig scope can be set to SHARED."""
|
||||
from praisonaiagents.config.feature_configs import LearnConfig, LearnScope
|
||||
|
||||
config = LearnConfig(scope=LearnScope.SHARED)
|
||||
assert config.scope == LearnScope.SHARED
|
||||
|
||||
def test_learn_config_to_dict_includes_scope(self):
|
||||
"""LearnConfig.to_dict() should include scope with correct value."""
|
||||
from praisonaiagents.config.feature_configs import LearnConfig, LearnScope
|
||||
|
||||
config = LearnConfig()
|
||||
d = config.to_dict()
|
||||
assert d['scope'] == 'private'
|
||||
|
||||
config2 = LearnConfig(scope=LearnScope.SHARED)
|
||||
d2 = config2.to_dict()
|
||||
assert d2['scope'] == 'shared'
|
||||
|
||||
|
||||
class TestBaseStoreDefaults:
|
||||
"""Test BaseStore uses correct default scope."""
|
||||
|
||||
def test_base_store_default_scope_is_private(self):
|
||||
"""BaseStore should default to 'private' scope."""
|
||||
import inspect
|
||||
from praisonaiagents.memory.learn.stores import BaseStore
|
||||
|
||||
sig = inspect.signature(BaseStore.__init__)
|
||||
scope_param = sig.parameters.get('scope')
|
||||
assert scope_param is not None
|
||||
assert scope_param.default == 'private', f"Expected 'private', got '{scope_param.default}'"
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Tests for precedence ladder resolvers."""
|
||||
|
||||
import pytest
|
||||
from praisonaiagents.config.feature_configs import (
|
||||
MemoryConfig,
|
||||
MemoryBackend,
|
||||
KnowledgeConfig,
|
||||
PlanningConfig,
|
||||
ReflectionConfig,
|
||||
GuardrailConfig,
|
||||
WebConfig,
|
||||
OutputConfig,
|
||||
ExecutionConfig,
|
||||
CachingConfig,
|
||||
AutonomyConfig,
|
||||
resolve_memory,
|
||||
resolve_knowledge,
|
||||
resolve_planning,
|
||||
resolve_reflection,
|
||||
resolve_guardrails,
|
||||
resolve_web,
|
||||
resolve_output,
|
||||
resolve_execution,
|
||||
resolve_caching,
|
||||
resolve_autonomy,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveMemory:
|
||||
"""Test resolve_memory precedence ladder."""
|
||||
|
||||
def test_default_none(self):
|
||||
"""Default (None) returns None."""
|
||||
assert resolve_memory(None) is None
|
||||
|
||||
def test_bool_false(self):
|
||||
"""False returns None (disabled)."""
|
||||
assert resolve_memory(False) is None
|
||||
|
||||
def test_bool_true(self):
|
||||
"""True returns MemoryConfig with defaults."""
|
||||
result = resolve_memory(True)
|
||||
assert isinstance(result, MemoryConfig)
|
||||
assert result.backend == MemoryBackend.FILE
|
||||
|
||||
def test_string_backend(self):
|
||||
"""String returns MemoryConfig with that backend."""
|
||||
result = resolve_memory("redis")
|
||||
assert isinstance(result, MemoryConfig)
|
||||
assert result.backend == MemoryBackend.REDIS
|
||||
|
||||
def test_string_custom_backend(self):
|
||||
"""Custom string backend is preserved."""
|
||||
result = resolve_memory("custom_backend")
|
||||
assert isinstance(result, MemoryConfig)
|
||||
assert result.backend == "custom_backend"
|
||||
|
||||
def test_dict_expansion(self):
|
||||
"""Dict expands to MemoryConfig."""
|
||||
result = resolve_memory({
|
||||
"backend": "sqlite",
|
||||
"user_id": "alice",
|
||||
"auto_memory": True,
|
||||
})
|
||||
assert isinstance(result, MemoryConfig)
|
||||
assert result.backend == MemoryBackend.SQLITE
|
||||
assert result.user_id == "alice"
|
||||
assert result.auto_memory is True
|
||||
|
||||
def test_config_passthrough(self):
|
||||
"""MemoryConfig passes through unchanged."""
|
||||
config = MemoryConfig(backend=MemoryBackend.POSTGRES, user_id="bob")
|
||||
result = resolve_memory(config)
|
||||
assert result is config
|
||||
|
||||
def test_instance_passthrough(self):
|
||||
"""Instance (non-config object) passes through."""
|
||||
class MockMemoryManager:
|
||||
pass
|
||||
instance = MockMemoryManager()
|
||||
result = resolve_memory(instance)
|
||||
assert result is instance
|
||||
|
||||
|
||||
class TestResolveKnowledge:
|
||||
"""Test resolve_knowledge precedence ladder."""
|
||||
|
||||
def test_default_none(self):
|
||||
assert resolve_knowledge(None) is None
|
||||
|
||||
def test_bool_false(self):
|
||||
assert resolve_knowledge(False) is None
|
||||
|
||||
def test_bool_true(self):
|
||||
result = resolve_knowledge(True)
|
||||
assert isinstance(result, KnowledgeConfig)
|
||||
|
||||
def test_string_single_source(self):
|
||||
result = resolve_knowledge("docs/")
|
||||
assert isinstance(result, KnowledgeConfig)
|
||||
assert result.sources == ["docs/"]
|
||||
|
||||
def test_array_sources(self):
|
||||
result = resolve_knowledge(["docs/", "data.pdf"])
|
||||
assert isinstance(result, KnowledgeConfig)
|
||||
assert result.sources == ["docs/", "data.pdf"]
|
||||
|
||||
def test_dict_expansion(self):
|
||||
result = resolve_knowledge({
|
||||
"sources": ["docs/"],
|
||||
"rerank": True,
|
||||
})
|
||||
assert isinstance(result, KnowledgeConfig)
|
||||
assert result.sources == ["docs/"]
|
||||
assert result.rerank is True
|
||||
|
||||
def test_config_passthrough(self):
|
||||
config = KnowledgeConfig(sources=["test/"])
|
||||
result = resolve_knowledge(config)
|
||||
assert result is config
|
||||
|
||||
|
||||
class TestResolvePlanning:
|
||||
"""Test resolve_planning precedence ladder."""
|
||||
|
||||
def test_default_none(self):
|
||||
assert resolve_planning(None) is None
|
||||
|
||||
def test_bool_false(self):
|
||||
assert resolve_planning(False) is None
|
||||
|
||||
def test_bool_true(self):
|
||||
result = resolve_planning(True)
|
||||
assert isinstance(result, PlanningConfig)
|
||||
|
||||
def test_config_passthrough(self):
|
||||
config = PlanningConfig()
|
||||
result = resolve_planning(config)
|
||||
assert result is config
|
||||
|
||||
|
||||
class TestResolveReflection:
|
||||
"""Test resolve_reflection precedence ladder."""
|
||||
|
||||
def test_default_none(self):
|
||||
assert resolve_reflection(None) is None
|
||||
|
||||
def test_bool_false(self):
|
||||
assert resolve_reflection(False) is None
|
||||
|
||||
def test_bool_true(self):
|
||||
result = resolve_reflection(True)
|
||||
assert isinstance(result, ReflectionConfig)
|
||||
|
||||
|
||||
class TestResolveGuardrails:
|
||||
"""Test resolve_guardrails precedence ladder."""
|
||||
|
||||
def test_default_none(self):
|
||||
assert resolve_guardrails(None) is None
|
||||
|
||||
def test_bool_false(self):
|
||||
assert resolve_guardrails(False) is None
|
||||
|
||||
def test_bool_true(self):
|
||||
result = resolve_guardrails(True)
|
||||
assert isinstance(result, GuardrailConfig)
|
||||
|
||||
def test_callable_validator(self):
|
||||
def my_validator(output):
|
||||
return True, output
|
||||
result = resolve_guardrails(my_validator)
|
||||
assert isinstance(result, GuardrailConfig)
|
||||
assert result.validator is my_validator
|
||||
|
||||
|
||||
class TestResolveWeb:
|
||||
"""Test resolve_web precedence ladder."""
|
||||
|
||||
def test_default_none(self):
|
||||
assert resolve_web(None) is None
|
||||
|
||||
def test_bool_false(self):
|
||||
assert resolve_web(False) is None
|
||||
|
||||
def test_bool_true(self):
|
||||
result = resolve_web(True)
|
||||
assert isinstance(result, WebConfig)
|
||||
|
||||
|
||||
class TestResolveCaching:
|
||||
"""Test resolve_caching precedence ladder."""
|
||||
|
||||
def test_default_none(self):
|
||||
assert resolve_caching(None) is None
|
||||
|
||||
def test_bool_false(self):
|
||||
assert resolve_caching(False) is None
|
||||
|
||||
def test_bool_true(self):
|
||||
result = resolve_caching(True)
|
||||
assert isinstance(result, CachingConfig)
|
||||
|
||||
|
||||
class TestResolveAutonomy:
|
||||
"""Test resolve_autonomy precedence ladder."""
|
||||
|
||||
def test_default_none(self):
|
||||
assert resolve_autonomy(None) is None
|
||||
|
||||
def test_bool_false(self):
|
||||
assert resolve_autonomy(False) is None
|
||||
|
||||
def test_bool_true(self):
|
||||
result = resolve_autonomy(True)
|
||||
assert isinstance(result, AutonomyConfig)
|
||||
|
||||
|
||||
class TestNoCortexParam:
|
||||
"""Test that cortex parameter does not exist."""
|
||||
|
||||
def test_agent_has_no_cortex_param(self):
|
||||
"""Agent.__init__ should not have cortex parameter."""
|
||||
import inspect
|
||||
from praisonaiagents import Agent
|
||||
|
||||
sig = inspect.signature(Agent.__init__)
|
||||
param_names = list(sig.parameters.keys())
|
||||
|
||||
assert "cortex" not in param_names, "cortex param should be removed from Agent"
|
||||
|
||||
def test_no_cortex_module_in_agents(self):
|
||||
"""praisonaiagents should not have cortex module."""
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.find_spec("praisonaiagents.cortex")
|
||||
assert spec is None, "praisonaiagents.cortex module should not exist"
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Tests for the learn module - continuous learning within memory system."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from praisonaiagents.config.feature_configs import LearnConfig, LearnScope, MemoryConfig
|
||||
from praisonaiagents.memory.learn.stores import (
|
||||
PersonaStore,
|
||||
InsightStore,
|
||||
ThreadStore,
|
||||
PatternStore,
|
||||
DecisionStore,
|
||||
FeedbackStore,
|
||||
ImprovementStore,
|
||||
LearnEntry,
|
||||
)
|
||||
from praisonaiagents.memory.learn.manager import LearnManager
|
||||
|
||||
|
||||
class TestLearnConfig:
|
||||
"""Test LearnConfig dataclass."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test default LearnConfig values."""
|
||||
config = LearnConfig()
|
||||
assert config.persona is True
|
||||
assert config.insights is True
|
||||
assert config.thread is True
|
||||
assert config.patterns is False
|
||||
assert config.decisions is False
|
||||
assert config.feedback is False
|
||||
assert config.improvements is False
|
||||
assert config.scope == LearnScope.USER
|
||||
assert config.auto_consolidate is True
|
||||
assert config.retention_days is None
|
||||
|
||||
def test_custom_values(self):
|
||||
"""Test LearnConfig with custom values."""
|
||||
config = LearnConfig(
|
||||
persona=False,
|
||||
insights=True,
|
||||
patterns=True,
|
||||
scope=LearnScope.TEAM,
|
||||
retention_days=30,
|
||||
)
|
||||
assert config.persona is False
|
||||
assert config.patterns is True
|
||||
assert config.scope == LearnScope.TEAM
|
||||
assert config.retention_days == 30
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test LearnConfig to_dict method."""
|
||||
config = LearnConfig(persona=True, insights=True)
|
||||
d = config.to_dict()
|
||||
assert d["persona"] is True
|
||||
assert d["insights"] is True
|
||||
assert d["scope"] == "user"
|
||||
|
||||
|
||||
class TestMemoryConfigWithLearn:
|
||||
"""Test MemoryConfig with learn field."""
|
||||
|
||||
def test_learn_none_by_default(self):
|
||||
"""Test that learn is None by default."""
|
||||
config = MemoryConfig()
|
||||
assert config.learn is None
|
||||
|
||||
def test_learn_bool_true(self):
|
||||
"""Test learn=True."""
|
||||
config = MemoryConfig(learn=True)
|
||||
assert config.learn is True
|
||||
d = config.to_dict()
|
||||
assert d["learn"] is not None
|
||||
assert d["learn"]["persona"] is True
|
||||
|
||||
def test_learn_config_instance(self):
|
||||
"""Test learn=LearnConfig(...)."""
|
||||
learn_config = LearnConfig(persona=True, patterns=True)
|
||||
config = MemoryConfig(learn=learn_config)
|
||||
assert config.learn is learn_config
|
||||
d = config.to_dict()
|
||||
assert d["learn"]["patterns"] is True
|
||||
|
||||
|
||||
class TestLearnEntry:
|
||||
"""Test LearnEntry dataclass."""
|
||||
|
||||
def test_create_entry(self):
|
||||
"""Test creating a LearnEntry."""
|
||||
entry = LearnEntry(
|
||||
id="test_1",
|
||||
content="Test content",
|
||||
metadata={"key": "value"},
|
||||
)
|
||||
assert entry.id == "test_1"
|
||||
assert entry.content == "Test content"
|
||||
assert entry.metadata["key"] == "value"
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test LearnEntry to_dict."""
|
||||
entry = LearnEntry(id="test_1", content="Test")
|
||||
d = entry.to_dict()
|
||||
assert d["id"] == "test_1"
|
||||
assert d["content"] == "Test"
|
||||
assert "created_at" in d
|
||||
|
||||
def test_from_dict(self):
|
||||
"""Test LearnEntry from_dict."""
|
||||
data = {
|
||||
"id": "test_1",
|
||||
"content": "Test content",
|
||||
"metadata": {"key": "value"},
|
||||
}
|
||||
entry = LearnEntry.from_dict(data)
|
||||
assert entry.id == "test_1"
|
||||
assert entry.content == "Test content"
|
||||
|
||||
|
||||
class TestPersonaStore:
|
||||
"""Test PersonaStore."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.store_path = os.path.join(self.temp_dir, "persona.json")
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up test fixtures."""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_add_preference(self):
|
||||
"""Test adding a preference."""
|
||||
store = PersonaStore(store_path=self.store_path, user_id="test_user")
|
||||
entry = store.add_preference("User prefers concise responses", "communication")
|
||||
assert entry is not None
|
||||
assert "concise" in entry.content
|
||||
assert entry.metadata["category"] == "communication"
|
||||
|
||||
def test_search(self):
|
||||
"""Test searching entries."""
|
||||
store = PersonaStore(store_path=self.store_path, user_id="test_user")
|
||||
store.add_preference("User likes Python", "programming")
|
||||
store.add_preference("User prefers dark mode", "ui")
|
||||
|
||||
results = store.search("Python")
|
||||
assert len(results) == 1
|
||||
assert "Python" in results[0].content
|
||||
|
||||
|
||||
class TestInsightStore:
|
||||
"""Test InsightStore."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.store_path = os.path.join(self.temp_dir, "insights.json")
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up test fixtures."""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_add_insight(self):
|
||||
"""Test adding an insight."""
|
||||
store = InsightStore(store_path=self.store_path, user_id="test_user")
|
||||
entry = store.add_insight("User works in data science", "conversation")
|
||||
assert entry is not None
|
||||
assert "data science" in entry.content
|
||||
|
||||
|
||||
class TestLearnManager:
|
||||
"""Test LearnManager."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up test fixtures."""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_create_manager_with_defaults(self):
|
||||
"""Test creating LearnManager with default config."""
|
||||
config = LearnConfig()
|
||||
manager = LearnManager(config=config, user_id="test_user", store_path=self.temp_dir)
|
||||
|
||||
assert "persona" in manager._stores
|
||||
assert "insights" in manager._stores
|
||||
assert "threads" in manager._stores
|
||||
|
||||
def test_capture_persona(self):
|
||||
"""Test capturing persona."""
|
||||
config = LearnConfig(persona=True)
|
||||
manager = LearnManager(config=config, user_id="test_user", store_path=self.temp_dir)
|
||||
|
||||
entry = manager.capture_persona("User prefers detailed explanations")
|
||||
assert entry is not None
|
||||
assert "detailed" in entry.content
|
||||
|
||||
def test_capture_insight(self):
|
||||
"""Test capturing insight."""
|
||||
config = LearnConfig(insights=True)
|
||||
manager = LearnManager(config=config, user_id="test_user", store_path=self.temp_dir)
|
||||
|
||||
entry = manager.capture_insight("User is experienced with Python")
|
||||
assert entry is not None
|
||||
|
||||
def test_get_learning_context(self):
|
||||
"""Test getting learning context."""
|
||||
config = LearnConfig(persona=True, insights=True)
|
||||
manager = LearnManager(config=config, user_id="test_user", store_path=self.temp_dir)
|
||||
|
||||
manager.capture_persona("User likes concise code")
|
||||
manager.capture_insight("User knows Python")
|
||||
|
||||
context = manager.get_learning_context()
|
||||
assert "persona" in context
|
||||
assert "insights" in context
|
||||
assert len(context["persona"]) == 1
|
||||
assert len(context["insights"]) == 1
|
||||
|
||||
def test_search(self):
|
||||
"""Test searching across stores."""
|
||||
config = LearnConfig(persona=True, insights=True)
|
||||
manager = LearnManager(config=config, user_id="test_user", store_path=self.temp_dir)
|
||||
|
||||
manager.capture_persona("User prefers Python")
|
||||
manager.capture_insight("User knows Python well")
|
||||
|
||||
results = manager.search("Python")
|
||||
assert "persona" in results or "insights" in results
|
||||
|
||||
def test_clear_all(self):
|
||||
"""Test clearing all stores."""
|
||||
config = LearnConfig(persona=True, insights=True)
|
||||
manager = LearnManager(config=config, user_id="test_user", store_path=self.temp_dir)
|
||||
|
||||
manager.capture_persona("Test 1")
|
||||
manager.capture_insight("Test 2")
|
||||
|
||||
cleared = manager.clear_all()
|
||||
assert cleared["persona"] == 1
|
||||
assert cleared["insights"] == 1
|
||||
|
||||
def test_get_stats(self):
|
||||
"""Test getting stats."""
|
||||
config = LearnConfig(persona=True, insights=True)
|
||||
manager = LearnManager(config=config, user_id="test_user", store_path=self.temp_dir)
|
||||
|
||||
manager.capture_persona("Test 1")
|
||||
manager.capture_persona("Test 2")
|
||||
manager.capture_insight("Test 3")
|
||||
|
||||
stats = manager.get_stats()
|
||||
assert stats["persona"] == 2
|
||||
assert stats["insights"] == 1
|
||||
|
||||
def test_to_system_prompt_context(self):
|
||||
"""Test generating system prompt context."""
|
||||
config = LearnConfig(persona=True, insights=True)
|
||||
manager = LearnManager(config=config, user_id="test_user", store_path=self.temp_dir)
|
||||
|
||||
manager.capture_persona("User prefers concise responses")
|
||||
manager.capture_insight("User is a Python developer")
|
||||
|
||||
context = manager.to_system_prompt_context()
|
||||
assert "User Preferences" in context
|
||||
assert "concise" in context
|
||||
assert "Learned Insights" in context
|
||||
assert "Python" in context
|
||||
|
||||
|
||||
class TestNoCortexParam:
|
||||
"""Test that cortex parameter does not exist."""
|
||||
|
||||
def test_agent_has_no_cortex_param(self):
|
||||
"""Agent.__init__ should not have cortex parameter."""
|
||||
import inspect
|
||||
from praisonaiagents import Agent
|
||||
|
||||
sig = inspect.signature(Agent.__init__)
|
||||
param_names = list(sig.parameters.keys())
|
||||
|
||||
assert "cortex" not in param_names, "cortex param should be removed from Agent"
|
||||
|
||||
def test_no_cortex_module_in_agents(self):
|
||||
"""praisonaiagents should not have cortex module."""
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.find_spec("praisonaiagents.cortex")
|
||||
assert spec is None, "praisonaiagents.cortex module should not exist"
|
||||
@@ -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"
|
||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Generated
+1
-1
@@ -2422,7 +2422,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.11.2"
|
||||
version = "0.11.8"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
chainlit.md
|
||||
tests/fixtures
|
||||
reports
|
||||
reports
|
||||
.agent
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
Dynamic Context Discovery Example.
|
||||
|
||||
This example demonstrates how to use the Dynamic Context Discovery feature
|
||||
to efficiently manage large tool outputs, conversation history, and terminal logs.
|
||||
|
||||
Features demonstrated:
|
||||
1. Tool output queuing - Large outputs automatically saved as artifacts
|
||||
2. Artifact tools - tail, grep, chunk operations on stored artifacts
|
||||
3. History persistence - Conversation history saved for loss recovery
|
||||
4. Terminal logging - Shell command outputs captured
|
||||
|
||||
Usage:
|
||||
python dynamic_context_example.py
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
|
||||
|
||||
def example_artifact_store():
|
||||
"""Demonstrate artifact storage and retrieval."""
|
||||
print("\n=== Artifact Store Example ===\n")
|
||||
|
||||
from praisonai.context import FileSystemArtifactStore
|
||||
from praisonaiagents.context.artifacts import ArtifactMetadata
|
||||
|
||||
# Create a temporary store
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileSystemArtifactStore(base_dir=tmpdir)
|
||||
|
||||
# Store a large JSON response (simulating API output)
|
||||
large_data = {
|
||||
"records": [{"id": i, "name": f"Item {i}"} for i in range(1000)],
|
||||
"metadata": {"total": 1000, "page": 1},
|
||||
}
|
||||
|
||||
metadata = ArtifactMetadata(
|
||||
agent_id="data_agent",
|
||||
run_id="run_001",
|
||||
tool_name="api_fetch",
|
||||
)
|
||||
|
||||
ref = store.store(large_data, metadata)
|
||||
print(f"Stored artifact: {ref.path}")
|
||||
print(f"Size: {ref._format_size(ref.size_bytes)}")
|
||||
print(f"Summary: {ref.summary}")
|
||||
print(f"Inline reference: {ref.to_inline()}")
|
||||
|
||||
# Demonstrate tail operation
|
||||
print("\n--- Tail (last 5 lines) ---")
|
||||
tail = store.tail(ref, lines=5)
|
||||
print(tail)
|
||||
|
||||
# Demonstrate grep operation
|
||||
print("\n--- Grep for 'Item 50' ---")
|
||||
matches = store.grep(ref, pattern="Item 50")
|
||||
for match in matches[:3]:
|
||||
print(f"Line {match.line_number}: {match.line_content[:60]}...")
|
||||
|
||||
# List all artifacts
|
||||
print("\n--- List artifacts ---")
|
||||
artifacts = store.list_artifacts(run_id="run_001")
|
||||
for art in artifacts:
|
||||
print(f" - {art.artifact_id}: {art.tool_name} ({art._format_size(art.size_bytes)})")
|
||||
|
||||
|
||||
def example_output_queue():
|
||||
"""Demonstrate automatic tool output queuing (previously called spooling)."""
|
||||
print("\n=== Output Queue Example ===\n")
|
||||
|
||||
from praisonai.context import OutputQueue
|
||||
from praisonaiagents.context.artifacts import ArtifactMetadata, ArtifactRef, QueueConfig
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create output queue with 1KB threshold (for demo)
|
||||
config = QueueConfig(inline_max_bytes=1024)
|
||||
queue = OutputQueue(base_dir=tmpdir, config=config)
|
||||
|
||||
# Small output - stays inline (not queued to artifact)
|
||||
small_output = "Hello, world!"
|
||||
metadata = ArtifactMetadata(agent_id="agent1", run_id="run1")
|
||||
result = queue.process(small_output, metadata)
|
||||
print(f"Small output ({len(small_output)} bytes): {'Queued' if isinstance(result, ArtifactRef) else 'Inline'}")
|
||||
|
||||
# Large output - automatically queued to artifact
|
||||
large_output = "x" * 2000
|
||||
result = queue.process(large_output, metadata)
|
||||
print(f"Large output ({len(large_output)} bytes): {'Queued' if isinstance(result, ArtifactRef) else 'Inline'}")
|
||||
|
||||
if isinstance(result, ArtifactRef):
|
||||
print(f" Artifact path: {result.path}")
|
||||
|
||||
|
||||
def example_history_store():
|
||||
"""Demonstrate conversation history persistence."""
|
||||
print("\n=== History Store Example ===\n")
|
||||
|
||||
from praisonai.context import HistoryStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = HistoryStore(base_dir=tmpdir)
|
||||
|
||||
# Simulate a conversation
|
||||
messages = [
|
||||
{"role": "user", "content": "What is the capital of France?"},
|
||||
{"role": "assistant", "content": "The capital of France is Paris."},
|
||||
{"role": "user", "content": "What about Germany?"},
|
||||
{"role": "assistant", "content": "The capital of Germany is Berlin."},
|
||||
{"role": "user", "content": "And what about the error in my code?"},
|
||||
{"role": "assistant", "content": "I see an error on line 42. The variable is undefined."},
|
||||
]
|
||||
|
||||
for msg in messages:
|
||||
store.append(msg, agent_id="assistant", run_id="session_001")
|
||||
|
||||
# Get history reference
|
||||
ref = store.get_ref(agent_id="assistant", run_id="session_001")
|
||||
print(f"History stored: {ref.summary}")
|
||||
|
||||
# Search history
|
||||
print("\n--- Search for 'capital' ---")
|
||||
matches = store.search("capital", agent_id="assistant", run_id="session_001")
|
||||
for msg in matches:
|
||||
print(f" [{msg['role']}]: {msg['content'][:50]}...")
|
||||
|
||||
# Get last messages
|
||||
print("\n--- Last 2 messages ---")
|
||||
recent = store.get_last_messages(agent_id="assistant", run_id="session_001", count=2)
|
||||
for msg in recent:
|
||||
print(f" [{msg['role']}]: {msg['content'][:50]}...")
|
||||
|
||||
|
||||
def example_terminal_logger():
|
||||
"""Demonstrate terminal session logging."""
|
||||
print("\n=== Terminal Logger Example ===\n")
|
||||
|
||||
from praisonai.context import TerminalLogger
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
logger = TerminalLogger(base_dir=tmpdir)
|
||||
|
||||
# Log some commands
|
||||
commands = [
|
||||
("ls -la", "total 100\ndrwxr-xr-x 5 user user 160 Jan 1 00:00 .\n-rw-r--r-- 1 user user 1234 Jan 1 00:00 file.txt", 0),
|
||||
("cat file.txt", "Hello, world!\nThis is a test file.", 0),
|
||||
("grep error log.txt", "Error: Something went wrong\nError: Another issue", 0),
|
||||
("invalid_command", "command not found: invalid_command", 127),
|
||||
]
|
||||
|
||||
for cmd, output, exit_code in commands:
|
||||
ref = logger.log_command(
|
||||
command=cmd,
|
||||
output=output,
|
||||
exit_code=exit_code,
|
||||
agent_id="shell_agent",
|
||||
run_id="session_001",
|
||||
)
|
||||
|
||||
# Get session reference
|
||||
ref = logger.get_session_ref(agent_id="shell_agent", run_id="session_001")
|
||||
print(f"Terminal session: {ref.summary}")
|
||||
|
||||
# Tail the session
|
||||
print("\n--- Tail (last 10 lines) ---")
|
||||
tail = logger.tail_session(agent_id="shell_agent", run_id="session_001", lines=10)
|
||||
print(tail)
|
||||
|
||||
# Search for errors
|
||||
print("\n--- Grep for 'Error' ---")
|
||||
matches = logger.grep_session("Error", agent_id="shell_agent", run_id="session_001")
|
||||
for match in matches:
|
||||
print(f" Line {match.line_number}: {match.line_content}")
|
||||
|
||||
# Get command list
|
||||
print("\n--- Commands executed ---")
|
||||
cmds = logger.get_commands(agent_id="shell_agent", run_id="session_001")
|
||||
for cmd in cmds:
|
||||
status = "✓" if cmd["exit_code"] == 0 else "✗"
|
||||
print(f" {status} $ {cmd['command']}")
|
||||
|
||||
|
||||
def example_setup_dynamic_context():
|
||||
"""Demonstrate the convenience setup function."""
|
||||
print("\n=== Setup Dynamic Context Example ===\n")
|
||||
|
||||
from praisonai.context import setup_dynamic_context
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Set up dynamic context with all features
|
||||
ctx = setup_dynamic_context(
|
||||
base_dir=tmpdir,
|
||||
inline_max_kb=16, # 16KB threshold
|
||||
redact_secrets=True,
|
||||
history_enabled=True,
|
||||
terminal_logging=True,
|
||||
)
|
||||
|
||||
print(f"Run ID: {ctx.run_id}")
|
||||
print(f"Base directory: {ctx.config.base_dir}")
|
||||
|
||||
# Get tools for agent
|
||||
tools = ctx.get_tools()
|
||||
print(f"\nAvailable tools ({len(tools)}):")
|
||||
for tool in tools:
|
||||
print(f" - {tool.__name__}: {tool.__doc__.split(chr(10))[0] if tool.__doc__ else 'No description'}")
|
||||
|
||||
# Get middleware
|
||||
middleware = ctx.get_middleware()
|
||||
print(f"\nMiddleware: {middleware.__name__}")
|
||||
|
||||
|
||||
def example_agent_integration():
|
||||
"""Show how to integrate with Agent (conceptual)."""
|
||||
print("\n=== Agent Integration Example (Conceptual) ===\n")
|
||||
|
||||
print("""
|
||||
# To integrate Dynamic Context Discovery with an Agent:
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.context import setup_dynamic_context
|
||||
|
||||
# Set up dynamic context
|
||||
ctx = setup_dynamic_context(
|
||||
inline_max_kb=32,
|
||||
history_enabled=True,
|
||||
)
|
||||
|
||||
# Create agent with dynamic context tools and middleware
|
||||
agent = Agent(
|
||||
name="DataAnalyst",
|
||||
instructions="You are a data analyst. Use artifact tools to explore large outputs.",
|
||||
tools=ctx.get_tools(), # Adds artifact_tail, artifact_grep, etc.
|
||||
hooks=[ctx.get_middleware()], # Auto-queues large tool outputs
|
||||
)
|
||||
|
||||
# Now when the agent uses tools that produce large outputs,
|
||||
# they will be automatically queued to artifacts.
|
||||
# The agent can then use artifact_tail, artifact_grep, etc.
|
||||
# to explore the data without loading it all into context.
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("Dynamic Context Discovery Examples")
|
||||
print("=" * 60)
|
||||
|
||||
example_artifact_store()
|
||||
example_output_queue()
|
||||
example_history_store()
|
||||
example_terminal_logger()
|
||||
example_setup_dynamic_context()
|
||||
example_agent_integration()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("All examples completed successfully!")
|
||||
print("=" * 60)
|
||||
@@ -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.3.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.3.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.8.2.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.8.2.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
@@ -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,6 +127,7 @@ def chat_main(
|
||||
),
|
||||
profile: bool = typer.Option(False, "--profile", help="Enable CLI profiling (timing breakdown)"),
|
||||
profile_deep: bool = typer.Option(False, "--profile-deep", help="Enable deep profiling (cProfile stats, higher overhead)"),
|
||||
debug: bool = typer.Option(False, "--debug", help="Enable debug logging to ~/.praisonai/async_tui_debug.log"),
|
||||
# UI backend selection
|
||||
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)"),
|
||||
@@ -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_backend,
|
||||
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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -224,3 +224,191 @@ def context_export(
|
||||
except ImportError as e:
|
||||
typer.echo(f"Error: Context module not available: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Artifact Commands (Dynamic Context Discovery)
|
||||
# =============================================================================
|
||||
|
||||
artifacts_app = typer.Typer(help="Artifact management for dynamic context discovery")
|
||||
app.add_typer(artifacts_app, name="artifacts")
|
||||
|
||||
|
||||
@artifacts_app.command("list")
|
||||
def artifacts_list(
|
||||
run_id: str = typer.Option(None, "--run-id", "-r", help="Filter by run ID"),
|
||||
agent_id: str = typer.Option(None, "--agent-id", "-a", help="Filter by agent ID"),
|
||||
tool_name: str = typer.Option(None, "--tool", "-t", help="Filter by tool name"),
|
||||
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
||||
):
|
||||
"""List artifacts."""
|
||||
try:
|
||||
from praisonai.context import FileSystemArtifactStore
|
||||
import json as json_module
|
||||
|
||||
store = FileSystemArtifactStore()
|
||||
artifacts = store.list_artifacts(
|
||||
run_id=run_id,
|
||||
agent_id=agent_id,
|
||||
tool_name=tool_name,
|
||||
)
|
||||
|
||||
if json_output:
|
||||
data = [a.to_dict() for a in artifacts]
|
||||
print(json_module.dumps(data, indent=2))
|
||||
else:
|
||||
if not artifacts:
|
||||
typer.echo("No artifacts found.")
|
||||
return
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
table = Table(title=f"Artifacts ({len(artifacts)} found)")
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Tool", style="green")
|
||||
table.add_column("Size", justify="right")
|
||||
table.add_column("Summary")
|
||||
table.add_column("Path", style="dim")
|
||||
|
||||
for ref in artifacts:
|
||||
size_str = ref._format_size(ref.size_bytes)
|
||||
table.add_row(
|
||||
ref.artifact_id,
|
||||
ref.tool_name or "-",
|
||||
size_str,
|
||||
ref.summary[:40] + "..." if len(ref.summary) > 40 else ref.summary,
|
||||
ref.path,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
except ImportError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@artifacts_app.command("show")
|
||||
def artifacts_show(
|
||||
artifact_path: str = typer.Argument(..., help="Path to artifact"),
|
||||
lines: int = typer.Option(None, "--lines", "-n", help="Number of lines to show"),
|
||||
):
|
||||
"""Show artifact content."""
|
||||
try:
|
||||
from praisonai.context import FileSystemArtifactStore
|
||||
from praisonaiagents.context.artifacts import ArtifactRef
|
||||
|
||||
store = FileSystemArtifactStore()
|
||||
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
|
||||
|
||||
if lines:
|
||||
content = store.head(ref, lines=lines)
|
||||
else:
|
||||
content = store.load(ref)
|
||||
if not isinstance(content, str):
|
||||
import json
|
||||
content = json.dumps(content, indent=2)
|
||||
|
||||
typer.echo(content)
|
||||
|
||||
except FileNotFoundError:
|
||||
typer.echo(f"Artifact not found: {artifact_path}", err=True)
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@artifacts_app.command("tail")
|
||||
def artifacts_tail(
|
||||
artifact_path: str = typer.Argument(..., help="Path to artifact"),
|
||||
lines: int = typer.Option(50, "--lines", "-n", help="Number of lines"),
|
||||
):
|
||||
"""Get last N lines of artifact."""
|
||||
try:
|
||||
from praisonai.context import FileSystemArtifactStore
|
||||
from praisonaiagents.context.artifacts import ArtifactRef
|
||||
|
||||
store = FileSystemArtifactStore()
|
||||
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
|
||||
content = store.tail(ref, lines=lines)
|
||||
typer.echo(content)
|
||||
|
||||
except FileNotFoundError:
|
||||
typer.echo(f"Artifact not found: {artifact_path}", err=True)
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@artifacts_app.command("grep")
|
||||
def artifacts_grep(
|
||||
artifact_path: str = typer.Argument(..., help="Path to artifact"),
|
||||
pattern: str = typer.Argument(..., help="Search pattern (regex)"),
|
||||
context_lines: int = typer.Option(2, "--context", "-C", help="Context lines"),
|
||||
max_matches: int = typer.Option(50, "--max", "-m", help="Max matches"),
|
||||
):
|
||||
"""Search for pattern in artifact."""
|
||||
try:
|
||||
from praisonai.context import FileSystemArtifactStore
|
||||
from praisonaiagents.context.artifacts import ArtifactRef
|
||||
|
||||
store = FileSystemArtifactStore()
|
||||
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
|
||||
matches = store.grep(ref, pattern=pattern, context_lines=context_lines, max_matches=max_matches)
|
||||
|
||||
if not matches:
|
||||
typer.echo(f"No matches found for: {pattern}")
|
||||
return
|
||||
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
|
||||
console.print(f"[bold]Found {len(matches)} matches:[/bold]")
|
||||
for match in matches:
|
||||
console.print(f"\n[cyan]--- Line {match.line_number} ---[/cyan]")
|
||||
for ctx in match.context_before:
|
||||
console.print(f" {ctx}", style="dim")
|
||||
console.print(f"[green]> {match.line_content}[/green]")
|
||||
for ctx in match.context_after:
|
||||
console.print(f" {ctx}", style="dim")
|
||||
|
||||
except FileNotFoundError:
|
||||
typer.echo(f"Artifact not found: {artifact_path}", err=True)
|
||||
raise typer.Exit(1)
|
||||
except ValueError as e:
|
||||
typer.echo(f"Invalid pattern: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@artifacts_app.command("export")
|
||||
def artifacts_export(
|
||||
artifact_path: str = typer.Argument(..., help="Path to artifact"),
|
||||
output: str = typer.Option(None, "--output", "-o", help="Output file path"),
|
||||
):
|
||||
"""Export artifact to file."""
|
||||
try:
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
src_path = Path(artifact_path)
|
||||
if not src_path.exists():
|
||||
typer.echo(f"Artifact not found: {artifact_path}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if output:
|
||||
dst_path = Path(output)
|
||||
else:
|
||||
dst_path = Path.cwd() / src_path.name
|
||||
|
||||
shutil.copy2(src_path, dst_path)
|
||||
typer.echo(f"Exported to: {dst_path}")
|
||||
|
||||
except Exception as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
@@ -203,6 +203,164 @@ def mcp_test(
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("sync")
|
||||
def mcp_sync(
|
||||
server: Optional[str] = typer.Argument(None, help="Server name (None for all)"),
|
||||
):
|
||||
"""Sync tool schemas from MCP servers to local index."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
from praisonai.mcp_server.tool_index import MCPToolIndex
|
||||
|
||||
index = MCPToolIndex()
|
||||
loader = get_config_loader()
|
||||
config = loader.load()
|
||||
|
||||
servers_to_sync = []
|
||||
if server:
|
||||
if server not in config.mcp.servers:
|
||||
output.print_error(f"Server not found: {server}")
|
||||
raise typer.Exit(1)
|
||||
servers_to_sync = [server]
|
||||
else:
|
||||
servers_to_sync = list(config.mcp.servers.keys())
|
||||
|
||||
if not servers_to_sync:
|
||||
output.print_info("No MCP servers configured")
|
||||
return
|
||||
|
||||
total_tools = 0
|
||||
for srv in servers_to_sync:
|
||||
output.print_info(f"Syncing {srv}...")
|
||||
# Note: This would need actual MCP connection to fetch tools
|
||||
# For now, we just create the index structure
|
||||
count = index.sync(srv, tools=[])
|
||||
total_tools += count
|
||||
output.print(f" Synced {count} tools")
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({"synced_servers": servers_to_sync, "total_tools": total_tools})
|
||||
else:
|
||||
output.print_success(f"Synced {len(servers_to_sync)} servers, {total_tools} tools total")
|
||||
|
||||
except ImportError as e:
|
||||
output.print_error(f"Error: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("tools")
|
||||
def mcp_tools(
|
||||
server: Optional[str] = typer.Argument(None, help="Server name (None for all)"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""List indexed MCP tools."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
from praisonai.mcp_server.tool_index import MCPToolIndex
|
||||
|
||||
index = MCPToolIndex()
|
||||
|
||||
if server:
|
||||
tools = index.list_tools(server)
|
||||
if not tools:
|
||||
output.print_info(f"No tools indexed for server: {server}")
|
||||
output.print("\nRun 'praisonai mcp sync' to index tools")
|
||||
return
|
||||
else:
|
||||
tools = index.get_all_tools()
|
||||
if not tools:
|
||||
output.print_info("No tools indexed")
|
||||
output.print("\nRun 'praisonai mcp sync' to index tools")
|
||||
return
|
||||
|
||||
if output.is_json_mode or json_output:
|
||||
output.print_json({"tools": [t.to_dict() for t in tools]})
|
||||
else:
|
||||
headers = ["Server", "Tool", "Description"]
|
||||
rows = [[t.server, t.name, t.hint[:50] + "..." if len(t.hint) > 50 else t.hint] for t in tools]
|
||||
output.print_table(headers, rows, title=f"MCP Tools ({len(tools)} total)")
|
||||
|
||||
except ImportError as e:
|
||||
output.print_error(f"Error: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("describe")
|
||||
def mcp_describe(
|
||||
server: str = typer.Argument(..., help="Server name"),
|
||||
tool: str = typer.Argument(..., help="Tool name"),
|
||||
):
|
||||
"""Show full schema for an MCP tool."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
from praisonai.mcp_server.tool_index import MCPToolIndex
|
||||
import json
|
||||
|
||||
index = MCPToolIndex()
|
||||
schema = index.describe(server, tool)
|
||||
|
||||
if not schema:
|
||||
output.print_error(f"Tool not found: {server}/{tool}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json(schema)
|
||||
else:
|
||||
output.print(json.dumps(schema, indent=2))
|
||||
|
||||
except ImportError as e:
|
||||
output.print_error(f"Error: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("status")
|
||||
def mcp_status(
|
||||
server: Optional[str] = typer.Argument(None, help="Server name (None for all)"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Show status of MCP servers."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
from praisonai.mcp_server.tool_index import MCPToolIndex
|
||||
from datetime import datetime
|
||||
|
||||
index = MCPToolIndex()
|
||||
|
||||
servers = [server] if server else index.list_servers()
|
||||
|
||||
if not servers:
|
||||
output.print_info("No servers indexed")
|
||||
return
|
||||
|
||||
statuses = []
|
||||
for srv in servers:
|
||||
status = index.get_status(srv)
|
||||
if status:
|
||||
statuses.append(status)
|
||||
|
||||
if output.is_json_mode or json_output:
|
||||
output.print_json({"statuses": [s.to_dict() for s in statuses]})
|
||||
else:
|
||||
headers = ["Server", "Status", "Tools", "Last Sync"]
|
||||
rows = []
|
||||
for s in statuses:
|
||||
status_str = "✓ Available" if s.available else "✗ Unavailable"
|
||||
if s.auth_required:
|
||||
status_str += " (auth required)"
|
||||
last_sync = datetime.fromtimestamp(s.last_sync).strftime("%Y-%m-%d %H:%M") if s.last_sync else "Never"
|
||||
rows.append([s.server, status_str, str(s.tool_count), last_sync])
|
||||
|
||||
output.print_table(headers, rows, title="MCP Server Status")
|
||||
|
||||
except ImportError as e:
|
||||
output.print_error(f"Error: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def mcp_callback(ctx: typer.Context):
|
||||
"""Show MCP help or list servers."""
|
||||
|
||||
@@ -107,3 +107,192 @@ def memory_clear(
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
# Learn subcommand group
|
||||
learn_app = typer.Typer(help="Continuous learning management")
|
||||
app.add_typer(learn_app, name="learn")
|
||||
|
||||
|
||||
@learn_app.command("status")
|
||||
def learn_status(
|
||||
user_id: str = typer.Option("default", "--user-id", "-u", help="User ID"),
|
||||
scope: str = typer.Option("private", "--scope", "-s", help="Scope (private/shared)"),
|
||||
):
|
||||
"""Show learning status and statistics."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
from praisonaiagents.config.feature_configs import LearnConfig
|
||||
|
||||
config = LearnConfig()
|
||||
manager = LearnManager(config=config, user_id=user_id)
|
||||
stats = manager.get_stats()
|
||||
|
||||
typer.echo(f"\n📚 Learning Status for user: {user_id}")
|
||||
typer.echo(f" Scope: {scope}")
|
||||
typer.echo("-" * 40)
|
||||
|
||||
if not stats:
|
||||
typer.echo("No learning stores enabled.")
|
||||
return
|
||||
|
||||
total = 0
|
||||
for store_name, count in stats.items():
|
||||
typer.echo(f" {store_name}: {count} entries")
|
||||
total += count
|
||||
|
||||
typer.echo("-" * 40)
|
||||
typer.echo(f" Total: {total} entries")
|
||||
|
||||
|
||||
@learn_app.command("show")
|
||||
def learn_show(
|
||||
store: str = typer.Argument("all", help="Store to show (persona/insights/threads/patterns/decisions/feedback/improvements/all)"),
|
||||
user_id: str = typer.Option("default", "--user-id", "-u", help="User ID"),
|
||||
limit: int = typer.Option(10, "--limit", "-l", help="Number of entries to show"),
|
||||
):
|
||||
"""Show learned entries."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
from praisonaiagents.config.feature_configs import LearnConfig
|
||||
|
||||
config = LearnConfig(
|
||||
persona=True,
|
||||
insights=True,
|
||||
thread=True,
|
||||
patterns=True,
|
||||
decisions=True,
|
||||
feedback=True,
|
||||
improvements=True,
|
||||
)
|
||||
manager = LearnManager(config=config, user_id=user_id)
|
||||
|
||||
if store == "all":
|
||||
context = manager.get_learning_context(limit_per_store=limit)
|
||||
for store_name, entries in context.items():
|
||||
if entries:
|
||||
typer.echo(f"\n📁 {store_name.upper()}")
|
||||
typer.echo("-" * 40)
|
||||
for entry in entries:
|
||||
typer.echo(f" [{entry['id'][:20]}...] {entry['content'][:60]}...")
|
||||
else:
|
||||
context = manager.get_learning_context(limit_per_store=limit)
|
||||
if store in context:
|
||||
entries = context[store]
|
||||
typer.echo(f"\n📁 {store.upper()}")
|
||||
typer.echo("-" * 40)
|
||||
for entry in entries:
|
||||
typer.echo(f" [{entry['id'][:20]}...] {entry['content']}")
|
||||
else:
|
||||
typer.echo(f"Store '{store}' not found or empty.")
|
||||
|
||||
|
||||
@learn_app.command("add")
|
||||
def learn_add(
|
||||
content: str = typer.Argument(..., help="Content to learn"),
|
||||
store: str = typer.Option("insights", "--store", "-s", help="Store type (persona/insights/patterns)"),
|
||||
user_id: str = typer.Option("default", "--user-id", "-u", help="User ID"),
|
||||
):
|
||||
"""Add a learning entry."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
from praisonaiagents.config.feature_configs import LearnConfig
|
||||
|
||||
config = LearnConfig(
|
||||
persona=(store == "persona"),
|
||||
insights=(store == "insights"),
|
||||
patterns=(store == "patterns"),
|
||||
thread=(store == "threads"),
|
||||
decisions=(store == "decisions"),
|
||||
feedback=(store == "feedback"),
|
||||
improvements=(store == "improvements"),
|
||||
)
|
||||
manager = LearnManager(config=config, user_id=user_id)
|
||||
|
||||
entry = None
|
||||
if store == "persona":
|
||||
entry = manager.capture_persona(content)
|
||||
elif store == "insights":
|
||||
entry = manager.capture_insight(content)
|
||||
elif store == "patterns":
|
||||
entry = manager.capture_pattern(content)
|
||||
elif store == "decisions":
|
||||
entry = manager.capture_decision(content)
|
||||
elif store == "feedback":
|
||||
entry = manager.capture_feedback(content)
|
||||
elif store == "improvements":
|
||||
entry = manager.capture_improvement(content)
|
||||
else:
|
||||
typer.echo(f"Unknown store: {store}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if entry:
|
||||
typer.echo(f"✅ Added to {store}: {entry.id}")
|
||||
else:
|
||||
typer.echo(f"❌ Failed to add to {store}")
|
||||
|
||||
|
||||
@learn_app.command("search")
|
||||
def learn_search(
|
||||
query: str = typer.Argument(..., help="Search query"),
|
||||
user_id: str = typer.Option("default", "--user-id", "-u", help="User ID"),
|
||||
limit: int = typer.Option(10, "--limit", "-l", help="Max results per store"),
|
||||
):
|
||||
"""Search across all learning stores."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
from praisonaiagents.config.feature_configs import LearnConfig
|
||||
|
||||
config = LearnConfig(
|
||||
persona=True,
|
||||
insights=True,
|
||||
thread=True,
|
||||
patterns=True,
|
||||
decisions=True,
|
||||
feedback=True,
|
||||
improvements=True,
|
||||
)
|
||||
manager = LearnManager(config=config, user_id=user_id)
|
||||
|
||||
results = manager.search(query, limit=limit)
|
||||
|
||||
if not results:
|
||||
typer.echo(f"No results found for: {query}")
|
||||
return
|
||||
|
||||
typer.echo(f"\n🔍 Search results for: {query}")
|
||||
for store_name, entries in results.items():
|
||||
typer.echo(f"\n📁 {store_name.upper()}")
|
||||
typer.echo("-" * 40)
|
||||
for entry in entries:
|
||||
typer.echo(f" {entry['content'][:80]}...")
|
||||
|
||||
|
||||
@learn_app.command("clear")
|
||||
def learn_clear(
|
||||
store: str = typer.Argument("all", help="Store to clear (or 'all')"),
|
||||
user_id: str = typer.Option("default", "--user-id", "-u", help="User ID"),
|
||||
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
||||
):
|
||||
"""Clear learning entries."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
from praisonaiagents.config.feature_configs import LearnConfig
|
||||
|
||||
if not force:
|
||||
confirm = typer.confirm(f"Clear {store} learning data for user {user_id}?")
|
||||
if not confirm:
|
||||
typer.echo("Cancelled.")
|
||||
return
|
||||
|
||||
config = LearnConfig(
|
||||
persona=True,
|
||||
insights=True,
|
||||
thread=True,
|
||||
patterns=True,
|
||||
decisions=True,
|
||||
feedback=True,
|
||||
improvements=True,
|
||||
)
|
||||
manager = LearnManager(config=config, user_id=user_id)
|
||||
|
||||
cleared = manager.clear_all()
|
||||
|
||||
typer.echo("\n🗑️ Cleared learning data:")
|
||||
for store_name, count in cleared.items():
|
||||
typer.echo(f" {store_name}: {count} entries")
|
||||
@@ -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()
|
||||
|
||||
@@ -97,3 +97,121 @@ def skills_install(
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("search")
|
||||
def skills_search(
|
||||
query: str = typer.Argument(..., help="Search query"),
|
||||
skill_dirs: str = typer.Option(None, "--dirs", "-d", help="Comma-separated skill directories"),
|
||||
):
|
||||
"""Search skills by name, description, or content."""
|
||||
try:
|
||||
from praisonaiagents.skills import discover_skills
|
||||
import re
|
||||
|
||||
dirs = skill_dirs.split(",") if skill_dirs else None
|
||||
skills = discover_skills(dirs, include_defaults=True)
|
||||
|
||||
pattern = re.compile(query, re.IGNORECASE)
|
||||
matches = []
|
||||
|
||||
for skill in skills:
|
||||
# Search in name and description
|
||||
if pattern.search(skill.name) or pattern.search(skill.description):
|
||||
matches.append(skill)
|
||||
continue
|
||||
|
||||
# Search in SKILL.md content if path available
|
||||
if skill.path:
|
||||
skill_md = skill.path / "SKILL.md"
|
||||
if skill_md.exists():
|
||||
content = skill_md.read_text()
|
||||
if pattern.search(content):
|
||||
matches.append(skill)
|
||||
|
||||
if not matches:
|
||||
typer.echo(f"No skills found matching: {query}")
|
||||
return
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
table = Table(title=f"Skills matching '{query}' ({len(matches)} found)")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Description")
|
||||
table.add_column("Path", style="dim")
|
||||
|
||||
for skill in matches:
|
||||
table.add_row(
|
||||
skill.name,
|
||||
skill.description[:60] + "..." if len(skill.description) > 60 else skill.description,
|
||||
str(skill.path) if skill.path else "-",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
except ImportError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("info")
|
||||
def skills_info(
|
||||
name: str = typer.Argument(..., help="Skill name or path"),
|
||||
):
|
||||
"""Show detailed information about a skill."""
|
||||
try:
|
||||
from praisonaiagents.skills import discover_skills, SkillLoader
|
||||
from pathlib import Path
|
||||
|
||||
# Try as path first
|
||||
path = Path(name).expanduser()
|
||||
if path.exists() and path.is_dir():
|
||||
loader = SkillLoader()
|
||||
skill = loader.load_metadata(str(path))
|
||||
if skill:
|
||||
loader.activate(skill)
|
||||
else:
|
||||
# Search by name
|
||||
skills = discover_skills(include_defaults=True)
|
||||
skill = None
|
||||
for s in skills:
|
||||
if s.name == name:
|
||||
loader = SkillLoader()
|
||||
skill = loader.load_metadata(str(s.path))
|
||||
if skill:
|
||||
loader.activate(skill)
|
||||
break
|
||||
|
||||
if not skill:
|
||||
typer.echo(f"Skill not found: {name}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.markdown import Markdown
|
||||
|
||||
console = Console()
|
||||
|
||||
# Show metadata
|
||||
console.print(f"\n[bold cyan]{skill.properties.name}[/bold cyan]")
|
||||
console.print(f"[dim]{skill.properties.description}[/dim]\n")
|
||||
|
||||
if skill.properties.license:
|
||||
console.print(f"License: {skill.properties.license}")
|
||||
if skill.properties.compatibility:
|
||||
console.print(f"Compatibility: {skill.properties.compatibility}")
|
||||
if skill.properties.allowed_tools:
|
||||
console.print(f"Allowed Tools: {skill.properties.allowed_tools}")
|
||||
if skill.properties.metadata:
|
||||
console.print(f"Metadata: {skill.properties.metadata}")
|
||||
|
||||
# Show instructions if loaded
|
||||
if skill.instructions:
|
||||
console.print("\n[bold]Instructions:[/bold]")
|
||||
console.print(Panel(Markdown(skill.instructions[:2000])))
|
||||
|
||||
except ImportError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Dynamic Context Discovery for PraisonAI.
|
||||
|
||||
This module provides filesystem-backed implementations for:
|
||||
- Artifact storage (tool outputs, history, terminal logs)
|
||||
- Tool output queuing middleware
|
||||
- History persistence with search
|
||||
- Terminal session logging
|
||||
|
||||
Zero Performance Impact:
|
||||
- All imports are lazy loaded
|
||||
- Features only activate when explicitly enabled
|
||||
- No overhead when not used
|
||||
|
||||
Usage:
|
||||
from praisonai.context import (
|
||||
FileSystemArtifactStore,
|
||||
OutputQueue,
|
||||
HistoryStore,
|
||||
TerminalLogger,
|
||||
)
|
||||
|
||||
# Create artifact store
|
||||
store = FileSystemArtifactStore(base_dir="~/.praison/runs")
|
||||
|
||||
# Create output queue
|
||||
queue = OutputQueue(store=store, inline_max_kb=32)
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
# Artifact Store
|
||||
"FileSystemArtifactStore",
|
||||
# Output Queue
|
||||
"OutputQueue",
|
||||
"create_queue_middleware",
|
||||
"create_artifact_tools",
|
||||
# History
|
||||
"HistoryStore",
|
||||
"create_history_tools",
|
||||
"create_history_pointer",
|
||||
# Terminal
|
||||
"TerminalLogger",
|
||||
"create_terminal_tools",
|
||||
# Config
|
||||
"DynamicContextConfig",
|
||||
# Setup
|
||||
"DynamicContextSetup",
|
||||
"setup_dynamic_context",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load module components."""
|
||||
if name == "FileSystemArtifactStore":
|
||||
from .artifact_store import FileSystemArtifactStore
|
||||
return FileSystemArtifactStore
|
||||
|
||||
# New user-friendly names
|
||||
if name == "OutputQueue":
|
||||
from .queue import OutputQueue
|
||||
return OutputQueue
|
||||
|
||||
if name == "create_queue_middleware":
|
||||
from .queue import create_queue_middleware
|
||||
return create_queue_middleware
|
||||
|
||||
|
||||
if name == "create_artifact_tools":
|
||||
from .queue import create_artifact_tools
|
||||
return create_artifact_tools
|
||||
|
||||
if name == "HistoryStore":
|
||||
from .history_store import HistoryStore
|
||||
return HistoryStore
|
||||
|
||||
if name == "create_history_tools":
|
||||
from .history_store import create_history_tools
|
||||
return create_history_tools
|
||||
|
||||
if name == "create_history_pointer":
|
||||
from .history_store import create_history_pointer
|
||||
return create_history_pointer
|
||||
|
||||
if name == "TerminalLogger":
|
||||
from .terminal_logger import TerminalLogger
|
||||
return TerminalLogger
|
||||
|
||||
if name == "create_terminal_tools":
|
||||
from .terminal_logger import create_terminal_tools
|
||||
return create_terminal_tools
|
||||
|
||||
if name == "DynamicContextConfig":
|
||||
from .config import DynamicContextConfig
|
||||
return DynamicContextConfig
|
||||
|
||||
if name == "DynamicContextSetup":
|
||||
from .setup import DynamicContextSetup
|
||||
return DynamicContextSetup
|
||||
|
||||
if name == "setup_dynamic_context":
|
||||
from .setup import setup_dynamic_context
|
||||
return setup_dynamic_context
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,513 @@
|
||||
"""
|
||||
Filesystem-backed Artifact Store for Dynamic Context Discovery.
|
||||
|
||||
Implements ArtifactStoreProtocol with:
|
||||
- Deterministic storage layout per run/agent
|
||||
- Checksum verification
|
||||
- Secret redaction
|
||||
- Efficient tail/grep/chunk operations
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from praisonaiagents.context.artifacts import (
|
||||
ArtifactRef,
|
||||
ArtifactMetadata,
|
||||
GrepMatch,
|
||||
QueueConfig,
|
||||
compute_checksum,
|
||||
generate_summary,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileSystemArtifactStore:
|
||||
"""
|
||||
Filesystem-backed artifact storage.
|
||||
|
||||
Stores artifacts in a deterministic layout:
|
||||
~/.praison/runs/{run_id}/artifacts/{agent_id}/{artifact_id}.{ext}
|
||||
|
||||
Features:
|
||||
- Automatic MIME type detection
|
||||
- Secret redaction (configurable)
|
||||
- Checksum verification
|
||||
- Efficient tail/grep for large files
|
||||
|
||||
Example:
|
||||
store = FileSystemArtifactStore(base_dir="~/.praison/runs")
|
||||
|
||||
ref = store.store(
|
||||
content={"data": [1, 2, 3]},
|
||||
metadata=ArtifactMetadata(agent_id="agent1", run_id="run123")
|
||||
)
|
||||
|
||||
# Later, retrieve
|
||||
content = store.load(ref)
|
||||
last_lines = store.tail(ref, lines=10)
|
||||
matches = store.grep(ref, pattern="error")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_dir: str = "~/.praison/runs",
|
||||
config: Optional[QueueConfig] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the artifact store.
|
||||
|
||||
Args:
|
||||
base_dir: Base directory for artifact storage
|
||||
config: Queue configuration (optional)
|
||||
"""
|
||||
self.base_dir = Path(base_dir).expanduser().resolve()
|
||||
self.config = config or QueueConfig()
|
||||
self._ensure_base_dir()
|
||||
|
||||
def _ensure_base_dir(self) -> None:
|
||||
"""Create base directory if it doesn't exist."""
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_artifact_dir(self, run_id: str, agent_id: str) -> Path:
|
||||
"""Get the directory for storing artifacts."""
|
||||
artifact_dir = self.base_dir / run_id / "artifacts" / agent_id
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
return artifact_dir
|
||||
|
||||
def _detect_mime_type(self, content: Any) -> str:
|
||||
"""Detect MIME type from content."""
|
||||
if isinstance(content, dict) or isinstance(content, list):
|
||||
return "application/json"
|
||||
elif isinstance(content, bytes):
|
||||
return "application/octet-stream"
|
||||
else:
|
||||
return "text/plain"
|
||||
|
||||
def _get_extension(self, mime_type: str) -> str:
|
||||
"""Get file extension from MIME type."""
|
||||
mime_to_ext = {
|
||||
"application/json": ".json",
|
||||
"text/plain": ".txt",
|
||||
"text/html": ".html",
|
||||
"text/csv": ".csv",
|
||||
"application/xml": ".xml",
|
||||
"application/octet-stream": ".bin",
|
||||
}
|
||||
return mime_to_ext.get(mime_type, ".txt")
|
||||
|
||||
def _serialize_content(self, content: Any, mime_type: str) -> bytes:
|
||||
"""Serialize content to bytes."""
|
||||
if isinstance(content, bytes):
|
||||
return content
|
||||
elif mime_type == "application/json":
|
||||
return json.dumps(content, indent=2, default=str).encode("utf-8")
|
||||
else:
|
||||
return str(content).encode("utf-8")
|
||||
|
||||
def _deserialize_content(self, data: bytes, mime_type: str) -> Any:
|
||||
"""Deserialize bytes to content."""
|
||||
if mime_type == "application/json":
|
||||
return json.loads(data.decode("utf-8"))
|
||||
elif mime_type == "application/octet-stream":
|
||||
return data
|
||||
else:
|
||||
return data.decode("utf-8")
|
||||
|
||||
def _redact_secrets(self, content: str) -> str:
|
||||
"""Redact secrets from content."""
|
||||
if not self.config.redact_secrets:
|
||||
return content
|
||||
|
||||
redacted = content
|
||||
for pattern in self.config.secret_patterns:
|
||||
try:
|
||||
redacted = re.sub(pattern, "[REDACTED]", redacted, flags=re.IGNORECASE)
|
||||
except re.error:
|
||||
logger.warning(f"Invalid regex pattern: {pattern}")
|
||||
|
||||
return redacted
|
||||
|
||||
def store(
|
||||
self,
|
||||
content: Any,
|
||||
metadata: ArtifactMetadata,
|
||||
) -> ArtifactRef:
|
||||
"""
|
||||
Store content as an artifact.
|
||||
|
||||
Args:
|
||||
content: The content to store
|
||||
metadata: Metadata about the artifact
|
||||
|
||||
Returns:
|
||||
ArtifactRef pointing to the stored artifact
|
||||
"""
|
||||
# Generate artifact ID
|
||||
artifact_id = str(uuid.uuid4())[:8]
|
||||
|
||||
# Detect MIME type
|
||||
mime_type = self._detect_mime_type(content)
|
||||
|
||||
# Serialize content
|
||||
data = self._serialize_content(content, mime_type)
|
||||
|
||||
# Redact secrets if text-based
|
||||
if mime_type != "application/octet-stream":
|
||||
data_str = data.decode("utf-8")
|
||||
data_str = self._redact_secrets(data_str)
|
||||
data = data_str.encode("utf-8")
|
||||
|
||||
# Compute checksum
|
||||
checksum = compute_checksum(data, self.config.checksum_algorithm)
|
||||
|
||||
# Generate summary
|
||||
summary = generate_summary(content, self.config.summary_max_chars)
|
||||
|
||||
# Determine file path
|
||||
run_id = metadata.run_id or "default"
|
||||
agent_id = metadata.agent_id or "default"
|
||||
artifact_dir = self._get_artifact_dir(run_id, agent_id)
|
||||
|
||||
ext = self._get_extension(mime_type)
|
||||
filename = f"{artifact_id}{ext}"
|
||||
if metadata.tool_name:
|
||||
filename = f"{metadata.tool_name}_{artifact_id}{ext}"
|
||||
|
||||
file_path = artifact_dir / filename
|
||||
|
||||
# Write content
|
||||
file_path.write_bytes(data)
|
||||
|
||||
# Write metadata sidecar
|
||||
meta_path = file_path.with_suffix(file_path.suffix + ".meta")
|
||||
meta_data = {
|
||||
**metadata.to_dict(),
|
||||
"artifact_id": artifact_id,
|
||||
"mime_type": mime_type,
|
||||
"checksum": checksum,
|
||||
"size_bytes": len(data),
|
||||
"created_at": time.time(),
|
||||
}
|
||||
meta_path.write_text(json.dumps(meta_data, indent=2))
|
||||
|
||||
return ArtifactRef(
|
||||
path=str(file_path),
|
||||
summary=summary,
|
||||
size_bytes=len(data),
|
||||
mime_type=mime_type,
|
||||
checksum=checksum,
|
||||
created_at=meta_data["created_at"],
|
||||
artifact_id=artifact_id,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
tool_name=metadata.tool_name,
|
||||
turn_id=metadata.turn_id,
|
||||
)
|
||||
|
||||
def load(self, ref: ArtifactRef) -> Any:
|
||||
"""
|
||||
Load full content from an artifact.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
|
||||
Returns:
|
||||
The deserialized content
|
||||
"""
|
||||
file_path = Path(ref.path)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"Artifact not found: {ref.path}")
|
||||
|
||||
data = file_path.read_bytes()
|
||||
|
||||
# Verify checksum if available
|
||||
if ref.checksum:
|
||||
actual_checksum = compute_checksum(data, self.config.checksum_algorithm)
|
||||
if actual_checksum != ref.checksum:
|
||||
logger.warning(f"Checksum mismatch for {ref.path}")
|
||||
|
||||
return self._deserialize_content(data, ref.mime_type)
|
||||
|
||||
def tail(self, ref: ArtifactRef, lines: int = 50) -> str:
|
||||
"""
|
||||
Get the last N lines of an artifact.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
lines: Number of lines to return
|
||||
|
||||
Returns:
|
||||
String containing the last N lines
|
||||
"""
|
||||
file_path = Path(ref.path)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"Artifact not found: {ref.path}")
|
||||
|
||||
# Efficient tail for large files
|
||||
with open(file_path, "rb") as f:
|
||||
# Seek to end
|
||||
f.seek(0, 2)
|
||||
file_size = f.tell()
|
||||
|
||||
# Read in chunks from the end
|
||||
chunk_size = 8192
|
||||
found_lines = []
|
||||
position = file_size
|
||||
|
||||
while position > 0 and len(found_lines) < lines + 1:
|
||||
read_size = min(chunk_size, position)
|
||||
position -= read_size
|
||||
f.seek(position)
|
||||
chunk = f.read(read_size).decode("utf-8", errors="replace")
|
||||
|
||||
# Split into lines and prepend
|
||||
chunk_lines = chunk.split("\n")
|
||||
if found_lines:
|
||||
# Merge with previous chunk
|
||||
chunk_lines[-1] += found_lines[0]
|
||||
found_lines = chunk_lines + found_lines[1:]
|
||||
else:
|
||||
found_lines = chunk_lines
|
||||
|
||||
# Return last N lines
|
||||
return "\n".join(found_lines[-lines:])
|
||||
|
||||
def head(self, ref: ArtifactRef, lines: int = 50) -> str:
|
||||
"""
|
||||
Get the first N lines of an artifact.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
lines: Number of lines to return
|
||||
|
||||
Returns:
|
||||
String containing the first N lines
|
||||
"""
|
||||
file_path = Path(ref.path)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"Artifact not found: {ref.path}")
|
||||
|
||||
result_lines = []
|
||||
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
for i, line in enumerate(f):
|
||||
if i >= lines:
|
||||
break
|
||||
result_lines.append(line.rstrip("\n"))
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
def grep(
|
||||
self,
|
||||
ref: ArtifactRef,
|
||||
pattern: str,
|
||||
context_lines: int = 2,
|
||||
max_matches: int = 50,
|
||||
) -> List[GrepMatch]:
|
||||
"""
|
||||
Search for pattern in artifact content.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
pattern: Regex pattern to search for
|
||||
context_lines: Number of context lines before/after match
|
||||
max_matches: Maximum number of matches to return
|
||||
|
||||
Returns:
|
||||
List of GrepMatch objects
|
||||
"""
|
||||
file_path = Path(ref.path)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"Artifact not found: {ref.path}")
|
||||
|
||||
try:
|
||||
regex = re.compile(pattern, re.IGNORECASE)
|
||||
except re.error as e:
|
||||
raise ValueError(f"Invalid regex pattern: {e}")
|
||||
|
||||
matches = []
|
||||
|
||||
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
all_lines = f.readlines()
|
||||
|
||||
for i, line in enumerate(all_lines):
|
||||
if regex.search(line):
|
||||
# Get context
|
||||
start = max(0, i - context_lines)
|
||||
end = min(len(all_lines), i + context_lines + 1)
|
||||
|
||||
match = GrepMatch(
|
||||
line_number=i + 1,
|
||||
line_content=line.rstrip("\n"),
|
||||
context_before=[ln.rstrip("\n") for ln in all_lines[start:i]],
|
||||
context_after=[ln.rstrip("\n") for ln in all_lines[i+1:end]],
|
||||
)
|
||||
matches.append(match)
|
||||
|
||||
if len(matches) >= max_matches:
|
||||
break
|
||||
|
||||
return matches
|
||||
|
||||
def chunk(
|
||||
self,
|
||||
ref: ArtifactRef,
|
||||
start_line: int = 1,
|
||||
end_line: Optional[int] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get a chunk of lines from an artifact.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
start_line: Starting line number (1-indexed)
|
||||
end_line: Ending line number (inclusive), None for end of file
|
||||
|
||||
Returns:
|
||||
String containing the requested lines
|
||||
"""
|
||||
file_path = Path(ref.path)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"Artifact not found: {ref.path}")
|
||||
|
||||
result_lines = []
|
||||
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
for i, line in enumerate(f, start=1):
|
||||
if i < start_line:
|
||||
continue
|
||||
if end_line is not None and i > end_line:
|
||||
break
|
||||
result_lines.append(line.rstrip("\n"))
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
def delete(self, ref: ArtifactRef) -> bool:
|
||||
"""
|
||||
Delete an artifact.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
"""
|
||||
file_path = Path(ref.path)
|
||||
meta_path = file_path.with_suffix(file_path.suffix + ".meta")
|
||||
|
||||
deleted = False
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
deleted = True
|
||||
if meta_path.exists():
|
||||
meta_path.unlink()
|
||||
|
||||
return deleted
|
||||
|
||||
def list_artifacts(
|
||||
self,
|
||||
run_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
tool_name: Optional[str] = None,
|
||||
) -> List[ArtifactRef]:
|
||||
"""
|
||||
List artifacts matching filters.
|
||||
|
||||
Args:
|
||||
run_id: Filter by run ID
|
||||
agent_id: Filter by agent ID
|
||||
tool_name: Filter by tool name
|
||||
|
||||
Returns:
|
||||
List of matching ArtifactRef objects
|
||||
"""
|
||||
artifacts = []
|
||||
|
||||
# Determine search path
|
||||
if run_id:
|
||||
search_paths = [self.base_dir / run_id / "artifacts"]
|
||||
else:
|
||||
search_paths = list(self.base_dir.glob("*/artifacts"))
|
||||
|
||||
for artifacts_dir in search_paths:
|
||||
if not artifacts_dir.exists():
|
||||
continue
|
||||
|
||||
# Search for agent directories
|
||||
if agent_id:
|
||||
agent_dirs = [artifacts_dir / agent_id]
|
||||
else:
|
||||
agent_dirs = list(artifacts_dir.iterdir())
|
||||
|
||||
for agent_dir in agent_dirs:
|
||||
if not agent_dir.is_dir():
|
||||
continue
|
||||
|
||||
# Find all meta files
|
||||
for meta_file in agent_dir.glob("*.meta"):
|
||||
try:
|
||||
meta_data = json.loads(meta_file.read_text())
|
||||
|
||||
# Apply tool_name filter
|
||||
if tool_name and meta_data.get("tool_name") != tool_name:
|
||||
continue
|
||||
|
||||
# Get artifact file path
|
||||
artifact_path = meta_file.with_suffix("")
|
||||
if not artifact_path.exists():
|
||||
continue
|
||||
|
||||
ref = ArtifactRef(
|
||||
path=str(artifact_path),
|
||||
summary=meta_data.get("summary", ""),
|
||||
size_bytes=meta_data.get("size_bytes", 0),
|
||||
mime_type=meta_data.get("mime_type", "application/octet-stream"),
|
||||
checksum=meta_data.get("checksum", ""),
|
||||
created_at=meta_data.get("created_at", 0),
|
||||
artifact_id=meta_data.get("artifact_id", ""),
|
||||
agent_id=meta_data.get("agent_id", ""),
|
||||
run_id=meta_data.get("run_id", ""),
|
||||
tool_name=meta_data.get("tool_name"),
|
||||
turn_id=meta_data.get("turn_id", 0),
|
||||
)
|
||||
artifacts.append(ref)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.warning(f"Failed to read metadata: {meta_file}: {e}")
|
||||
|
||||
# Sort by creation time (newest first)
|
||||
artifacts.sort(key=lambda x: x.created_at, reverse=True)
|
||||
return artifacts
|
||||
|
||||
def get_run_dir(self, run_id: str) -> Path:
|
||||
"""Get the directory for a specific run."""
|
||||
return self.base_dir / run_id
|
||||
|
||||
def cleanup_run(self, run_id: str) -> int:
|
||||
"""
|
||||
Delete all artifacts for a run.
|
||||
|
||||
Args:
|
||||
run_id: Run ID to clean up
|
||||
|
||||
Returns:
|
||||
Number of artifacts deleted
|
||||
"""
|
||||
import shutil
|
||||
|
||||
run_dir = self.base_dir / run_id
|
||||
if not run_dir.exists():
|
||||
return 0
|
||||
|
||||
# Count artifacts before deletion
|
||||
artifacts = self.list_artifacts(run_id=run_id)
|
||||
count = len(artifacts)
|
||||
|
||||
# Remove the entire run directory
|
||||
shutil.rmtree(run_dir)
|
||||
|
||||
return count
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Configuration for Dynamic Context Discovery.
|
||||
|
||||
Provides a unified configuration class for all dynamic context features.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from praisonaiagents.context.artifacts import QueueConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class DynamicContextConfig:
|
||||
"""
|
||||
Configuration for Dynamic Context Discovery features.
|
||||
|
||||
Controls:
|
||||
- Artifact storage location
|
||||
- Tool output queuing thresholds
|
||||
- History persistence
|
||||
- Terminal logging
|
||||
- Secret redaction
|
||||
|
||||
Example:
|
||||
config = DynamicContextConfig(
|
||||
base_dir="~/.praison/runs",
|
||||
inline_max_kb=32,
|
||||
redact_secrets=True,
|
||||
terminal_logging=True,
|
||||
)
|
||||
"""
|
||||
# Storage
|
||||
base_dir: str = "~/.praison/runs"
|
||||
|
||||
# Tool output queuing
|
||||
queuing_enabled: bool = True
|
||||
inline_max_kb: int = 32
|
||||
|
||||
# History
|
||||
history_enabled: bool = True
|
||||
|
||||
# Terminal logging
|
||||
terminal_logging: bool = False # Off by default for privacy
|
||||
|
||||
# Security
|
||||
redact_secrets: bool = True
|
||||
secret_patterns: List[str] = field(default_factory=list)
|
||||
|
||||
# Run identification
|
||||
run_id: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Apply environment variable overrides."""
|
||||
self.base_dir = os.getenv(
|
||||
"PRAISONAI_ARTIFACT_DIR",
|
||||
self.base_dir
|
||||
)
|
||||
self.inline_max_kb = int(os.getenv(
|
||||
"PRAISONAI_ARTIFACT_INLINE_MAX_KB",
|
||||
str(self.inline_max_kb)
|
||||
))
|
||||
self.redact_secrets = os.getenv(
|
||||
"PRAISONAI_ARTIFACT_REDACT",
|
||||
str(self.redact_secrets)
|
||||
).lower() in ("true", "1", "yes")
|
||||
self.terminal_logging = os.getenv(
|
||||
"PRAISONAI_TERMINAL_LOGGING",
|
||||
str(self.terminal_logging)
|
||||
).lower() in ("true", "1", "yes")
|
||||
|
||||
def to_queue_config(self) -> QueueConfig:
|
||||
"""Convert to QueueConfig for artifact store."""
|
||||
return QueueConfig(
|
||||
enabled=self.queuing_enabled,
|
||||
inline_max_bytes=self.inline_max_kb * 1024,
|
||||
redact_secrets=self.redact_secrets,
|
||||
secret_patterns=self.secret_patterns if self.secret_patterns else QueueConfig().secret_patterns,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"base_dir": self.base_dir,
|
||||
"queuing_enabled": self.queuing_enabled,
|
||||
"inline_max_kb": self.inline_max_kb,
|
||||
"history_enabled": self.history_enabled,
|
||||
"terminal_logging": self.terminal_logging,
|
||||
"redact_secrets": self.redact_secrets,
|
||||
"run_id": self.run_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "DynamicContextConfig":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
base_dir=data.get("base_dir", "~/.praison/runs"),
|
||||
queuing_enabled=data.get("queuing_enabled", True),
|
||||
inline_max_kb=data.get("inline_max_kb", 32),
|
||||
history_enabled=data.get("history_enabled", True),
|
||||
terminal_logging=data.get("terminal_logging", False),
|
||||
redact_secrets=data.get("redact_secrets", True),
|
||||
secret_patterns=data.get("secret_patterns", []),
|
||||
run_id=data.get("run_id"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "DynamicContextConfig":
|
||||
"""Create configuration from environment variables."""
|
||||
return cls() # __post_init__ handles env vars
|
||||
@@ -0,0 +1,514 @@
|
||||
"""
|
||||
History Store for Dynamic Context Discovery.
|
||||
|
||||
Persists conversation history to artifacts for loss recovery during summarization.
|
||||
Implements HistoryStoreProtocol.
|
||||
|
||||
Usage:
|
||||
from praisonai.context import HistoryStore
|
||||
|
||||
store = HistoryStore(base_dir="~/.praison/runs")
|
||||
|
||||
# Append messages
|
||||
store.append({"role": "user", "content": "Hello"}, agent_id="agent1", run_id="run123")
|
||||
|
||||
# Get reference for summary pointer
|
||||
ref = store.get_ref(agent_id="agent1", run_id="run123")
|
||||
|
||||
# Search history
|
||||
matches = store.search("error", agent_id="agent1", run_id="run123")
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from praisonaiagents.context.artifacts import (
|
||||
ArtifactRef,
|
||||
GrepMatch,
|
||||
compute_checksum,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HistoryStore:
|
||||
"""
|
||||
Filesystem-backed conversation history storage.
|
||||
|
||||
Stores history as JSONL files (one message per line) for efficient
|
||||
appending and searching.
|
||||
|
||||
Directory layout:
|
||||
~/.praison/runs/{run_id}/history/{agent_id}.jsonl
|
||||
|
||||
Features:
|
||||
- Append-only for performance
|
||||
- JSONL format for streaming reads
|
||||
- Regex search support
|
||||
- Turn-based retrieval
|
||||
"""
|
||||
|
||||
def __init__(self, base_dir: str = "~/.praison/runs"):
|
||||
"""
|
||||
Initialize the history store.
|
||||
|
||||
Args:
|
||||
base_dir: Base directory for history storage
|
||||
"""
|
||||
self.base_dir = Path(base_dir).expanduser().resolve()
|
||||
|
||||
def _get_history_path(self, run_id: str, agent_id: str) -> Path:
|
||||
"""Get the path to the history file."""
|
||||
history_dir = self.base_dir / run_id / "history"
|
||||
history_dir.mkdir(parents=True, exist_ok=True)
|
||||
return history_dir / f"{agent_id}.jsonl"
|
||||
|
||||
def append(
|
||||
self,
|
||||
message: Dict[str, Any],
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
) -> None:
|
||||
"""
|
||||
Append a message to history.
|
||||
|
||||
Args:
|
||||
message: The message dict (role, content, etc.)
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
"""
|
||||
history_path = self._get_history_path(run_id, agent_id)
|
||||
|
||||
# Add timestamp if not present
|
||||
if "timestamp" not in message:
|
||||
message = {**message, "timestamp": time.time()}
|
||||
|
||||
# Append as JSONL
|
||||
with open(history_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(message, default=str) + "\n")
|
||||
|
||||
def get_ref(self, agent_id: str, run_id: str) -> Optional[ArtifactRef]:
|
||||
"""
|
||||
Get artifact reference for history file.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
|
||||
Returns:
|
||||
ArtifactRef if history exists, None otherwise
|
||||
"""
|
||||
history_path = self._get_history_path(run_id, agent_id)
|
||||
|
||||
if not history_path.exists():
|
||||
return None
|
||||
|
||||
# Get file stats
|
||||
stat = history_path.stat()
|
||||
size_bytes = stat.st_size
|
||||
|
||||
# Count messages for summary
|
||||
message_count = 0
|
||||
with open(history_path, "r", encoding="utf-8") as f:
|
||||
for _ in f:
|
||||
message_count += 1
|
||||
|
||||
# Compute checksum
|
||||
content = history_path.read_bytes()
|
||||
checksum = compute_checksum(content)
|
||||
|
||||
return ArtifactRef(
|
||||
path=str(history_path),
|
||||
summary=f"Conversation history with {message_count} messages",
|
||||
size_bytes=size_bytes,
|
||||
mime_type="application/jsonl",
|
||||
checksum=checksum,
|
||||
created_at=stat.st_mtime,
|
||||
artifact_id=f"history_{agent_id}",
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
max_results: int = 20,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search history for matching messages.
|
||||
|
||||
Args:
|
||||
query: Search query (supports regex)
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
max_results: Maximum results to return
|
||||
|
||||
Returns:
|
||||
List of matching message dicts
|
||||
"""
|
||||
history_path = self._get_history_path(run_id, agent_id)
|
||||
|
||||
if not history_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
pattern = re.compile(query, re.IGNORECASE)
|
||||
except re.error:
|
||||
# Fall back to literal search
|
||||
pattern = re.compile(re.escape(query), re.IGNORECASE)
|
||||
|
||||
matches = []
|
||||
with open(history_path, "r", encoding="utf-8") as f:
|
||||
for line_num, line in enumerate(f, start=1):
|
||||
try:
|
||||
message = json.loads(line)
|
||||
content = message.get("content", "")
|
||||
if isinstance(content, str) and pattern.search(content):
|
||||
message["_line_number"] = line_num
|
||||
matches.append(message)
|
||||
if len(matches) >= max_results:
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return matches
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
start_turn: int = 0,
|
||||
end_turn: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get messages from history.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
start_turn: Starting turn number (0-indexed)
|
||||
end_turn: Ending turn number (None for all)
|
||||
|
||||
Returns:
|
||||
List of message dicts
|
||||
"""
|
||||
history_path = self._get_history_path(run_id, agent_id)
|
||||
|
||||
if not history_path.exists():
|
||||
return []
|
||||
|
||||
messages = []
|
||||
with open(history_path, "r", encoding="utf-8") as f:
|
||||
for i, line in enumerate(f):
|
||||
if i < start_turn:
|
||||
continue
|
||||
if end_turn is not None and i >= end_turn:
|
||||
break
|
||||
try:
|
||||
message = json.loads(line)
|
||||
messages.append(message)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return messages
|
||||
|
||||
def get_last_messages(
|
||||
self,
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
count: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get the last N messages from history.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
count: Number of messages to return
|
||||
|
||||
Returns:
|
||||
List of message dicts (most recent last)
|
||||
"""
|
||||
history_path = self._get_history_path(run_id, agent_id)
|
||||
|
||||
if not history_path.exists():
|
||||
return []
|
||||
|
||||
# Read all messages (could be optimized for large files)
|
||||
all_messages = []
|
||||
with open(history_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
try:
|
||||
message = json.loads(line)
|
||||
all_messages.append(message)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return all_messages[-count:]
|
||||
|
||||
def grep(
|
||||
self,
|
||||
pattern: str,
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
context_lines: int = 0,
|
||||
max_matches: int = 50,
|
||||
) -> List[GrepMatch]:
|
||||
"""
|
||||
Search history with grep-like interface.
|
||||
|
||||
Args:
|
||||
pattern: Regex pattern to search for
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
context_lines: Number of context messages before/after
|
||||
max_matches: Maximum matches to return
|
||||
|
||||
Returns:
|
||||
List of GrepMatch objects
|
||||
"""
|
||||
history_path = self._get_history_path(run_id, agent_id)
|
||||
|
||||
if not history_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
regex = re.compile(pattern, re.IGNORECASE)
|
||||
except re.error:
|
||||
regex = re.compile(re.escape(pattern), re.IGNORECASE)
|
||||
|
||||
# Read all lines for context
|
||||
all_lines = []
|
||||
with open(history_path, "r", encoding="utf-8") as f:
|
||||
all_lines = f.readlines()
|
||||
|
||||
matches = []
|
||||
for i, line in enumerate(all_lines):
|
||||
try:
|
||||
message = json.loads(line)
|
||||
content = message.get("content", "")
|
||||
if isinstance(content, str) and regex.search(content):
|
||||
# Get context
|
||||
start = max(0, i - context_lines)
|
||||
end = min(len(all_lines), i + context_lines + 1)
|
||||
|
||||
match = GrepMatch(
|
||||
line_number=i + 1,
|
||||
line_content=line.rstrip("\n"),
|
||||
context_before=[ln.rstrip("\n") for ln in all_lines[start:i]],
|
||||
context_after=[ln.rstrip("\n") for ln in all_lines[i+1:end]],
|
||||
)
|
||||
matches.append(match)
|
||||
|
||||
if len(matches) >= max_matches:
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return matches
|
||||
|
||||
def clear(self, agent_id: str, run_id: str) -> bool:
|
||||
"""
|
||||
Clear history for an agent.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
|
||||
Returns:
|
||||
True if cleared successfully
|
||||
"""
|
||||
history_path = self._get_history_path(run_id, agent_id)
|
||||
|
||||
if history_path.exists():
|
||||
history_path.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
def export(
|
||||
self,
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
format: str = "jsonl",
|
||||
) -> str:
|
||||
"""
|
||||
Export history in specified format.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
format: Export format (jsonl, json, text)
|
||||
|
||||
Returns:
|
||||
Exported content as string
|
||||
"""
|
||||
messages = self.get_messages(agent_id, run_id)
|
||||
|
||||
if format == "jsonl":
|
||||
return "\n".join(json.dumps(m, default=str) for m in messages)
|
||||
elif format == "json":
|
||||
return json.dumps(messages, indent=2, default=str)
|
||||
elif format == "text":
|
||||
lines = []
|
||||
for m in messages:
|
||||
role = m.get("role", "unknown")
|
||||
content = m.get("content", "")
|
||||
lines.append(f"[{role}]: {content}")
|
||||
return "\n\n".join(lines)
|
||||
else:
|
||||
raise ValueError(f"Unknown format: {format}")
|
||||
|
||||
|
||||
def create_history_pointer(ref: ArtifactRef) -> str:
|
||||
"""
|
||||
Create a history pointer string for inclusion in summaries.
|
||||
|
||||
This pointer allows agents to recover full conversation history
|
||||
after summarization.
|
||||
|
||||
Args:
|
||||
ref: ArtifactRef to the history file
|
||||
|
||||
Returns:
|
||||
Formatted pointer string
|
||||
"""
|
||||
return (
|
||||
f"[Full conversation history available at: {ref.path}]\n"
|
||||
f"[{ref.summary}]\n"
|
||||
f"[Use history_search or history_tail tools to retrieve details]"
|
||||
)
|
||||
|
||||
|
||||
def create_history_tools(
|
||||
store: Optional[HistoryStore] = None,
|
||||
base_dir: str = "~/.praison/runs",
|
||||
):
|
||||
"""
|
||||
Create tools for agents to interact with history.
|
||||
|
||||
Returns a list of tool functions that can be passed to Agent(tools=[...]).
|
||||
|
||||
Tools created:
|
||||
- history_search: Search conversation history
|
||||
- history_tail: Get recent messages
|
||||
- history_get: Get messages by turn range
|
||||
|
||||
Example:
|
||||
tools = create_history_tools()
|
||||
agent = Agent(name="MyAgent", tools=tools)
|
||||
"""
|
||||
history_store = store or HistoryStore(base_dir=base_dir)
|
||||
|
||||
def history_search(
|
||||
query: str,
|
||||
agent_id: str = "default",
|
||||
run_id: str = "default",
|
||||
max_results: int = 10,
|
||||
) -> str:
|
||||
"""
|
||||
Search conversation history for matching messages.
|
||||
|
||||
Args:
|
||||
query: Search query (supports regex patterns)
|
||||
agent_id: ID of the agent whose history to search
|
||||
run_id: ID of the run/session
|
||||
max_results: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
Formatted string with matching messages
|
||||
"""
|
||||
matches = history_store.search(
|
||||
query=query,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
max_results=max_results,
|
||||
)
|
||||
|
||||
if not matches:
|
||||
return f"No messages found matching: {query}"
|
||||
|
||||
result_lines = [f"Found {len(matches)} matching messages:"]
|
||||
for m in matches:
|
||||
role = m.get("role", "unknown")
|
||||
content = m.get("content", "")[:200]
|
||||
line_num = m.get("_line_number", "?")
|
||||
result_lines.append(f"\n[{line_num}] {role}: {content}...")
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
def history_tail(
|
||||
agent_id: str = "default",
|
||||
run_id: str = "default",
|
||||
count: int = 10,
|
||||
) -> str:
|
||||
"""
|
||||
Get the most recent messages from conversation history.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
count: Number of recent messages to return
|
||||
|
||||
Returns:
|
||||
Formatted string with recent messages
|
||||
"""
|
||||
messages = history_store.get_last_messages(
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
count=count,
|
||||
)
|
||||
|
||||
if not messages:
|
||||
return "No history found."
|
||||
|
||||
result_lines = [f"Last {len(messages)} messages:"]
|
||||
for m in messages:
|
||||
role = m.get("role", "unknown")
|
||||
content = m.get("content", "")
|
||||
result_lines.append(f"\n[{role}]: {content}")
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
def history_get(
|
||||
agent_id: str = "default",
|
||||
run_id: str = "default",
|
||||
start_turn: int = 0,
|
||||
end_turn: Optional[int] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get messages from history by turn range.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
start_turn: Starting turn number (0-indexed)
|
||||
end_turn: Ending turn number (None for all remaining)
|
||||
|
||||
Returns:
|
||||
Formatted string with messages
|
||||
"""
|
||||
messages = history_store.get_messages(
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
start_turn=start_turn,
|
||||
end_turn=end_turn,
|
||||
)
|
||||
|
||||
if not messages:
|
||||
return "No messages in specified range."
|
||||
|
||||
result_lines = [f"Messages {start_turn} to {end_turn or 'end'}:"]
|
||||
for i, m in enumerate(messages, start=start_turn):
|
||||
role = m.get("role", "unknown")
|
||||
content = m.get("content", "")
|
||||
result_lines.append(f"\n[{i}] {role}: {content}")
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
return [history_search, history_tail, history_get]
|
||||
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
Output Queue for Dynamic Context Discovery.
|
||||
|
||||
Middleware that automatically queues large tool outputs to artifacts,
|
||||
replacing them with lightweight references in the context.
|
||||
|
||||
Usage:
|
||||
from praisonai.context import OutputQueue, create_queue_middleware
|
||||
|
||||
# Create output queue
|
||||
queue = OutputQueue(store=artifact_store)
|
||||
|
||||
# Process tool output
|
||||
result = queue.process(tool_output, metadata)
|
||||
# Returns either original output (if small) or ArtifactRef (if large)
|
||||
|
||||
# Or use as middleware
|
||||
middleware = create_queue_middleware(store=artifact_store)
|
||||
agent = Agent(hooks=[middleware])
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Callable, Optional, Union
|
||||
|
||||
from praisonaiagents.context.artifacts import (
|
||||
ArtifactRef,
|
||||
ArtifactMetadata,
|
||||
QueueConfig,
|
||||
)
|
||||
from praisonaiagents.hooks import ToolRequest, ToolResponse
|
||||
|
||||
from .artifact_store import FileSystemArtifactStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OutputQueue:
|
||||
"""
|
||||
Middleware that queues large tool outputs to artifacts.
|
||||
|
||||
When a tool produces output larger than the configured threshold,
|
||||
the queue stores it as an artifact and returns a reference instead.
|
||||
|
||||
Features:
|
||||
- Configurable size threshold
|
||||
- Automatic MIME type detection
|
||||
- Secret redaction
|
||||
- Checksum verification
|
||||
|
||||
Example:
|
||||
queue = OutputQueue(
|
||||
store=FileSystemArtifactStore(),
|
||||
config=QueueConfig(inline_max_bytes=32*1024)
|
||||
)
|
||||
|
||||
# Small output - returned as-is
|
||||
small_result = queue.process("Hello", metadata)
|
||||
# Returns: "Hello"
|
||||
|
||||
# Large output - queued to artifact
|
||||
large_result = queue.process(large_json_data, metadata)
|
||||
# Returns: ArtifactRef(path="...", summary="...", ...)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: Optional[FileSystemArtifactStore] = None,
|
||||
config: Optional[QueueConfig] = None,
|
||||
base_dir: str = "~/.praison/runs",
|
||||
):
|
||||
"""
|
||||
Initialize the output queue.
|
||||
|
||||
Args:
|
||||
store: Artifact store to use (created if not provided)
|
||||
config: Queue configuration
|
||||
base_dir: Base directory for artifacts (if store not provided)
|
||||
"""
|
||||
self.config = config or QueueConfig()
|
||||
self.store = store or FileSystemArtifactStore(
|
||||
base_dir=base_dir,
|
||||
config=self.config,
|
||||
)
|
||||
|
||||
def _estimate_size(self, content: Any) -> int:
|
||||
"""Estimate the serialized size of content."""
|
||||
if isinstance(content, bytes):
|
||||
return len(content)
|
||||
elif isinstance(content, str):
|
||||
return len(content.encode("utf-8"))
|
||||
else:
|
||||
# Serialize to JSON to estimate size
|
||||
try:
|
||||
return len(json.dumps(content, default=str).encode("utf-8"))
|
||||
except (TypeError, ValueError):
|
||||
return len(str(content).encode("utf-8"))
|
||||
|
||||
def should_queue(self, content: Any) -> bool:
|
||||
"""
|
||||
Determine if content should be queued to artifact.
|
||||
|
||||
Args:
|
||||
content: The content to check
|
||||
|
||||
Returns:
|
||||
True if content should be queued to artifact
|
||||
"""
|
||||
if not self.config.enabled:
|
||||
return False
|
||||
|
||||
size = self._estimate_size(content)
|
||||
return size > self.config.inline_max_bytes
|
||||
|
||||
def process(
|
||||
self,
|
||||
content: Any,
|
||||
metadata: ArtifactMetadata,
|
||||
) -> Union[Any, ArtifactRef]:
|
||||
"""
|
||||
Process tool output, queuing to artifact if necessary.
|
||||
|
||||
Args:
|
||||
content: The tool output content
|
||||
metadata: Metadata about the tool call
|
||||
|
||||
Returns:
|
||||
Original content if small, ArtifactRef if queued
|
||||
"""
|
||||
if not self.should_queue(content):
|
||||
return content
|
||||
|
||||
# Queue to artifact
|
||||
ref = self.store.store(content, metadata)
|
||||
logger.info(
|
||||
f"Queued tool output to artifact: {ref.path} "
|
||||
f"({ref.size_bytes} bytes)"
|
||||
)
|
||||
return ref
|
||||
|
||||
|
||||
def process_tool_response(
|
||||
self,
|
||||
response: ToolResponse,
|
||||
run_id: str = "default",
|
||||
) -> ToolResponse:
|
||||
"""
|
||||
Process a ToolResponse, queuing result if necessary.
|
||||
|
||||
Args:
|
||||
response: The tool response to process
|
||||
run_id: Run ID for artifact storage
|
||||
|
||||
Returns:
|
||||
Modified ToolResponse with result potentially replaced by ArtifactRef
|
||||
"""
|
||||
if response.error:
|
||||
return response
|
||||
|
||||
metadata = ArtifactMetadata(
|
||||
agent_id=response.context.agent_id if response.context else "default",
|
||||
run_id=run_id,
|
||||
tool_name=response.tool_name,
|
||||
turn_id=0,
|
||||
)
|
||||
|
||||
processed_result = self.process(response.result, metadata)
|
||||
|
||||
# If queued, update the response
|
||||
if isinstance(processed_result, ArtifactRef):
|
||||
# Replace result with inline reference
|
||||
response.result = processed_result.to_inline()
|
||||
response.extra["artifact_ref"] = processed_result.to_dict()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def create_queue_middleware(
|
||||
store: Optional[FileSystemArtifactStore] = None,
|
||||
config: Optional[QueueConfig] = None,
|
||||
base_dir: str = "~/.praison/runs",
|
||||
run_id: str = "default",
|
||||
) -> Callable:
|
||||
"""
|
||||
Create a wrap_tool_call middleware for automatic output queuing.
|
||||
|
||||
This middleware intercepts tool calls and automatically queues
|
||||
large outputs to artifacts.
|
||||
|
||||
Args:
|
||||
store: Artifact store to use
|
||||
config: Queue configuration
|
||||
base_dir: Base directory for artifacts
|
||||
run_id: Run ID for artifact storage
|
||||
|
||||
Returns:
|
||||
Middleware function compatible with Agent(hooks=[...])
|
||||
|
||||
Example:
|
||||
middleware = create_queue_middleware(
|
||||
config=QueueConfig(inline_max_bytes=16*1024)
|
||||
)
|
||||
agent = Agent(name="MyAgent", hooks=[middleware])
|
||||
"""
|
||||
queue = OutputQueue(store=store, config=config, base_dir=base_dir)
|
||||
|
||||
def queue_middleware(
|
||||
request: ToolRequest,
|
||||
call_next: Callable[[ToolRequest], ToolResponse],
|
||||
) -> ToolResponse:
|
||||
"""Middleware that queues large tool outputs."""
|
||||
# Execute the tool
|
||||
response = call_next(request)
|
||||
|
||||
# Process the response
|
||||
return queue.process_tool_response(response, run_id=run_id)
|
||||
|
||||
# Mark as wrap_tool_call middleware
|
||||
queue_middleware._middleware_type = "wrap_tool_call"
|
||||
|
||||
return queue_middleware
|
||||
|
||||
|
||||
|
||||
def create_artifact_tools(
|
||||
store: Optional[FileSystemArtifactStore] = None,
|
||||
base_dir: str = "~/.praison/runs",
|
||||
):
|
||||
"""
|
||||
Create tools for agents to interact with artifacts.
|
||||
|
||||
Returns a list of tool functions that can be passed to Agent(tools=[...]).
|
||||
|
||||
Tools created:
|
||||
- artifact_tail: Get last N lines of an artifact
|
||||
- artifact_head: Get first N lines of an artifact
|
||||
- artifact_grep: Search for pattern in artifact
|
||||
- artifact_chunk: Get specific line range from artifact
|
||||
- artifact_list: List available artifacts
|
||||
|
||||
Example:
|
||||
tools = create_artifact_tools()
|
||||
agent = Agent(name="MyAgent", tools=tools)
|
||||
"""
|
||||
artifact_store = store or FileSystemArtifactStore(base_dir=base_dir)
|
||||
|
||||
def artifact_tail(artifact_path: str, lines: int = 50) -> str:
|
||||
"""
|
||||
Get the last N lines of an artifact file.
|
||||
|
||||
Args:
|
||||
artifact_path: Path to the artifact file
|
||||
lines: Number of lines to return (default: 50)
|
||||
|
||||
Returns:
|
||||
String containing the last N lines
|
||||
"""
|
||||
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
|
||||
try:
|
||||
return artifact_store.tail(ref, lines=lines)
|
||||
except FileNotFoundError:
|
||||
return f"Error: Artifact not found: {artifact_path}"
|
||||
|
||||
def artifact_head(artifact_path: str, lines: int = 50) -> str:
|
||||
"""
|
||||
Get the first N lines of an artifact file.
|
||||
|
||||
Args:
|
||||
artifact_path: Path to the artifact file
|
||||
lines: Number of lines to return (default: 50)
|
||||
|
||||
Returns:
|
||||
String containing the first N lines
|
||||
"""
|
||||
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
|
||||
try:
|
||||
return artifact_store.head(ref, lines=lines)
|
||||
except FileNotFoundError:
|
||||
return f"Error: Artifact not found: {artifact_path}"
|
||||
|
||||
def artifact_grep(
|
||||
artifact_path: str,
|
||||
pattern: str,
|
||||
context_lines: int = 2,
|
||||
max_matches: int = 20,
|
||||
) -> str:
|
||||
"""
|
||||
Search for a pattern in an artifact file.
|
||||
|
||||
Args:
|
||||
artifact_path: Path to the artifact file
|
||||
pattern: Regex pattern to search for
|
||||
context_lines: Number of context lines before/after match
|
||||
max_matches: Maximum number of matches to return
|
||||
|
||||
Returns:
|
||||
Formatted string with matches and context
|
||||
"""
|
||||
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
|
||||
try:
|
||||
matches = artifact_store.grep(
|
||||
ref,
|
||||
pattern=pattern,
|
||||
context_lines=context_lines,
|
||||
max_matches=max_matches,
|
||||
)
|
||||
|
||||
if not matches:
|
||||
return f"No matches found for pattern: {pattern}"
|
||||
|
||||
result_lines = [f"Found {len(matches)} matches:"]
|
||||
for match in matches:
|
||||
result_lines.append(f"\n--- Line {match.line_number} ---")
|
||||
for ctx_line in match.context_before:
|
||||
result_lines.append(f" {ctx_line}")
|
||||
result_lines.append(f"> {match.line_content}")
|
||||
for ctx_line in match.context_after:
|
||||
result_lines.append(f" {ctx_line}")
|
||||
|
||||
return "\n".join(result_lines)
|
||||
except FileNotFoundError:
|
||||
return f"Error: Artifact not found: {artifact_path}"
|
||||
except ValueError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def artifact_chunk(
|
||||
artifact_path: str,
|
||||
start_line: int = 1,
|
||||
end_line: Optional[int] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get a specific range of lines from an artifact file.
|
||||
|
||||
Args:
|
||||
artifact_path: Path to the artifact file
|
||||
start_line: Starting line number (1-indexed)
|
||||
end_line: Ending line number (inclusive), None for end of file
|
||||
|
||||
Returns:
|
||||
String containing the requested lines
|
||||
"""
|
||||
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
|
||||
try:
|
||||
return artifact_store.chunk(ref, start_line=start_line, end_line=end_line)
|
||||
except FileNotFoundError:
|
||||
return f"Error: Artifact not found: {artifact_path}"
|
||||
|
||||
def artifact_list(
|
||||
run_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
tool_name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
List available artifacts.
|
||||
|
||||
Args:
|
||||
run_id: Filter by run ID
|
||||
agent_id: Filter by agent ID
|
||||
tool_name: Filter by tool name
|
||||
|
||||
Returns:
|
||||
Formatted list of artifacts
|
||||
"""
|
||||
artifacts = artifact_store.list_artifacts(
|
||||
run_id=run_id,
|
||||
agent_id=agent_id,
|
||||
tool_name=tool_name,
|
||||
)
|
||||
|
||||
if not artifacts:
|
||||
return "No artifacts found."
|
||||
|
||||
result_lines = [f"Found {len(artifacts)} artifacts:"]
|
||||
for ref in artifacts:
|
||||
size_str = ArtifactRef._format_size(ref.size_bytes)
|
||||
tool_info = f" (from {ref.tool_name})" if ref.tool_name else ""
|
||||
result_lines.append(
|
||||
f"- {ref.path}{tool_info}\n"
|
||||
f" Size: {size_str}, Summary: {ref.summary[:50]}..."
|
||||
)
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
return [artifact_tail, artifact_head, artifact_grep, artifact_chunk, artifact_list]
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Setup utilities for Dynamic Context Discovery.
|
||||
|
||||
Provides convenience functions to set up dynamic context features
|
||||
with minimal configuration.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import List, Optional, Callable
|
||||
|
||||
from .config import DynamicContextConfig
|
||||
from .artifact_store import FileSystemArtifactStore
|
||||
from .queue import OutputQueue, create_queue_middleware, create_artifact_tools
|
||||
from .history_store import HistoryStore, create_history_tools
|
||||
from .terminal_logger import TerminalLogger, create_terminal_tools
|
||||
|
||||
|
||||
class DynamicContextSetup:
|
||||
"""
|
||||
Container for dynamic context components.
|
||||
|
||||
Provides access to all dynamic context features:
|
||||
- Artifact store
|
||||
- Output queue
|
||||
- History store
|
||||
- Terminal logger
|
||||
- Agent tools
|
||||
- Middleware
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[DynamicContextConfig] = None,
|
||||
run_id: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize dynamic context setup.
|
||||
|
||||
Args:
|
||||
config: Configuration (uses defaults if not provided)
|
||||
run_id: Run ID (generated if not provided)
|
||||
"""
|
||||
self.config = config or DynamicContextConfig()
|
||||
self.run_id = run_id or self.config.run_id or str(uuid.uuid4())[:8]
|
||||
|
||||
# Initialize components
|
||||
self.artifact_store = FileSystemArtifactStore(
|
||||
base_dir=self.config.base_dir,
|
||||
config=self.config.to_queue_config(),
|
||||
)
|
||||
|
||||
self.output_queue = OutputQueue(
|
||||
store=self.artifact_store,
|
||||
config=self.config.to_queue_config(),
|
||||
)
|
||||
|
||||
self.history_store = HistoryStore(base_dir=self.config.base_dir)
|
||||
|
||||
self.terminal_logger = TerminalLogger(base_dir=self.config.base_dir)
|
||||
|
||||
def get_tools(self) -> List[Callable]:
|
||||
"""
|
||||
Get all dynamic context tools for agents.
|
||||
|
||||
Returns:
|
||||
List of tool functions
|
||||
"""
|
||||
tools = []
|
||||
|
||||
# Artifact tools
|
||||
tools.extend(create_artifact_tools(store=self.artifact_store))
|
||||
|
||||
# History tools
|
||||
if self.config.history_enabled:
|
||||
tools.extend(create_history_tools(store=self.history_store))
|
||||
|
||||
# Terminal tools
|
||||
if self.config.terminal_logging:
|
||||
tools.extend(create_terminal_tools(terminal_logger=self.terminal_logger))
|
||||
|
||||
return tools
|
||||
|
||||
def get_middleware(self) -> Callable:
|
||||
"""
|
||||
Get queue middleware for agents.
|
||||
|
||||
Returns:
|
||||
Middleware function for Agent(hooks=[...])
|
||||
"""
|
||||
return create_queue_middleware(
|
||||
store=self.artifact_store,
|
||||
config=self.config.to_queue_config(),
|
||||
run_id=self.run_id,
|
||||
)
|
||||
|
||||
|
||||
def setup_dynamic_context(
|
||||
base_dir: str = "~/.praison/runs",
|
||||
inline_max_kb: int = 32,
|
||||
redact_secrets: bool = True,
|
||||
history_enabled: bool = True,
|
||||
terminal_logging: bool = False,
|
||||
run_id: Optional[str] = None,
|
||||
) -> DynamicContextSetup:
|
||||
"""
|
||||
Set up dynamic context discovery with minimal configuration.
|
||||
|
||||
This is the main entry point for enabling dynamic context features.
|
||||
|
||||
Args:
|
||||
base_dir: Base directory for artifact storage
|
||||
inline_max_kb: Maximum size for inline tool outputs (KB)
|
||||
redact_secrets: Whether to redact secrets in artifacts
|
||||
history_enabled: Whether to enable history persistence
|
||||
terminal_logging: Whether to enable terminal logging
|
||||
run_id: Run ID (generated if not provided)
|
||||
|
||||
Returns:
|
||||
DynamicContextSetup with all components initialized
|
||||
|
||||
Example:
|
||||
from praisonai.context import setup_dynamic_context
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Set up dynamic context
|
||||
ctx = setup_dynamic_context(inline_max_kb=16)
|
||||
|
||||
# Create agent with dynamic context tools and middleware
|
||||
agent = Agent(
|
||||
name="MyAgent",
|
||||
tools=ctx.get_tools(),
|
||||
hooks=[ctx.get_middleware()],
|
||||
)
|
||||
"""
|
||||
config = DynamicContextConfig(
|
||||
base_dir=base_dir,
|
||||
inline_max_kb=inline_max_kb,
|
||||
redact_secrets=redact_secrets,
|
||||
history_enabled=history_enabled,
|
||||
terminal_logging=terminal_logging,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
return DynamicContextSetup(config=config, run_id=run_id)
|
||||
@@ -0,0 +1,486 @@
|
||||
"""
|
||||
Terminal Logger for Dynamic Context Discovery.
|
||||
|
||||
Captures shell command outputs to artifacts for later retrieval.
|
||||
Implements TerminalLoggerProtocol.
|
||||
|
||||
Usage:
|
||||
from praisonai.context import TerminalLogger
|
||||
|
||||
logger = TerminalLogger(base_dir="~/.praison/runs")
|
||||
|
||||
# Log a command execution
|
||||
ref = logger.log_command(
|
||||
command="ls -la",
|
||||
output="total 100\ndrwxr-xr-x ...",
|
||||
exit_code=0,
|
||||
agent_id="agent1",
|
||||
run_id="run123"
|
||||
)
|
||||
|
||||
# Tail the session log
|
||||
recent = logger.tail_session(agent_id="agent1", run_id="run123", lines=50)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from praisonaiagents.context.artifacts import (
|
||||
ArtifactRef,
|
||||
GrepMatch,
|
||||
compute_checksum,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TerminalLogger:
|
||||
"""
|
||||
Filesystem-backed terminal session logging.
|
||||
|
||||
Captures shell command executions to log files for later retrieval.
|
||||
|
||||
Directory layout:
|
||||
~/.praison/runs/{run_id}/terminal/{agent_id}.log
|
||||
|
||||
Log format (per command):
|
||||
=== COMMAND [timestamp] ===
|
||||
$ command
|
||||
--- OUTPUT (exit_code) ---
|
||||
output content
|
||||
=== END ===
|
||||
|
||||
Features:
|
||||
- Append-only logging
|
||||
- Structured command/output format
|
||||
- Efficient tail/grep operations
|
||||
"""
|
||||
|
||||
def __init__(self, base_dir: str = "~/.praison/runs"):
|
||||
"""
|
||||
Initialize the terminal logger.
|
||||
|
||||
Args:
|
||||
base_dir: Base directory for terminal logs
|
||||
"""
|
||||
self.base_dir = Path(base_dir).expanduser().resolve()
|
||||
|
||||
def _get_log_path(self, run_id: str, agent_id: str) -> Path:
|
||||
"""Get the path to the terminal log file."""
|
||||
terminal_dir = self.base_dir / run_id / "terminal"
|
||||
terminal_dir.mkdir(parents=True, exist_ok=True)
|
||||
return terminal_dir / f"{agent_id}.log"
|
||||
|
||||
def log_command(
|
||||
self,
|
||||
command: str,
|
||||
output: str,
|
||||
exit_code: int,
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
) -> ArtifactRef:
|
||||
"""
|
||||
Log a command execution.
|
||||
|
||||
Args:
|
||||
command: The command that was executed
|
||||
output: Combined stdout/stderr output
|
||||
exit_code: Command exit code
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
|
||||
Returns:
|
||||
ArtifactRef to the logged output
|
||||
"""
|
||||
log_path = self._get_log_path(run_id, agent_id)
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
# Format the log entry
|
||||
entry = (
|
||||
f"\n=== COMMAND [{timestamp}] ===\n"
|
||||
f"$ {command}\n"
|
||||
f"--- OUTPUT (exit_code={exit_code}) ---\n"
|
||||
f"{output}\n"
|
||||
f"=== END ===\n"
|
||||
)
|
||||
|
||||
# Append to log
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(entry)
|
||||
|
||||
# Get file stats
|
||||
stat = log_path.stat()
|
||||
|
||||
return ArtifactRef(
|
||||
path=str(log_path),
|
||||
summary=f"Terminal log: {command[:50]}... (exit={exit_code})",
|
||||
size_bytes=stat.st_size,
|
||||
mime_type="text/plain",
|
||||
checksum="", # Don't compute for append-only logs
|
||||
created_at=time.time(),
|
||||
artifact_id=f"terminal_{agent_id}",
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
tool_name="shell",
|
||||
)
|
||||
|
||||
def get_session_ref(self, agent_id: str, run_id: str) -> Optional[ArtifactRef]:
|
||||
"""
|
||||
Get artifact reference for terminal session log.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
|
||||
Returns:
|
||||
ArtifactRef if session exists, None otherwise
|
||||
"""
|
||||
log_path = self._get_log_path(run_id, agent_id)
|
||||
|
||||
if not log_path.exists():
|
||||
return None
|
||||
|
||||
stat = log_path.stat()
|
||||
|
||||
# Count commands
|
||||
command_count = 0
|
||||
with open(log_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.startswith("=== COMMAND"):
|
||||
command_count += 1
|
||||
|
||||
return ArtifactRef(
|
||||
path=str(log_path),
|
||||
summary=f"Terminal session with {command_count} commands",
|
||||
size_bytes=stat.st_size,
|
||||
mime_type="text/plain",
|
||||
checksum=compute_checksum(log_path.read_bytes()),
|
||||
created_at=stat.st_mtime,
|
||||
artifact_id=f"terminal_{agent_id}",
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
def tail_session(
|
||||
self,
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
lines: int = 100,
|
||||
) -> str:
|
||||
"""
|
||||
Get last N lines from terminal session.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
lines: Number of lines to return
|
||||
|
||||
Returns:
|
||||
String containing the last N lines
|
||||
"""
|
||||
log_path = self._get_log_path(run_id, agent_id)
|
||||
|
||||
if not log_path.exists():
|
||||
return "No terminal session found."
|
||||
|
||||
# Efficient tail
|
||||
with open(log_path, "rb") as f:
|
||||
f.seek(0, 2)
|
||||
file_size = f.tell()
|
||||
|
||||
chunk_size = 8192
|
||||
found_lines = []
|
||||
position = file_size
|
||||
|
||||
while position > 0 and len(found_lines) < lines + 1:
|
||||
read_size = min(chunk_size, position)
|
||||
position -= read_size
|
||||
f.seek(position)
|
||||
chunk = f.read(read_size).decode("utf-8", errors="replace")
|
||||
|
||||
chunk_lines = chunk.split("\n")
|
||||
if found_lines:
|
||||
chunk_lines[-1] += found_lines[0]
|
||||
found_lines = chunk_lines + found_lines[1:]
|
||||
else:
|
||||
found_lines = chunk_lines
|
||||
|
||||
return "\n".join(found_lines[-lines:])
|
||||
|
||||
def grep_session(
|
||||
self,
|
||||
pattern: str,
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
max_matches: int = 50,
|
||||
) -> List[GrepMatch]:
|
||||
"""
|
||||
Search terminal session for pattern.
|
||||
|
||||
Args:
|
||||
pattern: Regex pattern to search for
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
max_matches: Maximum matches to return
|
||||
|
||||
Returns:
|
||||
List of GrepMatch objects
|
||||
"""
|
||||
log_path = self._get_log_path(run_id, agent_id)
|
||||
|
||||
if not log_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
regex = re.compile(pattern, re.IGNORECASE)
|
||||
except re.error:
|
||||
regex = re.compile(re.escape(pattern), re.IGNORECASE)
|
||||
|
||||
matches = []
|
||||
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
all_lines = f.readlines()
|
||||
|
||||
for i, line in enumerate(all_lines):
|
||||
if regex.search(line):
|
||||
start = max(0, i - 2)
|
||||
end = min(len(all_lines), i + 3)
|
||||
|
||||
match = GrepMatch(
|
||||
line_number=i + 1,
|
||||
line_content=line.rstrip("\n"),
|
||||
context_before=[ln.rstrip("\n") for ln in all_lines[start:i]],
|
||||
context_after=[ln.rstrip("\n") for ln in all_lines[i+1:end]],
|
||||
)
|
||||
matches.append(match)
|
||||
|
||||
if len(matches) >= max_matches:
|
||||
break
|
||||
|
||||
return matches
|
||||
|
||||
def get_commands(
|
||||
self,
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
last_n: Optional[int] = None,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Get executed commands from session.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
last_n: Return only last N commands (None for all)
|
||||
|
||||
Returns:
|
||||
List of command dicts with command, output, exit_code, timestamp
|
||||
"""
|
||||
log_path = self._get_log_path(run_id, agent_id)
|
||||
|
||||
if not log_path.exists():
|
||||
return []
|
||||
|
||||
commands = []
|
||||
current_command = None
|
||||
current_output_lines = []
|
||||
in_output = False
|
||||
|
||||
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
if line.startswith("=== COMMAND"):
|
||||
# Extract timestamp
|
||||
match = re.search(r'\[(.*?)\]', line)
|
||||
timestamp = match.group(1) if match else ""
|
||||
current_command = {"timestamp": timestamp}
|
||||
in_output = False
|
||||
current_output_lines = []
|
||||
elif line.startswith("$ ") and current_command:
|
||||
current_command["command"] = line[2:].rstrip("\n")
|
||||
elif line.startswith("--- OUTPUT"):
|
||||
match = re.search(r'exit_code=(\d+)', line)
|
||||
if match and current_command:
|
||||
current_command["exit_code"] = int(match.group(1))
|
||||
in_output = True
|
||||
elif line.startswith("=== END"):
|
||||
if current_command:
|
||||
current_command["output"] = "\n".join(current_output_lines)
|
||||
commands.append(current_command)
|
||||
current_command = None
|
||||
in_output = False
|
||||
elif in_output:
|
||||
current_output_lines.append(line.rstrip("\n"))
|
||||
|
||||
if last_n:
|
||||
return commands[-last_n:]
|
||||
return commands
|
||||
|
||||
def clear(self, agent_id: str, run_id: str) -> bool:
|
||||
"""
|
||||
Clear terminal log for an agent.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
|
||||
Returns:
|
||||
True if cleared successfully
|
||||
"""
|
||||
log_path = self._get_log_path(run_id, agent_id)
|
||||
|
||||
if log_path.exists():
|
||||
log_path.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
def export(
|
||||
self,
|
||||
agent_id: str,
|
||||
run_id: str,
|
||||
format: str = "text",
|
||||
) -> str:
|
||||
"""
|
||||
Export terminal log in specified format.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
format: Export format (text, json)
|
||||
|
||||
Returns:
|
||||
Exported content as string
|
||||
"""
|
||||
if format == "text":
|
||||
log_path = self._get_log_path(run_id, agent_id)
|
||||
if not log_path.exists():
|
||||
return ""
|
||||
return log_path.read_text(encoding="utf-8", errors="replace")
|
||||
elif format == "json":
|
||||
commands = self.get_commands(agent_id, run_id)
|
||||
return json.dumps(commands, indent=2)
|
||||
else:
|
||||
raise ValueError(f"Unknown format: {format}")
|
||||
|
||||
|
||||
def create_terminal_tools(
|
||||
terminal_logger: Optional[TerminalLogger] = None,
|
||||
base_dir: str = "~/.praison/runs",
|
||||
):
|
||||
"""
|
||||
Create tools for agents to interact with terminal logs.
|
||||
|
||||
Returns a list of tool functions that can be passed to Agent(tools=[...]).
|
||||
|
||||
Tools created:
|
||||
- terminal_tail: Get recent terminal output
|
||||
- terminal_grep: Search terminal logs
|
||||
- terminal_commands: List executed commands
|
||||
|
||||
Example:
|
||||
tools = create_terminal_tools()
|
||||
agent = Agent(name="MyAgent", tools=tools)
|
||||
"""
|
||||
term_logger = terminal_logger or TerminalLogger(base_dir=base_dir)
|
||||
|
||||
def terminal_tail(
|
||||
agent_id: str = "default",
|
||||
run_id: str = "default",
|
||||
lines: int = 50,
|
||||
) -> str:
|
||||
"""
|
||||
Get the most recent terminal output.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
lines: Number of lines to return
|
||||
|
||||
Returns:
|
||||
Recent terminal output
|
||||
"""
|
||||
return term_logger.tail_session(
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
def terminal_grep(
|
||||
pattern: str,
|
||||
agent_id: str = "default",
|
||||
run_id: str = "default",
|
||||
max_matches: int = 20,
|
||||
) -> str:
|
||||
"""
|
||||
Search terminal logs for a pattern.
|
||||
|
||||
Args:
|
||||
pattern: Regex pattern to search for
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
max_matches: Maximum matches to return
|
||||
|
||||
Returns:
|
||||
Formatted search results
|
||||
"""
|
||||
matches = term_logger.grep_session(
|
||||
pattern=pattern,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
max_matches=max_matches,
|
||||
)
|
||||
|
||||
if not matches:
|
||||
return f"No matches found for: {pattern}"
|
||||
|
||||
result_lines = [f"Found {len(matches)} matches:"]
|
||||
for match in matches:
|
||||
result_lines.append(f"\n--- Line {match.line_number} ---")
|
||||
for ctx in match.context_before:
|
||||
result_lines.append(f" {ctx}")
|
||||
result_lines.append(f"> {match.line_content}")
|
||||
for ctx in match.context_after:
|
||||
result_lines.append(f" {ctx}")
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
def terminal_commands(
|
||||
agent_id: str = "default",
|
||||
run_id: str = "default",
|
||||
last_n: int = 10,
|
||||
) -> str:
|
||||
"""
|
||||
List recently executed commands.
|
||||
|
||||
Args:
|
||||
agent_id: ID of the agent
|
||||
run_id: ID of the run/session
|
||||
last_n: Number of recent commands to show
|
||||
|
||||
Returns:
|
||||
Formatted list of commands
|
||||
"""
|
||||
commands = term_logger.get_commands(
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
last_n=last_n,
|
||||
)
|
||||
|
||||
if not commands:
|
||||
return "No commands found."
|
||||
|
||||
result_lines = [f"Last {len(commands)} commands:"]
|
||||
for cmd in commands:
|
||||
exit_code = cmd.get("exit_code", "?")
|
||||
command = cmd.get("command", "?")
|
||||
timestamp = cmd.get("timestamp", "?")
|
||||
status = "✓" if exit_code == 0 else "✗"
|
||||
result_lines.append(f"\n{status} [{timestamp}] $ {command}")
|
||||
result_lines.append(f" Exit code: {exit_code}")
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
return [terminal_tail, terminal_grep, terminal_commands]
|
||||
@@ -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.3 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==3.8.2 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
"""
|
||||
MCP Tool Index for Dynamic Context Discovery.
|
||||
|
||||
Provides file-backed indexing of MCP tool schemas for efficient loading.
|
||||
Only tool names and hints are loaded statically; full schemas are loaded on demand.
|
||||
|
||||
Usage:
|
||||
from praisonai.mcp_server.tool_index import MCPToolIndex
|
||||
|
||||
index = MCPToolIndex()
|
||||
|
||||
# Sync tools from a server
|
||||
index.sync("brave-search")
|
||||
|
||||
# Get minimal static context (names + hints)
|
||||
static_context = index.get_static_context()
|
||||
|
||||
# Load full schema on demand
|
||||
schema = index.describe("brave-search", "web_search")
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolInfo:
|
||||
"""Minimal tool information for static context."""
|
||||
name: str
|
||||
hint: str # One-line description
|
||||
server: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"hint": self.hint,
|
||||
"server": self.server,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerStatus:
|
||||
"""Status information for an MCP server."""
|
||||
server: str
|
||||
available: bool = True
|
||||
auth_required: bool = False
|
||||
last_sync: float = 0
|
||||
tool_count: int = 0
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"server": self.server,
|
||||
"available": self.available,
|
||||
"auth_required": self.auth_required,
|
||||
"last_sync": self.last_sync,
|
||||
"tool_count": self.tool_count,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
class MCPToolIndex:
|
||||
"""
|
||||
File-backed index of MCP tool schemas.
|
||||
|
||||
Directory layout:
|
||||
~/.praison/mcp/
|
||||
├── servers/
|
||||
│ ├── brave-search/
|
||||
│ │ ├── _index.json # Tool names + hints
|
||||
│ │ ├── _status.json # Server status
|
||||
│ │ ├── web_search.json # Full schema
|
||||
│ │ └── local_search.json
|
||||
│ └── github/
|
||||
│ └── ...
|
||||
└── config.json # Server configurations
|
||||
|
||||
Features:
|
||||
- Minimal static context (names + hints only)
|
||||
- Full schemas loaded on demand
|
||||
- Server status tracking
|
||||
- Offline-first with sync
|
||||
"""
|
||||
|
||||
def __init__(self, base_dir: str = "~/.praison/mcp"):
|
||||
"""
|
||||
Initialize the tool index.
|
||||
|
||||
Args:
|
||||
base_dir: Base directory for MCP tool index
|
||||
"""
|
||||
self.base_dir = Path(base_dir).expanduser().resolve()
|
||||
self.servers_dir = self.base_dir / "servers"
|
||||
self._ensure_dirs()
|
||||
|
||||
def _ensure_dirs(self) -> None:
|
||||
"""Create necessary directories."""
|
||||
self.servers_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_server_dir(self, server: str) -> Path:
|
||||
"""Get directory for a server's tools."""
|
||||
server_dir = self.servers_dir / server
|
||||
server_dir.mkdir(parents=True, exist_ok=True)
|
||||
return server_dir
|
||||
|
||||
def sync(self, server: str, tools: Optional[List[Dict[str, Any]]] = None) -> int:
|
||||
"""
|
||||
Sync tool schemas from an MCP server.
|
||||
|
||||
Args:
|
||||
server: Server name
|
||||
tools: List of tool schemas (if None, tries to fetch from server)
|
||||
|
||||
Returns:
|
||||
Number of tools synced
|
||||
"""
|
||||
server_dir = self._get_server_dir(server)
|
||||
|
||||
if tools is None:
|
||||
# Try to get tools from MCP
|
||||
tools = self._fetch_tools_from_server(server)
|
||||
|
||||
if not tools:
|
||||
self._update_status(server, available=False, error="No tools found")
|
||||
return 0
|
||||
|
||||
# Build index
|
||||
index_data = []
|
||||
for tool in tools:
|
||||
tool_name = tool.get("name", "")
|
||||
if not tool_name:
|
||||
continue
|
||||
|
||||
# Extract hint (first line of description)
|
||||
description = tool.get("description", "")
|
||||
hint = description.split("\n")[0][:100] if description else ""
|
||||
|
||||
index_data.append({
|
||||
"name": tool_name,
|
||||
"hint": hint,
|
||||
})
|
||||
|
||||
# Save full schema
|
||||
schema_path = server_dir / f"{tool_name}.json"
|
||||
schema_path.write_text(json.dumps(tool, indent=2))
|
||||
|
||||
# Save index
|
||||
index_path = server_dir / "_index.json"
|
||||
index_path.write_text(json.dumps(index_data, indent=2))
|
||||
|
||||
# Update status
|
||||
self._update_status(
|
||||
server,
|
||||
available=True,
|
||||
tool_count=len(index_data),
|
||||
last_sync=time.time(),
|
||||
)
|
||||
|
||||
logger.info(f"Synced {len(index_data)} tools from {server}")
|
||||
return len(index_data)
|
||||
|
||||
def _fetch_tools_from_server(self, server: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch tools from an MCP server.
|
||||
|
||||
This is a placeholder - actual implementation would use MCP client.
|
||||
"""
|
||||
# Try to use existing MCP infrastructure
|
||||
try:
|
||||
import importlib.util
|
||||
if importlib.util.find_spec("praisonaiagents.mcp") is not None:
|
||||
# MCP module available but would need server config to connect
|
||||
pass
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _update_status(
|
||||
self,
|
||||
server: str,
|
||||
available: bool = True,
|
||||
auth_required: bool = False,
|
||||
tool_count: int = 0,
|
||||
last_sync: float = 0,
|
||||
error: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Update server status file."""
|
||||
server_dir = self._get_server_dir(server)
|
||||
status_path = server_dir / "_status.json"
|
||||
|
||||
status = ServerStatus(
|
||||
server=server,
|
||||
available=available,
|
||||
auth_required=auth_required,
|
||||
tool_count=tool_count,
|
||||
last_sync=last_sync or time.time(),
|
||||
error=error,
|
||||
)
|
||||
|
||||
status_path.write_text(json.dumps(status.to_dict(), indent=2))
|
||||
|
||||
def get_status(self, server: str) -> Optional[ServerStatus]:
|
||||
"""
|
||||
Get status for a server.
|
||||
|
||||
Args:
|
||||
server: Server name
|
||||
|
||||
Returns:
|
||||
ServerStatus or None if not found
|
||||
"""
|
||||
server_dir = self._get_server_dir(server)
|
||||
status_path = server_dir / "_status.json"
|
||||
|
||||
if not status_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(status_path.read_text())
|
||||
return ServerStatus(**data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
def list_servers(self) -> List[str]:
|
||||
"""List all indexed servers."""
|
||||
servers = []
|
||||
for path in self.servers_dir.iterdir():
|
||||
if path.is_dir():
|
||||
servers.append(path.name)
|
||||
return sorted(servers)
|
||||
|
||||
def list_tools(self, server: str) -> List[ToolInfo]:
|
||||
"""
|
||||
List tools for a server (from index).
|
||||
|
||||
Args:
|
||||
server: Server name
|
||||
|
||||
Returns:
|
||||
List of ToolInfo objects
|
||||
"""
|
||||
server_dir = self._get_server_dir(server)
|
||||
index_path = server_dir / "_index.json"
|
||||
|
||||
if not index_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
data = json.loads(index_path.read_text())
|
||||
return [
|
||||
ToolInfo(name=t["name"], hint=t.get("hint", ""), server=server)
|
||||
for t in data
|
||||
]
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return []
|
||||
|
||||
def describe(self, server: str, tool: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get full schema for a tool (loaded on demand).
|
||||
|
||||
Args:
|
||||
server: Server name
|
||||
tool: Tool name
|
||||
|
||||
Returns:
|
||||
Full tool schema or None if not found
|
||||
"""
|
||||
server_dir = self._get_server_dir(server)
|
||||
schema_path = server_dir / f"{tool}.json"
|
||||
|
||||
if not schema_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
return json.loads(schema_path.read_text())
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
def get_static_context(self, servers: Optional[List[str]] = None) -> str:
|
||||
"""
|
||||
Get minimal static context for all indexed tools.
|
||||
|
||||
This is the token-efficient representation for system prompts.
|
||||
Only includes tool names and one-line hints.
|
||||
|
||||
Args:
|
||||
servers: List of servers to include (None for all)
|
||||
|
||||
Returns:
|
||||
Formatted string for system prompt
|
||||
"""
|
||||
target_servers = servers or self.list_servers()
|
||||
|
||||
lines = ["Available MCP Tools:"]
|
||||
|
||||
for server in target_servers:
|
||||
tools = self.list_tools(server)
|
||||
if not tools:
|
||||
continue
|
||||
|
||||
lines.append(f"\n[{server}]")
|
||||
for tool in tools:
|
||||
lines.append(f" - {tool.name}: {tool.hint}")
|
||||
|
||||
lines.append("\nUse 'describe_tool' to get full schema for any tool.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def get_all_tools(self) -> List[ToolInfo]:
|
||||
"""Get all tools from all servers."""
|
||||
all_tools = []
|
||||
for server in self.list_servers():
|
||||
all_tools.extend(self.list_tools(server))
|
||||
return all_tools
|
||||
|
||||
def search_tools(self, query: str) -> List[ToolInfo]:
|
||||
"""
|
||||
Search tools by name or hint.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
|
||||
Returns:
|
||||
List of matching ToolInfo objects
|
||||
"""
|
||||
import re
|
||||
|
||||
pattern = re.compile(query, re.IGNORECASE)
|
||||
matches = []
|
||||
|
||||
for tool in self.get_all_tools():
|
||||
if pattern.search(tool.name) or pattern.search(tool.hint):
|
||||
matches.append(tool)
|
||||
|
||||
return matches
|
||||
|
||||
def clear_server(self, server: str) -> bool:
|
||||
"""
|
||||
Clear all cached data for a server.
|
||||
|
||||
Args:
|
||||
server: Server name
|
||||
|
||||
Returns:
|
||||
True if cleared successfully
|
||||
"""
|
||||
import shutil
|
||||
|
||||
server_dir = self.servers_dir / server
|
||||
if server_dir.exists():
|
||||
shutil.rmtree(server_dir)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def create_mcp_tools(
|
||||
index: Optional[MCPToolIndex] = None,
|
||||
base_dir: str = "~/.praison/mcp",
|
||||
):
|
||||
"""
|
||||
Create tools for agents to interact with MCP tool index.
|
||||
|
||||
Returns a list of tool functions that can be passed to Agent(tools=[...]).
|
||||
|
||||
Tools created:
|
||||
- mcp_list_tools: List available MCP tools
|
||||
- mcp_describe_tool: Get full schema for a tool
|
||||
- mcp_search_tools: Search tools by name/description
|
||||
|
||||
Example:
|
||||
tools = create_mcp_tools()
|
||||
agent = Agent(name="MyAgent", tools=tools)
|
||||
"""
|
||||
tool_index = index or MCPToolIndex(base_dir=base_dir)
|
||||
|
||||
def mcp_list_tools(server: Optional[str] = None) -> str:
|
||||
"""
|
||||
List available MCP tools.
|
||||
|
||||
Args:
|
||||
server: Server name (None for all servers)
|
||||
|
||||
Returns:
|
||||
Formatted list of tools
|
||||
"""
|
||||
if server:
|
||||
tools = tool_index.list_tools(server)
|
||||
if not tools:
|
||||
return f"No tools found for server: {server}"
|
||||
|
||||
lines = [f"Tools for {server}:"]
|
||||
for tool in tools:
|
||||
lines.append(f" - {tool.name}: {tool.hint}")
|
||||
return "\n".join(lines)
|
||||
else:
|
||||
return tool_index.get_static_context()
|
||||
|
||||
def mcp_describe_tool(server: str, tool: str) -> str:
|
||||
"""
|
||||
Get full schema for an MCP tool.
|
||||
|
||||
Args:
|
||||
server: Server name
|
||||
tool: Tool name
|
||||
|
||||
Returns:
|
||||
JSON schema for the tool
|
||||
"""
|
||||
schema = tool_index.describe(server, tool)
|
||||
if not schema:
|
||||
return f"Tool not found: {server}/{tool}"
|
||||
|
||||
return json.dumps(schema, indent=2)
|
||||
|
||||
def mcp_search_tools(query: str) -> str:
|
||||
"""
|
||||
Search MCP tools by name or description.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
|
||||
Returns:
|
||||
Formatted list of matching tools
|
||||
"""
|
||||
matches = tool_index.search_tools(query)
|
||||
if not matches:
|
||||
return f"No tools found matching: {query}"
|
||||
|
||||
lines = [f"Found {len(matches)} tools matching '{query}':"]
|
||||
for tool in matches:
|
||||
lines.append(f" - [{tool.server}] {tool.name}: {tool.hint}")
|
||||
return "\n".join(lines)
|
||||
|
||||
return [mcp_list_tools, mcp_describe_tool, mcp_search_tools]
|
||||
@@ -358,6 +358,69 @@ class AIGenerator:
|
||||
pass
|
||||
return self._fast_context_handler
|
||||
|
||||
def _detect_feature_capabilities(self, slug_str: str, source_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Detect feature capabilities from source analysis."""
|
||||
caps = {
|
||||
"supports_bool": True,
|
||||
"supports_list": False,
|
||||
"supports_dict": False,
|
||||
"supports_presets": [],
|
||||
"supports_url": False,
|
||||
"usage_forms": [],
|
||||
"decorator": None,
|
||||
"built_in_items": [],
|
||||
}
|
||||
|
||||
# Feature-specific detection based on slug
|
||||
if slug_str == "tools":
|
||||
caps["supports_list"] = True
|
||||
caps["supports_bool"] = False
|
||||
caps["decorator"] = "@tool"
|
||||
caps["built_in_items"] = ["duckduckgo", "wikipedia_tools", "read_file", "execute_command"]
|
||||
caps["usage_forms"] = [
|
||||
"- List of tools: `tools=[my_func, another_func]`",
|
||||
"- Using @tool decorator to create custom tools",
|
||||
"- Using built-in tools: duckduckgo, wikipedia_tools, etc.",
|
||||
]
|
||||
elif slug_str == "memory":
|
||||
caps["supports_dict"] = True
|
||||
caps["supports_presets"] = ["sqlite", "redis", "postgres", "qdrant", "chroma"]
|
||||
caps["supports_url"] = True
|
||||
caps["usage_forms"] = [
|
||||
"- Boolean: `memory=True`",
|
||||
"- Preset: `memory=\"sqlite\"` or `memory=\"redis\"`",
|
||||
"- URL: `memory=\"redis://localhost:6379\"`",
|
||||
"- Dict: `memory={\"provider\": \"qdrant\"}`",
|
||||
]
|
||||
elif slug_str == "knowledge":
|
||||
caps["supports_dict"] = True
|
||||
caps["supports_list"] = True
|
||||
caps["usage_forms"] = [
|
||||
"- Boolean: `knowledge=True`",
|
||||
"- List of sources: `knowledge=[\"file.pdf\", \"doc.txt\"]`",
|
||||
"- Dict config: `knowledge={\"sources\": [...]}`",
|
||||
]
|
||||
elif slug_str == "guardrails":
|
||||
caps["supports_list"] = True
|
||||
caps["decorator"] = "guardrail function"
|
||||
caps["usage_forms"] = [
|
||||
"- Boolean: `guardrails=True`",
|
||||
"- List of functions: `guardrails=[my_guardrail_func]`",
|
||||
"- Custom guardrail function returning GuardrailResult",
|
||||
]
|
||||
elif slug_str == "streaming":
|
||||
caps["supports_bool"] = True
|
||||
caps["usage_forms"] = [
|
||||
"- Boolean: `output=\"stream\"` or iterate over agent.start()",
|
||||
"- Streaming with callbacks",
|
||||
]
|
||||
else:
|
||||
# Generic detection from source
|
||||
caps["usage_forms"] = [f"- Boolean: `{slug_str}=True`"]
|
||||
|
||||
logger.debug(f"Feature '{slug_str}' capabilities: {caps}")
|
||||
return caps
|
||||
|
||||
def _get_agent(self):
|
||||
"""Lazy-load the generation agent."""
|
||||
if self._agent is None:
|
||||
@@ -591,14 +654,19 @@ ISSUES: List any issues found (or "None")
|
||||
context = {}
|
||||
slug_str = slug.normalised
|
||||
|
||||
logger.debug(f"Gathering context for feature '{slug_str}', artifact '{artifact_type.value}'")
|
||||
|
||||
# Use FastContext to find relevant code in SDK
|
||||
fast_context = self._get_fast_context_handler()
|
||||
if fast_context and self.sdk_root:
|
||||
logger.debug(f"FastContext available, searching in {self.sdk_root}")
|
||||
try:
|
||||
# Search for feature-related code
|
||||
query = f"Find {slug_str} implementation, usage examples, and API"
|
||||
matches = fast_context.search_context(query, str(self.sdk_root), use_llm_keywords=False)
|
||||
|
||||
logger.debug(f"FastContext found {len(matches)} matches for '{query}'")
|
||||
|
||||
# Read matched files
|
||||
for match in matches[:3]:
|
||||
file_path = self.sdk_root / match.get('file', '')
|
||||
@@ -606,10 +674,11 @@ ISSUES: List any issues found (or "None")
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")[:2000]
|
||||
context[f"sdk_{file_path.stem}"] = content
|
||||
logger.debug(f"Added SDK context: {file_path.name} ({len(content)} chars)")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"FastContext search failed: {e}")
|
||||
|
||||
# Get SDK module info (fallback)
|
||||
if self.sdk_root and not context:
|
||||
@@ -653,6 +722,7 @@ ISSUES: List any issues found (or "None")
|
||||
if doc_path.exists():
|
||||
context[f"existing_{doc_type}"] = doc_path.read_text(encoding="utf-8")[:2000]
|
||||
|
||||
logger.debug(f"Context gathered for '{slug_str}': {list(context.keys())}")
|
||||
return context
|
||||
|
||||
def _read_sdk_module(self, sdk_path: Path) -> str:
|
||||
@@ -866,17 +936,26 @@ print(result)
|
||||
"",
|
||||
]
|
||||
|
||||
# Add usage forms guidance (common patterns for all features)
|
||||
param_name = slug_str.replace("-", "_")
|
||||
prompt_parts.extend([
|
||||
"## USAGE FORMS TO DEMONSTRATE (MUST cover ALL these progressively):",
|
||||
f"- Boolean: `{param_name}=True` - Enable with defaults",
|
||||
f"- String preset: `{param_name}=\"sqlite\"` or `{param_name}=\"redis\"` - Use predefined backend",
|
||||
f"- URL: `{param_name}=\"redis://localhost:6379\"` - Backend-specific connection",
|
||||
f"- Dict config: `{param_name}={{\"backend\": \"postgres\", \"user_id\": \"u1\"}}` - Custom config",
|
||||
f"- Array + overrides: `{param_name}=[\"redis\", {{\"user_id\": \"u1\"}}]` - Preset + customization",
|
||||
"",
|
||||
])
|
||||
# Get dynamic feature capabilities - NO hardcoded usage forms
|
||||
capabilities = self._detect_feature_capabilities(slug_str, source_info)
|
||||
|
||||
# Add ONLY the usage forms this feature actually supports
|
||||
prompt_parts.append("## USAGE FORMS TO DEMONSTRATE (cover these progressively):")
|
||||
for usage_form in capabilities["usage_forms"]:
|
||||
prompt_parts.append(usage_form)
|
||||
|
||||
# Add decorator info if applicable
|
||||
if capabilities.get("decorator"):
|
||||
prompt_parts.append(f"- MUST demonstrate the {capabilities['decorator']} pattern")
|
||||
|
||||
# Add built-in items if applicable
|
||||
if capabilities.get("built_in_items"):
|
||||
items = ", ".join(capabilities["built_in_items"][:4])
|
||||
prompt_parts.append(f"- Built-in options to show: {items}")
|
||||
|
||||
prompt_parts.append("")
|
||||
|
||||
logger.debug(f"Feature '{slug_str}' usage forms: {capabilities['usage_forms']}")
|
||||
|
||||
# Add dynamically extracted parameters if available
|
||||
main_class = source_info.get("main_class")
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.7.3"
|
||||
__version__ = "3.8.2"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=0.11.2",
|
||||
"praisonaiagents>=0.11.8",
|
||||
"python-dotenv>=0.19.0",
|
||||
"instructor>=1.3.3",
|
||||
"PyYAML>=6.0",
|
||||
|
||||
@@ -3,26 +3,35 @@
|
||||
Combined bump version and release script for PraisonAI package.
|
||||
|
||||
Usage:
|
||||
python src/praisonai/scripts/bump_and_release.py 2.2.99
|
||||
# Basic: bump praisonai to 3.8.2 with agents 0.11.8
|
||||
python scripts/bump_and_release.py --agents 0.11.8 3.8.2
|
||||
|
||||
Or with optional praisonaiagents version:
|
||||
python src/praisonai/scripts/bump_and_release.py 2.2.99 --agents 0.0.169
|
||||
# Wait for agents version to propagate to PyPI (recommended after publishing agents)
|
||||
python scripts/bump_and_release.py --agents 0.11.8 --wait 3.8.2
|
||||
|
||||
# Auto-detect latest agents version from PyPI
|
||||
python scripts/bump_and_release.py --auto 3.8.2
|
||||
|
||||
This script:
|
||||
1. Bumps version in all required files
|
||||
2. Copies root README.md to package dir
|
||||
3. Runs uv lock & uv build
|
||||
4. Commits changes
|
||||
5. Creates git tag
|
||||
6. Pushes to GitHub
|
||||
7. Creates GitHub release (latest)
|
||||
1. Validates pre-flight conditions (clean git state, versions)
|
||||
2. Optionally waits for praisonaiagents to be available on PyPI
|
||||
3. Bumps version in all required files
|
||||
4. Copies root README.md to package dir
|
||||
5. Runs uv lock & uv build
|
||||
6. Commits changes
|
||||
7. Creates git tag
|
||||
8. Pushes to GitHub (with rebase if needed)
|
||||
9. Creates GitHub release (latest)
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import argparse
|
||||
import subprocess
|
||||
import shutil
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -37,20 +46,63 @@ def get_praisonai_dir() -> Path:
|
||||
return get_project_root() / "src/praisonai"
|
||||
|
||||
|
||||
def run(cmd: list[str], cwd: Optional[Path] = None, check: bool = True) -> subprocess.CompletedProcess:
|
||||
def run(cmd: list[str], cwd: Optional[Path] = None, check: bool = True, silent: bool = False) -> subprocess.CompletedProcess:
|
||||
"""Run a command and print it."""
|
||||
print(f"$ {' '.join(cmd)}")
|
||||
if not silent:
|
||||
print(f"$ {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
if not silent:
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
if check and result.returncode != 0:
|
||||
print(f"❌ Command failed with exit code {result.returncode}")
|
||||
sys.exit(1)
|
||||
return result
|
||||
|
||||
|
||||
def get_pypi_version(package: str) -> Optional[str]:
|
||||
"""Get the latest version of a package from PyPI."""
|
||||
try:
|
||||
url = f"https://pypi.org/pypi/{package}/json"
|
||||
with urllib.request.urlopen(url, timeout=10) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
return data["info"]["version"]
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Failed to fetch PyPI version: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def wait_for_pypi_version(package: str, version: str, max_wait: int = 300, interval: int = 30) -> bool:
|
||||
"""Wait for a specific version to be available on PyPI."""
|
||||
print(f"\n⏳ Waiting for {package}=={version} to be available on PyPI...")
|
||||
start = time.time()
|
||||
|
||||
while time.time() - start < max_wait:
|
||||
current = get_pypi_version(package)
|
||||
if current == version:
|
||||
print(f" ✅ {package}=={version} is now available on PyPI")
|
||||
return True
|
||||
|
||||
elapsed = int(time.time() - start)
|
||||
remaining = max_wait - elapsed
|
||||
print(f" ⏳ Current: {current}, waiting for: {version} ({remaining}s remaining...)")
|
||||
time.sleep(interval)
|
||||
|
||||
print(f" ❌ Timeout waiting for {package}=={version} on PyPI")
|
||||
return False
|
||||
|
||||
|
||||
def check_git_status() -> bool:
|
||||
"""Check if there are uncommitted changes."""
|
||||
root = get_project_root()
|
||||
result = run(["git", "status", "--porcelain"], cwd=root, check=False, silent=True)
|
||||
if result.stdout.strip():
|
||||
return True # Has uncommitted changes
|
||||
return False
|
||||
|
||||
|
||||
def update_file(filepath: Path, patterns: list[tuple[str, str]], root: Path) -> bool:
|
||||
"""Update a file with the given regex patterns and replacements."""
|
||||
if not filepath.exists():
|
||||
@@ -130,17 +182,27 @@ def bump_version(new_version: str, agents_version: Optional[str] = None):
|
||||
print("\n✨ Version bump complete!")
|
||||
|
||||
|
||||
def validate_dependencies() -> bool:
|
||||
"""Validate that uv lock will succeed before making any changes."""
|
||||
def validate_dependencies(max_retries: int = 3, retry_interval: int = 60) -> bool:
|
||||
"""Validate that uv lock will succeed, with retry logic for PyPI propagation."""
|
||||
praisonai_dir = get_praisonai_dir()
|
||||
|
||||
print("\n🔍 Validating dependencies (dry-run)...")
|
||||
result = run(["uv", "lock", "--dry-run"], cwd=praisonai_dir, check=False)
|
||||
if result.returncode != 0:
|
||||
print("\n❌ Dependency validation failed. Fix the issues above before releasing.")
|
||||
return False
|
||||
print(" ✅ Dependencies validated successfully")
|
||||
return True
|
||||
for attempt in range(max_retries):
|
||||
print(f"\n🔍 Validating dependencies (attempt {attempt + 1}/{max_retries})...")
|
||||
|
||||
# Clear cache before validation
|
||||
run(["uv", "cache", "clean"], cwd=praisonai_dir, silent=True)
|
||||
|
||||
result = run(["uv", "lock", "--dry-run"], cwd=praisonai_dir, check=False)
|
||||
if result.returncode == 0:
|
||||
print(" ✅ Dependencies validated successfully")
|
||||
return True
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
print(f"\n⏳ Waiting {retry_interval}s for PyPI propagation before retry...")
|
||||
time.sleep(retry_interval)
|
||||
|
||||
print("\n❌ Dependency validation failed after all retries.")
|
||||
return False
|
||||
|
||||
|
||||
def release(version: str):
|
||||
@@ -179,8 +241,17 @@ def release(version: str):
|
||||
print(f"\n🏷️ Creating tag {tag}...")
|
||||
run(["git", "tag", "-f", tag], cwd=root)
|
||||
|
||||
# 6. Push to GitHub
|
||||
# 6. Pull rebase and push to GitHub
|
||||
print("\n⬆️ Pushing to GitHub...")
|
||||
# First fetch and rebase to handle any remote changes (e.g., auto-generated api.md)
|
||||
result = run(["git", "pull", "--rebase", "origin", "main"], cwd=root, check=False)
|
||||
if result.returncode != 0:
|
||||
print(" ⚠️ Rebase failed, trying to continue...")
|
||||
|
||||
# Recreate tag after rebase (commit hash may have changed)
|
||||
run(["git", "tag", "-f", tag], cwd=root)
|
||||
|
||||
# Push changes
|
||||
run(["git", "push"], cwd=root)
|
||||
run(["git", "push", "--tags", "-f"], cwd=root)
|
||||
|
||||
@@ -200,37 +271,105 @@ def release(version: str):
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Bump version and release PraisonAI package"
|
||||
description="Bump version and release PraisonAI package",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Bump to 3.8.2 with agents 0.11.8
|
||||
python scripts/bump_and_release.py --agents 0.11.8 3.8.2
|
||||
|
||||
# Wait for agents to propagate to PyPI first (recommended)
|
||||
python scripts/bump_and_release.py --agents 0.11.8 --wait 3.8.2
|
||||
|
||||
# Auto-detect latest agents version from PyPI
|
||||
python scripts/bump_and_release.py --auto 3.8.2
|
||||
"""
|
||||
)
|
||||
parser.add_argument(
|
||||
"version",
|
||||
help="New version number (e.g., 2.2.99)"
|
||||
help="New version number (e.g., 3.8.2)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--agents", "-a",
|
||||
help="Optional: Update praisonaiagents dependency version (e.g., 0.0.169)",
|
||||
help="Update praisonaiagents dependency version (e.g., 0.11.8)",
|
||||
default=None
|
||||
)
|
||||
parser.add_argument(
|
||||
"--auto",
|
||||
action="store_true",
|
||||
help="Auto-detect latest praisonaiagents version from PyPI"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wait", "-w",
|
||||
action="store_true",
|
||||
help="Wait for the specified agents version to be available on PyPI"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-wait",
|
||||
type=int,
|
||||
default=300,
|
||||
help="Maximum seconds to wait for PyPI propagation (default: 300)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--retries", "-r",
|
||||
type=int,
|
||||
default=3,
|
||||
help="Number of retries for dependency validation (default: 3)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force", "-f",
|
||||
action="store_true",
|
||||
help="Skip pre-flight checks (use with caution)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate version format
|
||||
if not re.match(r'^\d+\.\d+\.\d+$', args.version):
|
||||
print(f"❌ Invalid version format: {args.version}")
|
||||
print(" Expected format: X.Y.Z (e.g., 2.2.99)")
|
||||
print(" Expected format: X.Y.Z (e.g., 3.8.2)")
|
||||
sys.exit(1)
|
||||
|
||||
# Handle --auto flag
|
||||
if args.auto:
|
||||
print("\n🔍 Auto-detecting latest praisonaiagents version from PyPI...")
|
||||
agents_version = get_pypi_version("praisonaiagents")
|
||||
if not agents_version:
|
||||
print("❌ Failed to auto-detect praisonaiagents version")
|
||||
sys.exit(1)
|
||||
print(f" ✅ Latest version: {agents_version}")
|
||||
args.agents = agents_version
|
||||
|
||||
# Validate agents version format if provided
|
||||
if args.agents and not re.match(r'^\d+\.\d+\.\d+$', args.agents):
|
||||
print(f"❌ Invalid agents version format: {args.agents}")
|
||||
print(" Expected format: X.Y.Z (e.g., 0.0.169)")
|
||||
print(" Expected format: X.Y.Z (e.g., 0.11.8)")
|
||||
sys.exit(1)
|
||||
|
||||
# Pre-flight checks
|
||||
if not args.force:
|
||||
print("\n🔍 Pre-flight checks...")
|
||||
|
||||
# Check for uncommitted changes (warning only)
|
||||
if check_git_status():
|
||||
print(" ⚠️ Warning: You have uncommitted changes")
|
||||
else:
|
||||
print(" ✅ Git working directory is clean")
|
||||
|
||||
# Wait for PyPI propagation if requested
|
||||
if args.wait and args.agents:
|
||||
if not wait_for_pypi_version("praisonaiagents", args.agents, max_wait=args.max_wait):
|
||||
print("\n💡 Tip: Check if the package was published successfully")
|
||||
print("💡 Tip: You can retry without --wait if the package is confirmed published")
|
||||
sys.exit(1)
|
||||
|
||||
# Run bump version
|
||||
bump_version(args.version, args.agents)
|
||||
|
||||
# Validate dependencies after version bump (before git operations)
|
||||
if not validate_dependencies():
|
||||
# Validate dependencies after version bump (with retries)
|
||||
if not validate_dependencies(max_retries=args.retries):
|
||||
print("\n💡 Tip: Revert changes with 'git checkout .' if needed")
|
||||
print("💡 Tip: The package may need more time to propagate to PyPI")
|
||||
sys.exit(1)
|
||||
|
||||
# Run release
|
||||
|
||||
@@ -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
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user