Compare commits

...
6 Commits
Author SHA1 Message Date
Mervin Praison 4d262fc832 Release v4.3.1 2026-02-02 15:07:34 +00:00
github-actions[bot] c31c5b9fc7 docs: auto-update api.md [skip ci] 2026-02-02 10:05:53 +00:00
Mervin Praison 45419c40d8 Release v4.3.0 2026-02-02 10:05:37 +00:00
Mervin Praison 9df792b218 Release v4.2.4 2026-02-01 06:26:04 +00:00
Mervin Praison 0526cdc528 Add when and If to top-level exports for friendly imports 2026-02-01 06:19:33 +00:00
Mervin Praison e6f3438cfa Release v4.2.3 2026-01-31 22:18:09 +00:00
77 changed files with 8579 additions and 224 deletions

No files matched your search

+5
View File
@@ -208,10 +208,15 @@ from praisonaiagents import Loop, Parallel, Pipeline, Repeat, Route, Task, Workf
Methods:
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">depends_on</a>()</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">depends_on</a>(value)</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">evaluate_when</a>(context: Dict[str, Any]) -> bool</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">execute_callback</a>(task_output: TaskOutput) -> None</code>
* <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">get_next_task</a>(context: Dict[str, Any]) -> Optional[str]</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="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">loop</a>(step: Any = None, steps: Optional[List[Any]] = None, over: Optional[str] = None, from_csv: Optional[str] = None, from_file: Optional[str] = None, var_name: str = 'item', parallel: bool = False, max_workers: Optional[int] = None, output_variable: Optional[str] = None) -> Loop</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">parallel</a>(steps: List) -> Parallel</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">repeat</a>(step: Any, until: Optional[Callable[[WorkflowContext], bool]] = None, max_iterations: int = 10) -> Repeat</code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.2.2" \
"praisonai>=4.3.1" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.2.2" \
"praisonai>=4.3.1" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.2.2" \
"praisonai>=4.3.1" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
Smoke Test: Condition Syntax Comparison
This script tests both condition syntaxes WITHOUT LLM calls (deterministic):
1. AgentFlow with when() string expressions
2. AgentTeam with Task.condition dict routing
Run: python examples/smoke_test_condition_syntax.py
"""
from praisonaiagents import AgentFlow, Task
from praisonaiagents.workflows import when
print("=" * 60)
print("SMOKE TEST: Condition Syntax Comparison")
print("=" * 60)
# ============================================================
# TEST 1: AgentFlow with when() - String Expression Syntax
# ============================================================
print("\n" + "=" * 60)
print("TEST 1: AgentFlow with when() - String Expression")
print("=" * 60)
def high_score_handler(ctx):
print(" ✅ HIGH SCORE PATH: Score was >= 50")
return {"output": "High score processed"}
def low_score_handler(ctx):
print(" ❌ LOW SCORE PATH: Score was < 50")
return {"output": "Low score processed"}
# AgentFlow uses STRING condition: "{{score}} >= 50"
flow = AgentFlow(
name="Score-Based Flow",
steps=[
when(
condition="{{score}} >= 50", # STRING expression
then_steps=[high_score_handler],
else_steps=[low_score_handler]
)
],
variables={"score": 75} # Pre-set score for deterministic test
)
print("\nAgentFlow Syntax:")
print(' when(condition="{{score}} >= 50", then_steps=[...], else_steps=[...])')
print(" Variables: {'score': 75}")
print("\nRunning AgentFlow...")
try:
result = flow.start("Process the score")
print("\n✅ AgentFlow completed successfully")
except Exception as e:
print(f"\n❌ AgentFlow failed: {e}")
# ============================================================
# TEST 2: AgentTeam with Task.condition - Dict Routing Syntax
# ============================================================
print("\n" + "=" * 60)
print("TEST 2: AgentTeam with Task.condition - Dict Routing")
print("=" * 60)
# Task uses DICT condition: {"approved": ["publish"], "rejected": ["edit"]}
# NOTE: This is just showing the syntax - no LLM call needed
review_task = Task(
name="review",
description="Review the content and decide: approved or rejected",
task_type="decision",
is_start=True,
condition={ # DICT routing
"approved": ["publish"],
"rejected": ["edit"]
}
)
print("\nAgentTeam/Task Syntax:")
print(' Task(condition={"approved": ["publish"], "rejected": ["edit"]})')
print(" task_type='decision' triggers LLM to output a decision value")
print("\nTask condition structure:")
print(f" review_task.condition = {review_task.condition}")
print(f" review_task.task_type = '{review_task.task_type}'")
print("\n✅ Task condition syntax validated (no LLM call needed)")
# ============================================================
# TEST 3: NEW UNIFIED SYNTAX - Task.when (same as AgentFlow!)
# ============================================================
print("\n" + "=" * 60)
print("TEST 3: NEW UNIFIED SYNTAX - Task.when (same as AgentFlow!)")
print("=" * 60)
# Task now supports the same string expression syntax as AgentFlow!
unified_task = Task(
name="score_check",
description="Check if score passes threshold",
when="{{score}} > 80", # SAME syntax as AgentFlow!
then_task="approve",
else_task="reject"
)
print("\nNEW Unified Task Syntax:")
print(' Task(when="{{score}} > 80", then_task="approve", else_task="reject")')
print("\nTask structure:")
print(f" unified_task.when = '{unified_task.when}'")
print(f" unified_task.then_task = '{unified_task.then_task}'")
print(f" unified_task.else_task = '{unified_task.else_task}'")
# Test evaluate_when method
print("\nTesting evaluate_when():")
result_high = unified_task.evaluate_when({"score": 90})
result_low = unified_task.evaluate_when({"score": 70})
print(f" evaluate_when({{'score': 90}}) = {result_high}")
print(f" evaluate_when({{'score': 70}}) = {result_low}")
# Test get_next_task method
print("\nTesting get_next_task():")
next_high = unified_task.get_next_task({"score": 90})
next_low = unified_task.get_next_task({"score": 70})
print(f" get_next_task({{'score': 90}}) = '{next_high}'")
print(f" get_next_task({{'score': 70}}) = '{next_low}'")
print("\n✅ NEW unified Task.when syntax works!")
# ============================================================
# SUMMARY: Syntax Differences
# ============================================================
print("\n" + "=" * 60)
print("SUMMARY: Syntax Differences (CONFUSING FOR USERS)")
print("=" * 60)
print("""
┌─────────────────────────────────────────────────────────────────┐
│ CONDITION SYNTAX COMPARISON │
├─────────────────────────────────────────────────────────────────┤
│ │
│ AGENTFLOW (Deterministic Pipelines) │
│ ───────────────────────────────────── │
│ Syntax: STRING expression with {{variable}} placeholders │
│ │
│ when( │
│ condition="{{score}} >= 50", ← STRING │
│ then_steps=[approve], │
│ else_steps=[reject] │
│ ) │
│ │
│ Supported: >, <, >=, <=, ==, !=, in, contains │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ AGENTTEAM (Multi-Agent Task Graphs) │
│ ───────────────────────────────────── │
│ Syntax: DICT mapping decision values to next task names │
│ │
│ Task( │
│ condition={ ← DICT │
│ "approved": ["publish"], │
│ "rejected": ["edit"] │
│ }, │
│ task_type="decision" │
│ ) │
│ │
│ LLM outputs a decision value, Process routes by key lookup │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 🚨 CONFUSION POINTS FOR BEGINNERS: │
│ │
│ 1. Same word "condition" means DIFFERENT things │
│ 2. AgentFlow: evaluates expression → boolean │
│ 3. AgentTeam: routes by key lookup → next task │
│ 4. No unified syntax for simple use cases │
│ 5. Task also has should_run (callable) - 3rd way! │
│ │
└─────────────────────────────────────────────────────────────────┘
""")
print("\n✅ Smoke test completed!")
@@ -0,0 +1,175 @@
"""
Streaming with Reasoning Content Example
This example demonstrates how to handle streaming events with reasoning content
from models like o1, o1-mini, and deepseek-reasoner.
The is_reasoning flag allows you to distinguish between:
- Reasoning/thinking content (is_reasoning=True)
- Final response content (is_reasoning=False)
"""
from praisonaiagents import Agent
from praisonaiagents.streaming import StreamEvent, StreamEventType, StreamMetrics
def create_styled_callback():
"""Create a callback that styles reasoning content differently."""
def callback(event: StreamEvent):
if event.type == StreamEventType.DELTA_TEXT:
if event.is_reasoning:
# Dim gray for reasoning content
print(f"\033[90m{event.content}\033[0m", end="", flush=True)
else:
# Normal for response content
print(event.content, end="", flush=True)
elif event.type == StreamEventType.STREAM_END:
print() # Newline at end
return callback
def create_collecting_callback():
"""Create a callback that collects reasoning and response separately."""
reasoning_parts = []
response_parts = []
def callback(event: StreamEvent):
if event.type == StreamEventType.DELTA_TEXT:
if event.is_reasoning:
reasoning_parts.append(event.content)
else:
response_parts.append(event.content)
def get_results():
return {
"reasoning": "".join(reasoning_parts),
"response": "".join(response_parts)
}
return callback, get_results
def example_basic_streaming():
"""Basic streaming with reasoning detection."""
print("=" * 60)
print("Example 1: Basic Streaming with Reasoning Detection")
print("=" * 60)
agent = Agent(
name="thinker",
instructions="Think step by step before answering.",
llm="gpt-4o-mini" # Change to o1-mini for actual reasoning
)
# Add styled callback
agent.stream_emitter.add_callback(create_styled_callback())
# Enable metrics
agent.stream_emitter.enable_metrics()
# Run with streaming
response = agent.start("What is 15 * 23?", stream=True)
# Show metrics
metrics = agent.stream_emitter.get_metrics()
if metrics:
print(f"\n📊 {metrics.format_summary()}")
return response
def example_collect_separately():
"""Collect reasoning and response content separately."""
print("\n" + "=" * 60)
print("Example 2: Collect Reasoning and Response Separately")
print("=" * 60)
agent = Agent(
name="analyzer",
instructions="Analyze the problem carefully.",
llm="gpt-4o-mini"
)
# Create collecting callback
callback, get_results = create_collecting_callback()
agent.stream_emitter.add_callback(callback)
# Run with streaming
response = agent.start("Explain why the sky is blue.", stream=True)
# Get separated results
results = get_results()
print(f"\n📝 Reasoning length: {len(results['reasoning'])} chars")
print(f"📝 Response length: {len(results['response'])} chars")
return response
def example_with_metrics():
"""Track streaming performance metrics."""
print("\n" + "=" * 60)
print("Example 3: Streaming with Performance Metrics")
print("=" * 60)
metrics = StreamMetrics()
def metrics_callback(event: StreamEvent):
metrics.update_from_event(event)
if event.type == StreamEventType.FIRST_TOKEN:
print("⚡ First token received!")
elif event.type == StreamEventType.DELTA_TEXT:
print(event.content, end="", flush=True)
elif event.type == StreamEventType.STREAM_END:
print()
agent = Agent(name="assistant", llm="gpt-4o-mini")
agent.stream_emitter.add_callback(metrics_callback)
response = agent.start("Write a haiku about coding.", stream=True)
print("\n📊 Performance Metrics:")
print(f" TTFT: {metrics.ttft * 1000:.0f}ms")
print(f" Stream Duration: {metrics.stream_duration * 1000:.0f}ms")
print(f" Total Time: {metrics.total_time * 1000:.0f}ms")
print(f" Tokens: {metrics.token_count}")
print(f" Speed: {metrics.tokens_per_second:.1f} tokens/sec")
return response
def example_multi_agent_context():
"""Show agent context in multi-agent scenarios."""
print("\n" + "=" * 60)
print("Example 4: Multi-Agent Context")
print("=" * 60)
def context_callback(event: StreamEvent):
if event.type == StreamEventType.DELTA_TEXT:
prefix = f"[{event.agent_id}] " if event.agent_id else ""
print(f"{prefix}{event.content}", end="", flush=True)
elif event.type == StreamEventType.STREAM_END:
print()
# Note: agent_id is populated when using multi-agent workflows
agent = Agent(name="helper", llm="gpt-4o-mini")
agent.stream_emitter.add_callback(context_callback)
response = agent.start("Say hello!", stream=True)
return response
if __name__ == "__main__":
print("🚀 PraisonAI Streaming with Reasoning Examples\n")
# Run examples
example_basic_streaming()
example_collect_separately()
example_with_metrics()
example_multi_agent_context()
print("\n✅ All examples completed!")
@@ -150,10 +150,19 @@ _LAZY_IMPORTS = {
'Parallel': ('praisonaiagents.workflows', 'Parallel'),
'Loop': ('praisonaiagents.workflows', 'Loop'),
'Repeat': ('praisonaiagents.workflows', 'Repeat'),
'If': ('praisonaiagents.workflows', 'If'),
'route': ('praisonaiagents.workflows', 'route'),
'parallel': ('praisonaiagents.workflows', 'parallel'),
'loop': ('praisonaiagents.workflows', 'loop'),
'repeat': ('praisonaiagents.workflows', 'repeat'),
'when': ('praisonaiagents.workflows', 'when'),
# Conditions (Protocol-driven condition evaluation)
'ConditionProtocol': ('praisonaiagents.conditions.protocols', 'ConditionProtocol'),
'RoutingConditionProtocol': ('praisonaiagents.conditions.protocols', 'RoutingConditionProtocol'),
'ExpressionCondition': ('praisonaiagents.conditions.evaluator', 'ExpressionCondition'),
'DictCondition': ('praisonaiagents.conditions.evaluator', 'DictCondition'),
'evaluate_condition': ('praisonaiagents.conditions.evaluator', 'evaluate_condition'),
# Handoff
'Handoff': ('praisonaiagents.agent.handoff', 'Handoff'),
@@ -421,6 +430,33 @@ _LAZY_IMPORTS = {
'ProviderStatus': ('praisonaiagents.llm.failover', 'ProviderStatus'),
'FailoverConfig': ('praisonaiagents.llm.failover', 'FailoverConfig'),
'FailoverManager': ('praisonaiagents.llm.failover', 'FailoverManager'),
# Plugins - Core classes
'PluginManager': ('praisonaiagents.plugins', 'PluginManager'),
'Plugin': ('praisonaiagents.plugins', 'Plugin'),
'PluginInfo': ('praisonaiagents.plugins', 'PluginInfo'),
'PluginHook': ('praisonaiagents.plugins', 'PluginHook'),
'FunctionPlugin': ('praisonaiagents.plugins', 'FunctionPlugin'),
'get_plugin_manager': ('praisonaiagents.plugins', 'get_plugin_manager'),
# Plugins - Protocols
'PluginProtocol': ('praisonaiagents.plugins', 'PluginProtocol'),
'ToolPluginProtocol': ('praisonaiagents.plugins', 'ToolPluginProtocol'),
'HookPluginProtocol': ('praisonaiagents.plugins', 'HookPluginProtocol'),
'AgentPluginProtocol': ('praisonaiagents.plugins', 'AgentPluginProtocol'),
'LLMPluginProtocol': ('praisonaiagents.plugins', 'LLMPluginProtocol'),
# Plugins - Single-file plugin support
'PluginMetadata': ('praisonaiagents.plugins', 'PluginMetadata'),
'PluginParseError': ('praisonaiagents.plugins', 'PluginParseError'),
'parse_plugin_header': ('praisonaiagents.plugins', 'parse_plugin_header'),
'parse_plugin_header_from_file': ('praisonaiagents.plugins', 'parse_plugin_header_from_file'),
'discover_plugins': ('praisonaiagents.plugins', 'discover_plugins'),
'load_plugin': ('praisonaiagents.plugins', 'load_plugin'),
'discover_and_load_plugins': ('praisonaiagents.plugins', 'discover_and_load_plugins'),
'get_default_plugin_dirs': ('praisonaiagents.plugins', 'get_default_plugin_dirs'),
'get_plugin_template': ('praisonaiagents.plugins', 'get_plugin_template'),
'ensure_plugin_dir': ('praisonaiagents.plugins', 'ensure_plugin_dir'),
}
@@ -150,6 +150,7 @@ if TYPE_CHECKING:
from ..task.task import Task
from .handoff import Handoff, HandoffConfig, HandoffResult
from ..rag.models import RAGResult, ContextPack
from ..eval.results import EvaluationLoopResult
class Agent:
# Class-level counter for generating unique display names for nameless agents
@@ -851,6 +852,24 @@ class Agent:
elif memory is False:
memory = None
# ─────────────────────────────────────────────────────────────────────
# Resolve HISTORY from MemoryConfig - FAST PATH
# History is enabled via memory="history" preset or MemoryConfig(history=True)
# ─────────────────────────────────────────────────────────────────────
_history_enabled = False
_history_limit = 10
_history_session_id = None
# Check if memory config has history settings
if _memory_config is not None and isinstance(_memory_config, MemoryConfig):
if _memory_config.history:
_history_enabled = True
_history_limit = _memory_config.history_limit
# Use auto_save session if no explicit session and history is enabled
if _history_enabled and _history_session_id is None and auto_save:
_history_session_id = auto_save
# ─────────────────────────────────────────────────────────────────────
# Resolve KNOWLEDGE param - FAST PATH
# ─────────────────────────────────────────────────────────────────────
@@ -1085,9 +1104,19 @@ class Agent:
self.__stream_emitter = None # Will be initialized on first access
self._hooks_registry_param = hooks # Store for lazy init
# Handle model= alias for llm= (NO warnings - both are valid)
if llm is None and model is not None:
llm = model # model= is an alias for llm=
# Handle llm= deprecation: model= is the preferred parameter name
# llm= still works but shows deprecation warning
if llm is not None and model is None:
import warnings
warnings.warn(
"Parameter 'llm' is deprecated, use 'model' instead. "
"Example: Agent(model='gpt-4o-mini') instead of Agent(llm='gpt-4o-mini')",
DeprecationWarning,
stacklevel=2
)
# model= is the preferred parameter (no warning)
if model is not None:
llm = model # model= takes precedence
# Store rate limiter (optional, zero overhead when None)
self._rate_limiter = rate_limiter
@@ -1343,6 +1372,11 @@ Your Goal: {self.goal}
# Used when session_id is provided but no DB adapter
self._session_store = None
self._session_store_initialized = False
# History injection settings (for auto-injecting session history into context)
self._history_enabled = _history_enabled
self._history_limit = _history_limit
self._history_session_id = _history_session_id
# Agent-centric feature instances (lazy loaded for zero performance impact)
self._auto_memory = auto_memory
@@ -2288,6 +2322,99 @@ Summary:"""
)
return handoff_obj.execute_programmatic(self, prompt, context)
def run_until(
self,
prompt: str,
criteria: str,
threshold: float = 8.0,
max_iterations: int = 5,
mode: str = "optimize",
on_iteration: Optional[Callable[[Any], None]] = None,
verbose: bool = False,
) -> "EvaluationLoopResult":
"""
Run agent iteratively until output meets quality criteria.
This method implements the "Ralph Loop" pattern: run agent → judge output
→ improve based on feedback → repeat until threshold met.
Args:
prompt: The prompt to send to the agent
criteria: Evaluation criteria for the Judge (e.g., "Response is thorough")
threshold: Score threshold for success (default: 8.0, scale 1-10)
max_iterations: Maximum iterations before stopping (default: 5)
mode: "optimize" (stop on success) or "review" (run all iterations)
on_iteration: Optional callback called after each iteration
verbose: Enable verbose logging
Returns:
EvaluationLoopResult with iteration history and final score
Example:
```python
agent = Agent(name="analyzer", instructions="Analyze systems")
result = agent.run_until(
"Analyze the auth flow",
criteria="Analysis is thorough and actionable",
threshold=8.0,
)
print(result.final_score) # 8.5
print(result.success) # True
```
"""
from ..eval.loop import EvaluationLoop
loop = EvaluationLoop(
agent=self,
criteria=criteria,
threshold=threshold,
max_iterations=max_iterations,
mode=mode,
on_iteration=on_iteration,
verbose=verbose,
)
return loop.run(prompt)
async def run_until_async(
self,
prompt: str,
criteria: str,
threshold: float = 8.0,
max_iterations: int = 5,
mode: str = "optimize",
on_iteration: Optional[Callable[[Any], None]] = None,
verbose: bool = False,
) -> "EvaluationLoopResult":
"""
Async version of run_until().
Run agent iteratively until output meets quality criteria.
Args:
prompt: The prompt to send to the agent
criteria: Evaluation criteria for the Judge
threshold: Score threshold for success (default: 8.0)
max_iterations: Maximum iterations before stopping (default: 5)
mode: "optimize" (stop on success) or "review" (run all iterations)
on_iteration: Optional callback called after each iteration
verbose: Enable verbose logging
Returns:
EvaluationLoopResult with iteration history and final score
"""
from ..eval.loop import EvaluationLoop
loop = EvaluationLoop(
agent=self,
criteria=criteria,
threshold=threshold,
max_iterations=max_iterations,
mode=mode,
on_iteration=on_iteration,
verbose=verbose,
)
return await loop.run_async(prompt)
async def handoff_to_async(
self,
target_agent: 'Agent',
@@ -3432,7 +3559,19 @@ Your Goal: {self.goal}"""
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
# Add chat history
# Inject session history if enabled (from persistent storage)
if self._history_enabled and self._session_store is not None:
try:
session_history = self._session_store.get_chat_history(
self._history_session_id,
max_messages=self._history_limit
)
if session_history:
messages.extend(session_history)
except Exception as e:
logging.debug(f"Failed to load session history: {e}")
# Add in-memory chat history (current conversation)
if self.chat_history:
messages.extend(self.chat_history)
@@ -51,10 +51,17 @@ class BackgroundRunner:
"""
self.config = config or BackgroundConfig()
self._tasks: Dict[str, BackgroundTask] = {}
self._semaphore = asyncio.Semaphore(self.config.max_concurrent_tasks)
# Lazily create asyncio.Semaphore() to avoid Python 3.9 event loop issues
self._semaphore: Optional[asyncio.Semaphore] = None
self._running = False
self._cleanup_task: Optional[asyncio.Task] = None
def _get_semaphore(self) -> asyncio.Semaphore:
"""Get or create the semaphore lazily (Python 3.9 compatible)."""
if self._semaphore is None:
self._semaphore = asyncio.Semaphore(self.config.max_concurrent_tasks)
return self._semaphore
@property
def tasks(self) -> List[BackgroundTask]:
"""Get all tasks."""
@@ -109,7 +116,7 @@ class BackgroundRunner:
# Create the execution coroutine
async def execute():
async with self._semaphore:
async with self._get_semaphore():
task.start()
logger.info(f"Background task started: {task.name} ({task.id})")
@@ -48,7 +48,16 @@ class BackgroundTask:
_cancel_event: Optional[asyncio.Event] = field(default=None, repr=False)
def __post_init__(self):
self._cancel_event = asyncio.Event()
# Lazily create asyncio.Event() to avoid Python 3.9 event loop issues
self._cancel_event = None
# Simple flag for sync cancellation check (doesn't require event loop)
self._cancelled = False
def _get_cancel_event(self) -> asyncio.Event:
"""Get or create the cancel event lazily (Python 3.9 compatible)."""
if self._cancel_event is None:
self._cancel_event = asyncio.Event()
return self._cancel_event
@property
def is_running(self) -> bool:
@@ -93,7 +102,10 @@ class BackgroundTask:
def cancel(self):
"""Request task cancellation."""
if self._cancel_event:
# Set simple flag (works without event loop)
self._cancelled = True
# Also set the event if it exists (for async waiters)
if self._cancel_event is not None:
self._cancel_event.set()
self.status = TaskStatus.CANCELLED
self.completed_at = datetime.now()
@@ -135,6 +147,10 @@ class BackgroundTask:
def should_cancel(self) -> bool:
"""Check if cancellation was requested."""
# Check simple flag first (works without event loop)
if self._cancelled:
return True
# Also check event if it exists
return self._cancel_event is not None and self._cancel_event.is_set()
def to_dict(self) -> Dict[str, Any]:
@@ -19,6 +19,14 @@ from .types import (
logger = logging.getLogger(__name__)
def _parse_iso_timestamp(timestamp: str) -> datetime:
"""Parse ISO format timestamp, handling 'Z' suffix for Python 3.9 compatibility."""
# Python 3.9's fromisoformat() doesn't support 'Z' suffix
if timestamp.endswith('Z'):
timestamp = timestamp[:-1] + '+00:00'
return datetime.fromisoformat(timestamp)
# Protected paths that should never be checkpointed
PROTECTED_PATHS = [
os.path.expanduser("~"),
@@ -275,7 +283,7 @@ class CheckpointService:
id=commit_hash,
short_id=commit_hash[:8],
message=message,
timestamp=datetime.fromisoformat(timestamp)
timestamp=_parse_iso_timestamp(timestamp)
)
self._checkpoints.append(checkpoint)
@@ -321,7 +329,7 @@ class CheckpointService:
id=checkpoint_id,
short_id=checkpoint_id[:8],
message=message,
timestamp=datetime.fromisoformat(timestamp)
timestamp=_parse_iso_timestamp(timestamp)
)
self._emit(CheckpointEvent.CHECKPOINT_RESTORED, checkpoint)
@@ -450,7 +458,7 @@ class CheckpointService:
id=parts[0],
short_id=parts[0][:8],
message=parts[1],
timestamp=datetime.fromisoformat(parts[2])
timestamp=_parse_iso_timestamp(parts[2])
))
return checkpoints
@@ -11,6 +11,15 @@ from typing import Optional, Dict, Any, List
from datetime import datetime
from pathlib import Path
from ..paths import get_checkpoints_dir
def _parse_iso_timestamp(timestamp: str) -> datetime:
"""Parse ISO format timestamp, handling 'Z' suffix for Python 3.9 compatibility."""
if timestamp.endswith('Z'):
timestamp = timestamp[:-1] + '+00:00'
return datetime.fromisoformat(timestamp)
class CheckpointEvent(str, Enum):
"""Events emitted by the checkpoint service."""
@@ -46,8 +55,8 @@ class CheckpointConfig:
if self.storage_dir:
self.storage_dir = os.path.expanduser(self.storage_dir)
else:
# Default storage in user's home directory
self.storage_dir = os.path.expanduser("~/.praison/checkpoints")
# Default storage in user's home directory (uses centralized paths - DRY)
self.storage_dir = str(get_checkpoints_dir())
def get_checkpoint_dir(self) -> str:
"""Get the checkpoint directory for this workspace."""
@@ -77,7 +86,7 @@ class Checkpoint:
id=commit_hash,
short_id=commit_hash[:8],
message=message,
timestamp=datetime.fromisoformat(timestamp) if isinstance(timestamp, str) else timestamp
timestamp=_parse_iso_timestamp(timestamp) if isinstance(timestamp, str) else timestamp
)
def to_dict(self) -> Dict[str, Any]:
@@ -0,0 +1,25 @@
"""
Conditions Module - Unified condition evaluation for workflows.
This module provides a protocol-driven approach to condition evaluation,
enabling DRY condition handling across AgentFlow and AgentTeam.
Exports:
- ConditionProtocol: Protocol interface for condition implementations
- ExpressionCondition: String-based conditions like "{{score}} > 80"
- DictCondition: Dict-based routing conditions
- evaluate_condition: Standalone function for condition evaluation
"""
from .protocols import ConditionProtocol
from .evaluator import (
ExpressionCondition,
DictCondition,
evaluate_condition,
)
__all__ = [
'ConditionProtocol',
'ExpressionCondition',
'DictCondition',
'evaluate_condition',
]
@@ -0,0 +1,284 @@
"""
Condition Evaluator - Shared condition evaluation logic.
This module provides the shared condition evaluation logic used by both
AgentFlow (string-based conditions) and AgentTeam (dict-based routing).
The implementation is extracted from workflows.py _evaluate_condition()
to enable DRY condition handling across the codebase.
"""
import re
import logging
from typing import Dict, Any, List, Optional
logger = logging.getLogger(__name__)
class ExpressionCondition:
"""
String-based condition evaluator for expressions like "{{score}} > 80".
Supports:
- Numeric comparison: "{{var}} > 80", "{{var}} >= 50", "{{var}} < 10"
- String equality: "{{var}} == approved", "{{var}} != rejected"
- Contains check: "error in {{message}}", "{{status}} contains success"
- Boolean: "{{flag}}" (truthy check)
- Nested property: "{{item.score}} >= 60"
Example:
```python
cond = ExpressionCondition("{{score}} > 80")
result = cond.evaluate({"score": 90}) # True
```
"""
def __init__(self, expression: str):
"""
Initialize with a condition expression.
Args:
expression: Condition string with {{var}} placeholders.
"""
self.expression = expression
def evaluate(self, context: Dict[str, Any]) -> bool:
"""
Evaluate the condition against the given context.
Args:
context: Dictionary containing variables for evaluation.
Special key 'previous_output' is also substituted.
Returns:
Boolean result of condition evaluation.
Returns False on evaluation errors (fail-safe).
"""
return evaluate_condition(
self.expression,
context,
previous_output=context.get('previous_output')
)
def __repr__(self) -> str:
return f"ExpressionCondition({self.expression!r})"
class DictCondition:
"""
Dict-based condition evaluator for routing decisions.
Used by AgentTeam for task routing based on decision keys.
Example:
```python
cond = DictCondition(
{"approved": ["publish"], "rejected": ["revise"]},
key="decision"
)
result = cond.evaluate({"decision": "approved"}) # True
targets = cond.get_target({"decision": "approved"}) # ["publish"]
```
"""
def __init__(self, routes: Dict[str, List[str]], key: str = "decision"):
"""
Initialize with routing configuration.
Args:
routes: Dict mapping decision values to target task lists.
key: Context key to read decision value from.
"""
self.routes = routes
self.key = key
def evaluate(self, context: Dict[str, Any]) -> bool:
"""
Check if the context contains a valid routing decision.
Args:
context: Dictionary containing the decision key.
Returns:
True if decision value matches a route key.
"""
decision = context.get(self.key, "")
if isinstance(decision, str):
decision = decision.lower()
return decision in self.routes
def get_target(self, context: Dict[str, Any]) -> List[str]:
"""
Get target tasks based on the decision value.
Args:
context: Dictionary containing the decision key.
Returns:
List of target task names, or empty list if no match.
"""
decision = context.get(self.key, "")
if isinstance(decision, str):
decision = decision.lower()
targets = self.routes.get(decision, [])
if isinstance(targets, str):
return [targets]
return targets if targets else []
def __repr__(self) -> str:
return f"DictCondition(routes={self.routes!r}, key={self.key!r})"
def evaluate_condition(
condition: str,
variables: Dict[str, Any],
previous_output: Optional[str] = None
) -> bool:
"""
Evaluate a condition expression with variable substitution.
This is the shared condition evaluation function used by both
AgentFlow and AgentTeam. It supports various condition formats.
Supported formats:
- Numeric comparison: "{{var}} > 80", "{{var}} >= 50", "{{var}} < 10"
- String equality: "{{var}} == approved", "{{var}} != rejected"
- Contains check: "error in {{message}}", "{{status}} contains success"
- Boolean: "{{flag}}" (truthy check)
- Nested property: "{{item.score}} >= 60"
Args:
condition: Condition expression with {{var}} placeholders.
variables: Dictionary containing variables for substitution.
previous_output: Optional output from previous step (substitutes {{previous_output}}).
Returns:
Boolean result of condition evaluation.
Returns False on evaluation errors (fail-safe).
Example:
```python
result = evaluate_condition("{{score}} > 80", {"score": 90}) # True
result = evaluate_condition("{{status}} == approved", {"status": "approved"}) # True
result = evaluate_condition("error in {{message}}", {"message": "An error occurred"}) # True
```
"""
# Substitute variables in condition
substituted = condition
# Handle nested property access like {{item.score}}
def get_nested_value(var_path: str, vars_dict: Dict[str, Any]) -> Any:
parts = var_path.split('.')
value = vars_dict
for part in parts:
if isinstance(value, dict):
value = value.get(part)
else:
return None
return value
# First substitute {{previous_output}} if provided (before general variable substitution)
if previous_output is not None:
substituted = substituted.replace("{{previous_output}}", str(previous_output))
# Find all {{var}} patterns and substitute
pattern = r'\{\{([^}]+)\}\}'
matches = re.findall(pattern, substituted) # Use substituted, not condition
for var_name in matches:
# Skip previous_output as it's already handled
if var_name == 'previous_output':
continue
if '.' in var_name:
# Nested property access
value = get_nested_value(var_name, variables)
else:
value = variables.get(var_name)
if value is None:
value = ""
# Replace the placeholder with the value
placeholder = f"{{{{{var_name}}}}}"
if isinstance(value, (int, float)):
substituted = substituted.replace(placeholder, str(value))
elif isinstance(value, bool):
substituted = substituted.replace(placeholder, str(value).lower())
else:
substituted = substituted.replace(placeholder, str(value))
# Now evaluate the substituted condition
try:
# Handle different condition formats
# Check if we have unsubstituted empty values in comparisons
# If a variable was missing, it becomes empty string which can cause issues
if ' > ' in substituted or ' < ' in substituted or ' >= ' in substituted or ' <= ' in substituted:
# Check for empty left side in comparison (missing variable)
if substituted.strip().startswith('>') or substituted.strip().startswith('<'):
return False
if substituted.strip().startswith('='):
return False
# Numeric comparisons: "90 > 80", "50 >= 50", "10 < 20", etc.
numeric_pattern = r'^(-?\d+(?:\.\d+)?)\s*(>|>=|<|<=|==|!=)\s*(-?\d+(?:\.\d+)?)$'
numeric_match = re.match(numeric_pattern, substituted.strip())
if numeric_match:
left = float(numeric_match.group(1))
op = numeric_match.group(2)
right = float(numeric_match.group(3))
if op == '>':
return left > right
if op == '>=':
return left >= right
if op == '<':
return left < right
if op == '<=':
return left <= right
if op == '==':
return left == right
if op == '!=':
return left != right
# String equality: "approved == approved", "status != rejected"
string_eq_pattern = r'^(.+?)\s*(==|!=)\s*(.+)$'
string_match = re.match(string_eq_pattern, substituted.strip())
if string_match:
left = string_match.group(1).strip()
op = string_match.group(2)
right = string_match.group(3).strip()
if op == '==':
return left == right
if op == '!=':
return left != right
# Contains check: "error in some message", "status contains success"
if ' in ' in substituted:
parts = substituted.split(' in ', 1)
if len(parts) == 2:
needle = parts[0].strip()
haystack = parts[1].strip()
return needle.lower() in haystack.lower()
if ' contains ' in substituted:
parts = substituted.split(' contains ', 1)
if len(parts) == 2:
haystack = parts[0].strip()
needle = parts[1].strip()
return needle.lower() in haystack.lower()
# Boolean check: truthy evaluation
# Handle "true", "false", "True", "False"
if substituted.strip().lower() == 'true':
return True
if substituted.strip().lower() == 'false':
return False
# Non-empty string is truthy
return bool(substituted.strip())
except Exception as e:
logger.warning(f"Condition evaluation failed for '{condition}': {e}")
return False
@@ -0,0 +1,92 @@
"""
Condition Protocol Definitions.
Provides Protocol interfaces that define the minimal contract for condition implementations.
This enables:
- Unified condition evaluation across AgentFlow and AgentTeam
- Custom condition implementations
- Type checking with static analyzers
- Mocking conditions in tests
These protocols are lightweight and have zero performance impact.
"""
from typing import Protocol, runtime_checkable, Dict, Any, List, Optional
@runtime_checkable
class ConditionProtocol(Protocol):
"""
Minimal Protocol for condition implementations.
This defines the essential interface that any condition must provide.
It enables unified condition evaluation across AgentFlow (string-based)
and AgentTeam (dict-based) systems.
Example:
```python
# Create a custom condition
class ScoreCondition:
def __init__(self, threshold: int):
self.threshold = threshold
def evaluate(self, context: Dict[str, Any]) -> bool:
return context.get("score", 0) > self.threshold
# Use in workflows
cond: ConditionProtocol = ScoreCondition(80)
result = cond.evaluate({"score": 90}) # True
```
"""
def evaluate(self, context: Dict[str, Any]) -> bool:
"""
Evaluate the condition against the given context.
Args:
context: Dictionary containing variables for evaluation.
May include workflow variables, previous outputs, etc.
Returns:
Boolean result of condition evaluation.
Returns False on evaluation errors (fail-safe).
"""
...
@runtime_checkable
class RoutingConditionProtocol(ConditionProtocol, Protocol):
"""
Extended Protocol for conditions that support routing to targets.
This extends ConditionProtocol with the ability to return target
tasks/steps based on the condition evaluation. Used primarily
by AgentTeam for task routing.
Example:
```python
class ApprovalCondition:
def __init__(self, routes: Dict[str, List[str]]):
self.routes = routes
def evaluate(self, context: Dict[str, Any]) -> bool:
decision = context.get("decision", "")
return decision in self.routes
def get_target(self, context: Dict[str, Any]) -> List[str]:
decision = context.get("decision", "")
return self.routes.get(decision, [])
```
"""
def get_target(self, context: Dict[str, Any]) -> List[str]:
"""
Get the target tasks/steps based on condition evaluation.
Args:
context: Dictionary containing variables for evaluation.
Returns:
List of target task/step names to route to.
Returns empty list if no match found.
"""
...
@@ -165,6 +165,10 @@ class MemoryConfig:
# Continuous learning configuration
learn: Optional[Union[bool, LearnConfig]] = None
# History injection (auto-inject session history into context)
history: bool = False
history_limit: int = 10
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
learn_dict = None
@@ -181,6 +185,8 @@ class MemoryConfig:
"claude_memory": self.claude_memory,
"config": self.config,
"learn": learn_dict,
"history": self.history,
"history_limit": self.history_limit,
}
@@ -16,6 +16,7 @@ from typing import Any, Dict
# =============================================================================
MEMORY_PRESETS: Dict[str, Dict[str, Any]] = {
# Backend presets
"file": {"backend": "file"},
"sqlite": {"backend": "sqlite"},
"redis": {"backend": "redis"},
@@ -23,6 +24,12 @@ MEMORY_PRESETS: Dict[str, Dict[str, Any]] = {
"postgresql": {"backend": "postgres"},
"mem0": {"backend": "mem0"},
"mongodb": {"backend": "mongodb"},
# Feature presets
"learn": {"backend": "file", "learn": True}, # Enable Agent Learn with file backend
# History presets (auto-inject session history into context)
"history": {"backend": "file", "history": True, "history_limit": 10},
"session": {"backend": "file", "history": True, "history_limit": 10}, # Alias
"chat": {"backend": "file", "history": True, "history_limit": 20}, # Conversational
}
MEMORY_URL_SCHEMES: Dict[str, str] = {
@@ -76,6 +76,13 @@ __all__ = [
"count_tokens",
"needs_chunking",
"get_recommended_chunk_size",
# EvaluationLoop (iterative improvement)
"EvaluationLoop",
"EvaluationLoopConfig",
"IterationResult",
"EvaluationLoopResult",
"EvaluationLoopProtocol",
"EvaluationLoopResultProtocol",
]
_LAZY_IMPORTS = {
@@ -134,6 +141,13 @@ _LAZY_IMPORTS = {
"count_tokens": ("tokens", "count_tokens"),
"needs_chunking": ("tokens", "needs_chunking"),
"get_recommended_chunk_size": ("tokens", "get_recommended_chunk_size"),
# EvaluationLoop (iterative improvement)
"EvaluationLoop": ("loop", "EvaluationLoop"),
"EvaluationLoopConfig": ("loop", "EvaluationLoopConfig"),
"IterationResult": ("results", "IterationResult"),
"EvaluationLoopResult": ("results", "EvaluationLoopResult"),
"EvaluationLoopProtocol": ("protocols", "EvaluationLoopProtocol"),
"EvaluationLoopResultProtocol": ("protocols", "EvaluationLoopResultProtocol"),
}
@@ -0,0 +1,276 @@
"""
EvaluationLoop - Iterative evaluation and improvement loop for agents.
This module provides the EvaluationLoop class which implements the "Ralph Loop"
pattern: run agent → judge output → improve → repeat until threshold met.
Example:
from praisonaiagents import Agent
from praisonaiagents.eval import EvaluationLoop
agent = Agent(name="analyzer", instructions="Analyze systems")
loop = EvaluationLoop(agent=agent, criteria="Analysis is thorough")
result = loop.run("Analyze the auth flow")
print(result.final_score) # 8.5
print(result.success) # True
"""
import time
import logging
from typing import TYPE_CHECKING, Optional, Callable, Any, List
from dataclasses import dataclass
if TYPE_CHECKING:
from ..agent.agent import Agent
logger = logging.getLogger(__name__)
@dataclass
class EvaluationLoopConfig:
"""Configuration for EvaluationLoop."""
criteria: str = ""
threshold: float = 8.0
max_iterations: int = 5
mode: str = "optimize"
model: str = "gpt-4o-mini"
verbose: bool = False
class EvaluationLoop:
"""
Iterative evaluation loop that runs an agent, judges output, and improves.
The loop continues until either:
- The score meets or exceeds the threshold (success)
- Max iterations is reached (failure in optimize mode)
- All iterations complete (review mode always runs all)
Args:
agent: The Agent instance to evaluate
criteria: Evaluation criteria for the Judge
threshold: Score threshold for success (default: 8.0)
max_iterations: Maximum number of iterations (default: 5)
mode: "optimize" (stop on success) or "review" (run all iterations)
judge: Optional custom Judge instance
on_iteration: Optional callback called after each iteration
verbose: Enable verbose logging
model: LLM model for Judge (default: gpt-4o-mini)
Example:
loop = EvaluationLoop(
agent=my_agent,
criteria="Response is helpful and accurate",
threshold=8.0,
max_iterations=5,
on_iteration=lambda r: print(f"Score: {r.score}")
)
result = loop.run("Analyze the codebase")
"""
def __init__(
self,
agent: "Agent",
criteria: str,
threshold: float = 8.0,
max_iterations: int = 5,
mode: str = "optimize",
judge: Optional[Any] = None,
on_iteration: Optional[Callable[[Any], None]] = None,
verbose: bool = False,
model: str = "gpt-4o-mini",
):
self.agent = agent
self.criteria = criteria
self.threshold = threshold
self.max_iterations = max_iterations
self.mode = mode
self._judge = judge
self.on_iteration = on_iteration
self.verbose = verbose
self.model = model
if mode not in ("optimize", "review"):
raise ValueError(f"mode must be 'optimize' or 'review', got '{mode}'")
@property
def judge(self):
"""Lazy-load Judge to avoid import-time overhead."""
if self._judge is None:
from .judge import Judge
self._judge = Judge(
criteria=self.criteria,
model=self.model,
)
return self._judge
def _get_agent_output(self, prompt: str, iteration: int, feedback: str = "") -> str:
"""Get output from agent, optionally including feedback from previous iteration."""
if iteration == 1 or not feedback:
return str(self.agent.chat(prompt))
improved_prompt = f"{prompt}\n\nPrevious feedback to address:\n{feedback}"
return str(self.agent.chat(improved_prompt))
async def _get_agent_output_async(self, prompt: str, iteration: int, feedback: str = "") -> str:
"""Async version of _get_agent_output."""
if iteration == 1 or not feedback:
if hasattr(self.agent, 'chat_async'):
return str(await self.agent.chat_async(prompt))
return str(self.agent.chat(prompt))
improved_prompt = f"{prompt}\n\nPrevious feedback to address:\n{feedback}"
if hasattr(self.agent, 'chat_async'):
return str(await self.agent.chat_async(improved_prompt))
return str(self.agent.chat(improved_prompt))
def run(self, prompt: str) -> "EvaluationLoopResult":
"""
Run the evaluation loop synchronously.
Args:
prompt: The prompt to send to the agent
Returns:
EvaluationLoopResult with iteration results
"""
from .results import IterationResult, EvaluationLoopResult
start_time = time.time()
iterations: List[IterationResult] = []
feedback = ""
for i in range(1, self.max_iterations + 1):
if self.verbose:
logger.info(f"EvaluationLoop iteration {i}/{self.max_iterations}")
output = self._get_agent_output(prompt, i, feedback)
judge_result = self.judge.run(
output=output,
criteria=self.criteria,
)
findings = getattr(judge_result, 'suggestions', []) or []
iteration_result = IterationResult(
iteration=i,
output=output,
score=judge_result.score,
reasoning=judge_result.reasoning,
findings=findings,
)
iterations.append(iteration_result)
if self.on_iteration:
try:
self.on_iteration(iteration_result)
except Exception as e:
logger.warning(f"on_iteration callback error: {e}")
if self.mode == "optimize" and judge_result.score >= self.threshold:
if self.verbose:
logger.info(f"Threshold met at iteration {i}: {judge_result.score} >= {self.threshold}")
break
feedback = judge_result.reasoning
if findings:
feedback += "\nSuggestions:\n" + "\n".join(f"- {s}" for s in findings)
total_duration = time.time() - start_time
success = iterations[-1].score >= self.threshold if iterations else False
result = EvaluationLoopResult(
iterations=iterations,
success=success,
total_duration_seconds=total_duration,
threshold=self.threshold,
mode=self.mode,
)
if self.verbose:
result.print_summary()
return result
async def run_async(self, prompt: str) -> "EvaluationLoopResult":
"""
Run the evaluation loop asynchronously.
Args:
prompt: The prompt to send to the agent
Returns:
EvaluationLoopResult with iteration results
"""
from .results import IterationResult, EvaluationLoopResult
start_time = time.time()
iterations: List[IterationResult] = []
feedback = ""
for i in range(1, self.max_iterations + 1):
if self.verbose:
logger.info(f"EvaluationLoop iteration {i}/{self.max_iterations}")
output = await self._get_agent_output_async(prompt, i, feedback)
if hasattr(self.judge, 'run_async'):
judge_result = await self.judge.run_async(
output=output,
criteria=self.criteria,
)
else:
judge_result = self.judge.run(
output=output,
criteria=self.criteria,
)
findings = getattr(judge_result, 'suggestions', []) or []
iteration_result = IterationResult(
iteration=i,
output=output,
score=judge_result.score,
reasoning=judge_result.reasoning,
findings=findings,
)
iterations.append(iteration_result)
if self.on_iteration:
try:
self.on_iteration(iteration_result)
except Exception as e:
logger.warning(f"on_iteration callback error: {e}")
if self.mode == "optimize" and judge_result.score >= self.threshold:
if self.verbose:
logger.info(f"Threshold met at iteration {i}: {judge_result.score} >= {self.threshold}")
break
feedback = judge_result.reasoning
if findings:
feedback += "\nSuggestions:\n" + "\n".join(f"- {s}" for s in findings)
total_duration = time.time() - start_time
success = iterations[-1].score >= self.threshold if iterations else False
result = EvaluationLoopResult(
iterations=iterations,
success=success,
total_duration_seconds=total_duration,
threshold=self.threshold,
mode=self.mode,
)
if self.verbose:
result.print_summary()
return result
__all__ = [
'EvaluationLoop',
'EvaluationLoopConfig',
]
@@ -243,4 +243,58 @@ __all__ = [
'OptimizationRuleProtocol',
'JudgeProtocol',
'JudgeResultProtocol',
'EvaluationLoopProtocol',
'EvaluationLoopResultProtocol',
]
class EvaluationLoopResultProtocol(Protocol):
"""Protocol for evaluation loop results."""
@property
def success(self) -> bool:
"""Whether the loop achieved the threshold."""
...
@property
def final_score(self) -> float:
"""Get the final iteration's score."""
...
@property
def score_history(self) -> List[float]:
"""Get list of all scores across iterations."""
...
@property
def final_output(self) -> str:
"""Get the final iteration's output."""
...
class EvaluationLoopProtocol(Protocol):
"""Protocol for evaluation loops that iteratively improve agent outputs."""
def run(self, prompt: str) -> EvaluationLoopResultProtocol:
"""
Run the evaluation loop synchronously.
Args:
prompt: The prompt to send to the agent
Returns:
EvaluationLoopResultProtocol with iteration results
"""
...
async def run_async(self, prompt: str) -> EvaluationLoopResultProtocol:
"""
Run the evaluation loop asynchronously.
Args:
prompt: The prompt to send to the agent
Returns:
EvaluationLoopResultProtocol with iteration results
"""
...
@@ -547,3 +547,189 @@ class CriteriaResult:
print(f" Average Score: {self.avg_score:.2f}")
print(f" Pass Rate: {self.pass_rate:.1%}")
print(f" Status: {'PASSED' if self.passed else 'FAILED'}")
@dataclass
class IterationResult:
"""
Result from a single iteration of an evaluation loop.
Attributes:
iteration: The iteration number (1-indexed)
output: The agent's output for this iteration
score: Quality score (1-10)
reasoning: Explanation for the score
findings: List of findings/suggestions from this iteration
timestamp: When this iteration completed
metadata: Additional metadata
"""
iteration: int
output: str
score: float
reasoning: str
findings: List[str] = field(default_factory=list)
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
metadata: Dict[str, Any] = field(default_factory=dict)
@property
def passed(self) -> bool:
"""Check if this iteration passed (score >= 7.0)."""
return self.score >= 7.0
def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary."""
return {
"iteration": self.iteration,
"output": self.output,
"score": self.score,
"reasoning": self.reasoning,
"findings": self.findings,
"timestamp": self.timestamp,
"passed": self.passed,
"metadata": self.metadata,
}
@dataclass
class EvaluationLoopResult:
"""
Result from running an evaluation loop.
Attributes:
iterations: List of IterationResult from each iteration
success: Whether the loop achieved the threshold
total_duration_seconds: Total time taken
threshold: The score threshold used
mode: The mode used ('optimize' or 'review')
metadata: Additional metadata
"""
iterations: List[IterationResult] = field(default_factory=list)
success: bool = False
total_duration_seconds: float = 0.0
threshold: float = 8.0
mode: str = "optimize"
metadata: Dict[str, Any] = field(default_factory=dict)
@property
def final_score(self) -> float:
"""Get the final iteration's score."""
if not self.iterations:
return 0.0
return self.iterations[-1].score
@property
def score_history(self) -> List[float]:
"""Get list of all scores across iterations."""
return [it.score for it in self.iterations]
@property
def final_output(self) -> str:
"""Get the final iteration's output."""
if not self.iterations:
return ""
return self.iterations[-1].output
@property
def accumulated_findings(self) -> List[str]:
"""Get all findings accumulated across iterations."""
findings = []
for it in self.iterations:
findings.extend(it.findings)
return findings
@property
def num_iterations(self) -> int:
"""Get the number of iterations completed."""
return len(self.iterations)
def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary."""
return {
"success": self.success,
"final_score": self.final_score,
"score_history": self.score_history,
"final_output": self.final_output,
"accumulated_findings": self.accumulated_findings,
"num_iterations": self.num_iterations,
"total_duration_seconds": self.total_duration_seconds,
"threshold": self.threshold,
"mode": self.mode,
"iterations": [it.to_dict() for it in self.iterations],
"metadata": self.metadata,
}
def to_json(self) -> str:
"""Convert result to JSON string."""
return json.dumps(self.to_dict(), indent=2)
@property
def final_report(self) -> str:
"""Generate a markdown report of the evaluation loop."""
lines = [
"# Evaluation Loop Report",
"",
f"**Status**: {'✅ Success' if self.success else '❌ Failed'}",
f"**Final Score**: {self.final_score:.1f}/10",
f"**Threshold**: {self.threshold}/10",
f"**Iterations**: {self.num_iterations}",
f"**Duration**: {self.total_duration_seconds:.2f}s",
f"**Mode**: {self.mode}",
"",
"## Score History",
"",
]
for i, score in enumerate(self.score_history, 1):
status = "✅" if score >= self.threshold else "⬜"
lines.append(f"- Iteration {i}: {score:.1f}/10 {status}")
lines.append("")
lines.append("## Iteration Details")
lines.append("")
for it in self.iterations:
lines.append(f"### Iteration {it.iteration}")
lines.append(f"**Score**: {it.score:.1f}/10")
lines.append(f"**Reasoning**: {it.reasoning}")
if it.findings:
lines.append("**Findings**:")
for f in it.findings:
lines.append(f" - {f}")
lines.append("")
if self.accumulated_findings:
lines.append("## All Findings")
lines.append("")
for f in self.accumulated_findings:
lines.append(f"- {f}")
return "\n".join(lines)
def print_summary(self) -> None:
"""Print a summary of the evaluation loop results."""
try:
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Evaluation Loop Summary")
table.add_column("Metric", style="cyan")
table.add_column("Value", style="green" if self.success else "red")
table.add_row("Status", "✅ SUCCESS" if self.success else "❌ FAILED")
table.add_row("Final Score", f"{self.final_score:.1f}/10")
table.add_row("Threshold", f"{self.threshold}/10")
table.add_row("Iterations", str(self.num_iterations))
table.add_row("Duration", f"{self.total_duration_seconds:.2f}s")
table.add_row("Mode", self.mode)
table.add_row("Score History", " → ".join(f"{s:.1f}" for s in self.score_history))
console.print(table)
except ImportError:
print("Evaluation Loop Summary")
print(f" Status: {'SUCCESS' if self.success else 'FAILED'}")
print(f" Final Score: {self.final_score:.1f}/10")
print(f" Threshold: {self.threshold}/10")
print(f" Iterations: {self.num_iterations}")
print(f" Duration: {self.total_duration_seconds:.2f}s")
@@ -1681,6 +1681,23 @@ Now provide your final answer using this result. Summarize the information natur
response_text = resp["choices"][0]["message"]["content"]
final_response = resp
# Emit StreamEvent for reasoning content if callback provided
if _emit and reasoning_content:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=reasoning_content,
is_reasoning=True
))
# Emit StreamEvent for response content if callback provided
if _emit and response_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=response_text,
is_reasoning=False
))
# Track token usage
if self.metrics:
self._track_token_usage(final_response, self.model)
@@ -764,6 +764,14 @@ class OpenAIClient:
if rc:
reasoning_content += rc
live.update(display_fn(f"{full_response_text}\n[Reasoning: {reasoning_content}]", start_time))
# Emit reasoning content as StreamEvent with is_reasoning=True
if _emit:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=rc,
is_reasoning=True
))
# Clear the last generating display with a blank line
console.print()
@@ -978,6 +986,14 @@ class OpenAIClient:
if rc:
reasoning_content += rc
live.update(display_fn(f"{full_response_text}\n[Reasoning: {reasoning_content}]", start_time))
# Emit reasoning content as StreamEvent with is_reasoning=True
if _emit:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=rc,
is_reasoning=True
))
# Clear the last generating display with a blank line
console.print()
@@ -1543,6 +1559,8 @@ class OpenAIClient:
reasoning_steps: bool = False,
verbose: bool = True,
max_iterations: int = 10,
stream_callback: Optional[Callable] = None,
emit_events: bool = False,
**kwargs
):
"""
@@ -1560,6 +1578,8 @@ class OpenAIClient:
reasoning_steps: Whether to show reasoning
verbose: Whether to show verbose output
max_iterations: Maximum tool calling iterations
stream_callback: Optional callback for StreamEvent emission
emit_events: Whether to emit StreamEvents via callback
**kwargs: Additional API parameters
Yields:
@@ -1568,8 +1588,19 @@ class OpenAIClient:
# Format tools for OpenAI API
formatted_tools = self.format_tools(tools)
# Setup StreamEvent emission if enabled
_emit = emit_events and stream_callback is not None
if _emit:
from ..streaming.events import StreamEvent, StreamEventType
stream_callback(StreamEvent(
type=StreamEventType.REQUEST_START,
timestamp=time.perf_counter(),
metadata={"model": model, "provider": "openai"}
))
# Continue tool execution loop until no more tool calls are needed
iteration_count = 0
_first_token_emitted = False
while iteration_count < max_iterations:
try:
@@ -1594,6 +1625,20 @@ class OpenAIClient:
content = chunk.choices[0].delta.content
full_response_text += content
yield content
# Emit StreamEvent for text delta
if _emit:
if not _first_token_emitted:
stream_callback(StreamEvent(
type=StreamEventType.FIRST_TOKEN,
timestamp=time.perf_counter()
))
_first_token_emitted = True
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=content,
is_reasoning=False
))
# Handle reasoning content if enabled
if reasoning_steps and chunk.choices and hasattr(chunk.choices[0].delta, "reasoning_content"):
@@ -1601,6 +1646,14 @@ class OpenAIClient:
if rc:
reasoning_content += rc
yield f"[Reasoning: {rc}]"
# Emit StreamEvent for reasoning content
if _emit:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=rc,
is_reasoning=True
))
# Process the complete response to check for tool calls
final_response = process_stream_chunks(chunks)
@@ -86,7 +86,8 @@ class RateLimiter:
else:
self._tokens = float('inf')
self._last_update = time.monotonic()
self._lock = asyncio.Lock()
# Lazily create asyncio.Lock() to avoid Python 3.9 event loop issues
self._lock = None
# Token-based limiting
if self.tokens_per_minute is not None:
@@ -95,7 +96,8 @@ class RateLimiter:
else:
self._api_tokens = float('inf')
self._api_tokens_last_update = time.monotonic()
self._api_tokens_lock = asyncio.Lock()
# Lazily create asyncio.Lock() to avoid Python 3.9 event loop issues
self._api_tokens_lock = None
# Default implementations (can be overridden for testing)
if self._get_time is None:
@@ -105,6 +107,18 @@ class RateLimiter:
if self._async_sleep is None:
self._async_sleep = asyncio.sleep
def _get_lock(self) -> asyncio.Lock:
"""Get or create the request lock lazily (Python 3.9 compatible)."""
if self._lock is None:
self._lock = asyncio.Lock()
return self._lock
def _get_api_tokens_lock(self) -> asyncio.Lock:
"""Get or create the API tokens lock lazily (Python 3.9 compatible)."""
if self._api_tokens_lock is None:
self._api_tokens_lock = asyncio.Lock()
return self._api_tokens_lock
@property
def _rate(self) -> float:
"""Requests per second."""
@@ -203,7 +217,7 @@ class RateLimiter:
if self.requests_per_minute is None:
return
async with self._lock:
async with self._get_lock():
self._refill()
wait = self._wait_time()
@@ -228,7 +242,7 @@ class RateLimiter:
if self.tokens_per_minute is None:
return
async with self._api_tokens_lock:
async with self._get_api_tokens_lock():
self._refill_api_tokens()
wait = self._wait_time_for_tokens(num_tokens)
@@ -25,11 +25,10 @@ def get_default_auth_filepath() -> str:
Returns:
Path to mcp-auth.json in user's praison config directory
"""
# Use ~/.praison/ directory for config
home = Path.home()
praison_dir = home / ".praison"
praison_dir.mkdir(parents=True, exist_ok=True)
return str(praison_dir / "mcp-auth.json")
# Use centralized paths (DRY)
from ..paths import get_mcp_auth_path, ensure_dir, get_data_dir
ensure_dir(get_data_dir())
return str(get_mcp_auth_path())
class MCPAuthStorage:
@@ -88,9 +88,10 @@ class BaseStore(ABC):
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)
"""Default storage path (uses centralized paths - DRY)."""
from ...paths import get_learn_dir, ensure_dir
base = get_learn_dir() / self.scope / self.user_id
ensure_dir(base)
return str(base / f"{self.store_name}.json")
def _load(self) -> None:
@@ -0,0 +1,310 @@
"""
Centralized Path Utilities for PraisonAI Agents.
All persistent data uses ~/.praisonai/ by default.
Override with PRAISONAI_HOME environment variable.
This module provides a single source of truth for all data storage paths,
eliminating hardcoded paths throughout the codebase (DRY principle).
Usage:
from praisonaiagents.paths import get_data_dir, get_sessions_dir
# Get user data directory
data_dir = get_data_dir() # ~/.praisonai/
# Get specific subdirectories
sessions_dir = get_sessions_dir() # ~/.praisonai/sessions/
# Override with environment variable
# export PRAISONAI_HOME=/custom/path
# data_dir = get_data_dir() # /custom/path/
Backward Compatibility:
If ~/.praisonai/ doesn't exist but ~/.praison/ does, the legacy
path will be used with a deprecation warning. Run 'praisonai migrate-data'
to migrate to the new location.
"""
import os
import warnings
from pathlib import Path
from typing import Dict, Optional, Union
# Environment variable for override
ENV_VAR = "PRAISONAI_HOME"
# Default directory name (branded)
DEFAULT_DIR_NAME = ".praisonai"
# Legacy directory name (for backward compat)
LEGACY_DIR_NAME = ".praison"
# Cache for data dir to avoid repeated filesystem checks
_data_dir_cache: Optional[Path] = None
def _clear_cache() -> None:
"""Clear the data dir cache. Used for testing."""
global _data_dir_cache
_data_dir_cache = None
def get_data_dir() -> Path:
"""
Get PraisonAI data directory.
Priority:
1. PRAISONAI_HOME env var
2. ~/.praisonai/ (default)
3. ~/.praison/ (legacy fallback with warning)
Returns:
Path to data directory
Example:
>>> from praisonaiagents.paths import get_data_dir
>>> data_dir = get_data_dir()
>>> print(data_dir)
/home/user/.praisonai
"""
global _data_dir_cache
# Check env var first (always takes precedence, no caching)
env_path = os.environ.get(ENV_VAR)
if env_path:
return Path(env_path).expanduser()
# Return cached value if available
if _data_dir_cache is not None:
return _data_dir_cache
home = Path.home()
# Check new location first
new_path = home / DEFAULT_DIR_NAME
if new_path.exists():
_data_dir_cache = new_path
return new_path
# Check legacy location (backward compat)
legacy_path = home / LEGACY_DIR_NAME
if legacy_path.exists():
warnings.warn(
f"Using legacy data directory {legacy_path}. "
f"Run 'praisonai migrate-data' to migrate to {new_path}.",
DeprecationWarning,
stacklevel=2
)
_data_dir_cache = legacy_path
return legacy_path
# Default to new location (will be created when needed)
_data_dir_cache = new_path
return new_path
def get_sessions_dir() -> Path:
"""
Get sessions directory.
Returns:
Path to ~/.praisonai/sessions/
"""
return get_data_dir() / "sessions"
def get_skills_dir() -> Path:
"""
Get user skills directory.
Returns:
Path to ~/.praisonai/skills/
"""
return get_data_dir() / "skills"
def get_plugins_dir() -> Path:
"""
Get user plugins directory.
Returns:
Path to ~/.praisonai/plugins/
"""
return get_data_dir() / "plugins"
def get_mcp_dir() -> Path:
"""
Get MCP config directory.
Returns:
Path to ~/.praisonai/mcp/
"""
return get_data_dir() / "mcp"
def get_docs_dir() -> Path:
"""
Get docs directory.
Returns:
Path to ~/.praisonai/docs/
"""
return get_data_dir() / "docs"
def get_rules_dir() -> Path:
"""
Get rules directory.
Returns:
Path to ~/.praisonai/rules/
"""
return get_data_dir() / "rules"
def get_permissions_dir() -> Path:
"""
Get permissions directory.
Returns:
Path to ~/.praisonai/permissions/
"""
return get_data_dir() / "permissions"
def get_storage_dir() -> Path:
"""
Get generic storage directory.
Returns:
Path to ~/.praisonai/storage/
"""
return get_data_dir() / "storage"
def get_checkpoints_dir() -> Path:
"""
Get checkpoints directory.
Returns:
Path to ~/.praisonai/checkpoints/
"""
return get_data_dir() / "checkpoints"
def get_snapshots_dir() -> Path:
"""
Get snapshots directory.
Returns:
Path to ~/.praisonai/snapshots/
"""
return get_data_dir() / "snapshots"
def get_learn_dir() -> Path:
"""
Get learn directory for learning stores.
Returns:
Path to ~/.praisonai/learn/
"""
return get_data_dir() / "learn"
def get_cache_dir() -> Path:
"""
Get cache directory (disposable data).
Returns:
Path to ~/.praisonai/cache/
"""
return get_data_dir() / "cache"
def get_mcp_auth_path() -> Path:
"""
Get path to MCP auth storage file.
Returns:
Path to ~/.praisonai/mcp-auth.json
"""
return get_data_dir() / "mcp-auth.json"
def get_project_data_dir(project_path: Optional[Union[str, Path]] = None) -> Path:
"""
Get project-level data directory.
Args:
project_path: Project root (defaults to cwd)
Returns:
Path to .praisonai/ in project
Example:
>>> from praisonaiagents.paths import get_project_data_dir
>>> project_dir = get_project_data_dir("/path/to/project")
>>> print(project_dir)
/path/to/project/.praisonai
"""
if project_path is None:
base = Path.cwd()
elif isinstance(project_path, str):
base = Path(project_path)
else:
base = project_path
return base / DEFAULT_DIR_NAME
def ensure_dir(path: Union[str, Path]) -> Path:
"""
Ensure a directory exists, creating it if necessary.
Args:
path: Directory path to ensure exists
Returns:
Path object for the directory
Example:
>>> from praisonaiagents.paths import ensure_dir, get_sessions_dir
>>> sessions = ensure_dir(get_sessions_dir())
"""
if isinstance(path, str):
path = Path(path)
path.mkdir(parents=True, exist_ok=True)
return path
def get_all_paths() -> Dict[str, Path]:
"""
Get all PraisonAI data paths.
Returns:
Dictionary mapping path names to Path objects
Example:
>>> from praisonaiagents.paths import get_all_paths
>>> paths = get_all_paths()
>>> for name, path in paths.items():
... print(f"{name}: {path}")
"""
return {
"data_dir": get_data_dir(),
"sessions": get_sessions_dir(),
"skills": get_skills_dir(),
"plugins": get_plugins_dir(),
"mcp": get_mcp_dir(),
"docs": get_docs_dir(),
"rules": get_rules_dir(),
"permissions": get_permissions_dir(),
"storage": get_storage_dir(),
"checkpoints": get_checkpoints_dir(),
"snapshots": get_snapshots_dir(),
"learn": get_learn_dir(),
"cache": get_cache_dir(),
"mcp_auth": get_mcp_auth_path(),
}
@@ -14,10 +14,12 @@ from typing import Any, Callable, Dict, List, Optional
from .rules import PermissionRule, PermissionAction, PermissionResult, PersistentApproval
from .doom_loop import DoomLoopDetector
from ..paths import get_permissions_dir
logger = logging.getLogger(__name__)
# Default storage path
DEFAULT_PERMISSIONS_DIR = os.path.expanduser("~/.praison/permissions")
# Default storage path (uses centralized paths - DRY)
DEFAULT_PERMISSIONS_DIR = str(get_permissions_dir())
class PermissionManager:
@@ -36,16 +36,21 @@ class PlanStorage:
todos_dir: Directory for todo files
"""
DEFAULT_BASE_PATH = ".praison"
DEFAULT_BASE_PATH = ".praisonai" # Updated to use branded path
def __init__(self, base_path: Optional[str] = None):
"""
Initialize PlanStorage.
Args:
base_path: Base directory for storage. Defaults to .praison/
base_path: Base directory for storage. Defaults to .praisonai/
"""
self.base_path = base_path or self.DEFAULT_BASE_PATH
# Use centralized paths for project-level data (DRY)
if base_path is None:
from ..paths import get_project_data_dir
self.base_path = str(get_project_data_dir())
else:
self.base_path = base_path
self.plans_dir = os.path.join(self.base_path, "plans")
self.todos_dir = os.path.join(self.base_path, "todos")
@@ -18,21 +18,42 @@ class Process:
DEFAULT_RETRY_LIMIT = 3 # Predefined retry limit in a common place
VALIDATION_FAILURE_DECISIONS = ["invalid", "retry", "failed", "error", "unsuccessful", "fail", "errors", "reject", "rejected", "incomplete"] # Decision strings that trigger validation feedback
def __init__(self, tasks: Dict[str, Task], agents: List[Agent], manager_llm: Optional[str] = None, verbose: bool = False, max_iter: int = 10):
def __init__(
self,
tasks: Dict[str, Task],
agents: List[Agent],
manager_llm: Optional[str] = None,
verbose: bool = False,
max_iter: int = 10,
output: Optional[str] = None,
):
logging.debug(f"=== Initializing Process ===")
logging.debug(f"Number of tasks: {len(tasks)}")
logging.debug(f"Number of agents: {len(agents)}")
logging.debug(f"Manager LLM: {manager_llm}")
logging.debug(f"Verbose mode: {verbose}")
logging.debug(f"Max iterations: {max_iter}")
self.tasks = tasks
self.agents = agents
self.manager_llm = manager_llm
self.verbose = verbose
self.max_iter = max_iter
self.task_retry_counter: Dict[str, int] = {} # Initialize retry counter
self.workflow_finished = False # ADDED: Workflow finished flag
# Resolve verbose from output= param (takes precedence) or legacy verbose= param
if output is not None:
# output= takes precedence over verbose=
from ..config.presets import OUTPUT_PRESETS
preset = OUTPUT_PRESETS.get(output, {})
self._verbose = preset.get("verbose", False)
else:
# Backward compat: use legacy verbose= param
self._verbose = verbose
# Keep self.verbose as alias for backward compat
self.verbose = self._verbose
logging.debug(f"Verbose mode: {self._verbose}")
def _create_llm_instance(self):
"""Create and return a configured LLM instance for manager tasks."""
@@ -17,10 +17,12 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from ..paths import get_sessions_dir
logger = logging.getLogger(__name__)
# Default session directory
DEFAULT_SESSION_DIR = os.path.expanduser("~/.praison/sessions")
# Default session directory (uses centralized paths - DRY)
DEFAULT_SESSION_DIR = str(get_sessions_dir())
# Default limits
DEFAULT_MAX_MESSAGES = 100
@@ -14,10 +14,12 @@ import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from ..paths import get_snapshots_dir
logger = logging.getLogger(__name__)
# Default snapshot directory
DEFAULT_SNAPSHOT_DIR = os.path.expanduser("~/.praison/snapshots")
# Default snapshot directory (uses centralized paths - DRY)
DEFAULT_SNAPSHOT_DIR = str(get_snapshots_dir())
@dataclass
@@ -19,8 +19,13 @@ import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..paths import get_storage_dir
logger = logging.getLogger(__name__)
# Default storage directory (uses centralized paths - DRY)
DEFAULT_STORAGE_DIR = str(get_storage_dir())
class FileBackend:
"""
@@ -31,7 +36,7 @@ class FileBackend:
Example:
```python
backend = FileBackend(storage_dir="~/.praison/data")
backend = FileBackend(storage_dir="~/.praisonai/storage")
backend.save("session_123", {"messages": []})
data = backend.load("session_123")
```
@@ -39,7 +44,7 @@ class FileBackend:
def __init__(
self,
storage_dir: str = "~/.praison/storage",
storage_dir: str = None,
suffix: str = ".json",
pretty: bool = True,
):
@@ -51,7 +56,7 @@ class FileBackend:
suffix: File suffix (default: .json)
pretty: Use pretty-printed JSON
"""
self.storage_dir = Path(os.path.expanduser(storage_dir))
self.storage_dir = Path(os.path.expanduser(storage_dir or DEFAULT_STORAGE_DIR))
self.suffix = suffix
self.pretty = pretty
self._lock = threading.Lock()
@@ -43,6 +43,10 @@ class StreamEvent:
tool_call: Tool call data for DELTA_TOOL_CALL events
metadata: Additional event-specific metadata
error: Error message for ERROR events
is_reasoning: Whether this content is reasoning/thinking (default False)
agent_id: Optional agent identifier for multi-agent scenarios
session_id: Optional session identifier for tracking
run_id: Optional run identifier for correlation
"""
type: StreamEventType
timestamp: float = field(default_factory=time.perf_counter)
@@ -50,6 +54,10 @@ class StreamEvent:
tool_call: Optional[Dict[str, Any]] = None
metadata: Optional[Dict[str, Any]] = None
error: Optional[str] = None
is_reasoning: bool = False
agent_id: Optional[str] = None
session_id: Optional[str] = None
run_id: Optional[str] = None
def __repr__(self) -> str:
parts = [f"StreamEvent({self.type.value}"]
@@ -47,6 +47,7 @@ class Task:
name: Optional[str] = None,
tools: Optional[List[Any]] = None,
context: Optional[List[Union[str, List, 'Task']]] = None,
depends_on: Optional[List[Union[str, List, 'Task']]] = None, # Alias for context (clearer semantics)
async_execution: Optional[bool] = False,
config: Optional[Dict[str, Any]] = None,
output_file: Optional[str] = None,
@@ -93,8 +94,17 @@ class Task:
loop_var: str = "item",
# Consolidated config objects (from Task)
execution: Optional[Any] = None,
routing: Optional[Any] = None,
routing: Optional[Dict[str, List[str]]] = None, # Renamed from condition for clarity
output_config: Optional[Any] = None,
# ============================================================
# UNIFIED CONDITION SYNTAX (same as AgentFlow)
# ============================================================
# String-based condition expression (e.g., "{{score}} > 80")
when: Optional[str] = None,
# Task to route to when condition is True
then_task: Optional[str] = None,
# Task to route to when condition is False
else_task: Optional[str] = None,
# Feature configs (from Task)
autonomy: Optional[Any] = None,
knowledge: Optional[Any] = None,
@@ -136,7 +146,13 @@ class Task:
self.expected_output = expected_output if expected_output is not None else "Complete the task successfully"
self.agent = agent
self.tools = tools if tools else []
self.context = context if context else []
# Handle depends_on as alias for context (depends_on takes precedence)
if depends_on is not None:
self.context = depends_on
else:
self.context = context if context else []
# Store depends_on as property alias for context
self._depends_on = self.context
self.async_execution = async_execution
self.config = config if config else {}
self.output_file = output_file
@@ -156,7 +172,17 @@ class Task:
self.quality_check = quality_check
self.rerun = rerun # Assigning the rerun parameter
self.retain_full_context = retain_full_context
self.guardrail = guardrail if guardrail is not None else guardrails # guardrails is alias
# Handle guardrail/guardrails: guardrails (plural) is canonical, guardrail (singular) is deprecated
if guardrail is not None and guardrails is None:
import warnings
warnings.warn(
"Parameter 'guardrail' is deprecated, use 'guardrails' instead. "
"Example: Task(guardrails=my_fn) instead of Task(guardrail=my_fn)",
DeprecationWarning,
stacklevel=2
)
# guardrails takes precedence over guardrail
self.guardrail = guardrails if guardrails is not None else guardrail
self.max_retries = max_retries
self.retry_count = retry_count
self._guardrail_fn = None
@@ -182,8 +208,17 @@ class Task:
self.loop_var = loop_var
# Consolidated config objects (from Task)
self.execution = execution
self.routing = routing
# Handle routing parameter - use routing if provided, else fall back to condition
if routing is not None:
self.condition = routing
self.routing = self.condition # Alias for backward compat
self.output_config = output_config
# ============================================================
# UNIFIED CONDITION SYNTAX (same as AgentFlow)
# ============================================================
self.when = when
self.then_task = then_task
self.else_task = else_task
# Feature configs (from Task)
self.autonomy = autonomy
self.knowledge = knowledge
@@ -258,6 +293,16 @@ class Task:
# Initialize guardrail
self._setup_guardrail()
@property
def depends_on(self):
"""Alias for context - returns the list of dependent tasks."""
return self.context
@depends_on.setter
def depends_on(self, value):
"""Alias for context - sets the list of dependent tasks."""
self.context = value
def _setup_guardrail(self):
"""Setup the guardrail function based on the provided guardrail parameter."""
if self.guardrail is None:
@@ -316,6 +361,40 @@ class Task:
def __str__(self):
return f"Task(name='{self.name if self.name else 'None'}', description='{self.description}', agent='{self.agent.name if self.agent else 'None'}', status='{self.status}')"
def to_dict(self) -> Dict[str, Any]:
"""Serialize the Task to a dictionary.
Returns:
Dictionary representation of the Task.
"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"action": self.action,
"expected_output": self.expected_output,
"status": self.status,
"task_type": self.task_type,
"tools": self.tools,
"context": [str(c) if hasattr(c, '__str__') else c for c in self.context] if self.context else [],
"next_tasks": self.next_tasks,
"condition": self.condition,
"routing": self.routing,
"is_start": self.is_start,
"max_retries": self.max_retries,
"async_execution": self.async_execution,
"output_file": self.output_file,
"output_variable": self.output_variable,
"retain_full_context": self.retain_full_context,
"loop_over": self.loop_over,
"loop_var": self.loop_var,
"when": self.when,
"then_task": self.then_task,
"else_task": self.else_task,
"agent_config": self.agent_config,
"variables": self.variables,
}
def initialize_memory(self):
"""Initialize memory if config exists but memory doesn't"""
if not self.memory and self.config.get('memory_config'):
@@ -632,4 +711,51 @@ Context:
if asyncio.iscoroutinefunction(self.callback):
await self.callback(task_output)
else:
self.callback(task_output)
self.callback(task_output)
def evaluate_when(self, context: Dict[str, Any]) -> bool:
"""
Evaluate the 'when' condition against the given context.
Uses the shared evaluate_condition function from the conditions module
for DRY compliance with AgentFlow condition evaluation.
Args:
context: Dictionary containing variables for evaluation.
May include workflow variables, previous outputs, etc.
Returns:
True if condition is met or no condition is set.
False if condition is not met.
"""
if self.when is None:
return True
from ..conditions.evaluator import evaluate_condition
return evaluate_condition(
self.when,
context,
previous_output=context.get('previous_output')
)
def get_next_task(self, context: Dict[str, Any]) -> Optional[str]:
"""
Get the next task name based on the 'when' condition evaluation.
This provides a simple routing mechanism for Task objects,
similar to AgentFlow's when() function.
Args:
context: Dictionary containing variables for evaluation.
Returns:
then_task if condition is True, else_task if False.
None if no routing is configured.
"""
if self.when is None and self.then_task is None and self.else_task is None:
return None
if self.evaluate_when(context):
return self.then_task
else:
return self.else_task
@@ -43,8 +43,8 @@ def internet_search(query: str, max_results: int = 5, retries: int = MAX_RETRIES
results = []
ddgs = DDGS()
# Try text search
search_results = list(ddgs.text(keywords=query, max_results=max_results))
# Try text search (use positional 'query' for v8+ compatibility)
search_results = list(ddgs.text(query, max_results=max_results))
for result in search_results:
results.append({
@@ -226,7 +226,8 @@ def _search_duckduckgo(query: str, max_results: int = 5) -> List[Dict[str, Any]]
try:
results = []
ddgs = DDGS()
search_results = list(ddgs.text(keywords=query, max_results=max_results))
# Use positional 'query' (v8+) instead of keywords= (deprecated)
search_results = list(ddgs.text(query, max_results=max_results))
for result in search_results:
results.append({
@@ -2696,6 +2696,9 @@ CONCISE SUMMARY:"""
) -> bool:
"""Evaluate a condition expression with variable substitution.
This method delegates to the shared evaluate_condition() function
from praisonaiagents.conditions for DRY condition handling.
Supported formats:
- Numeric comparison: "{{var}} > 80", "{{var}} >= 50", "{{var}} < 10"
- String equality: "{{var}} == approved", "{{var}} != rejected"
@@ -2711,115 +2714,8 @@ CONCISE SUMMARY:"""
Returns:
Boolean result of condition evaluation
"""
import re
# Substitute variables in condition
substituted = condition
# Handle nested property access like {{item.score}}
def get_nested_value(var_path: str, vars_dict: Dict[str, Any]) -> Any:
parts = var_path.split('.')
value = vars_dict
for part in parts:
if isinstance(value, dict):
value = value.get(part)
else:
return None
return value
# Find all {{var}} patterns and substitute
pattern = r'\{\{([^}]+)\}\}'
matches = re.findall(pattern, condition)
for var_name in matches:
if '.' in var_name:
# Nested property access
value = get_nested_value(var_name, variables)
else:
value = variables.get(var_name)
if value is None:
value = ""
# Replace the placeholder with the value
placeholder = f"{{{{{var_name}}}}}"
if isinstance(value, (int, float)):
substituted = substituted.replace(placeholder, str(value))
elif isinstance(value, bool):
substituted = substituted.replace(placeholder, str(value).lower())
else:
substituted = substituted.replace(placeholder, str(value))
# Also substitute {{previous_output}}
if previous_output:
substituted = substituted.replace("{{previous_output}}", str(previous_output))
# Now evaluate the substituted condition
try:
# Handle different condition formats
# Numeric comparisons: "90 > 80", "50 >= 50", "10 < 20", etc.
numeric_pattern = r'^(-?\d+(?:\.\d+)?)\s*(>|>=|<|<=|==|!=)\s*(-?\d+(?:\.\d+)?)$'
numeric_match = re.match(numeric_pattern, substituted.strip())
if numeric_match:
left = float(numeric_match.group(1))
op = numeric_match.group(2)
right = float(numeric_match.group(3))
if op == '>':
return left > right
if op == '>=':
return left >= right
if op == '<':
return left < right
if op == '<=':
return left <= right
if op == '==':
return left == right
if op == '!=':
return left != right
# String equality: "approved == approved", "status != rejected"
string_eq_pattern = r'^(.+?)\s*(==|!=)\s*(.+)$'
string_match = re.match(string_eq_pattern, substituted.strip())
if string_match:
left = string_match.group(1).strip()
op = string_match.group(2)
right = string_match.group(3).strip()
if op == '==':
return left == right
if op == '!=':
return left != right
# Contains check: "error in some message", "status contains success"
if ' in ' in substituted:
parts = substituted.split(' in ', 1)
if len(parts) == 2:
needle = parts[0].strip()
haystack = parts[1].strip()
return needle.lower() in haystack.lower()
if ' contains ' in substituted:
parts = substituted.split(' contains ', 1)
if len(parts) == 2:
haystack = parts[0].strip()
needle = parts[1].strip()
return needle.lower() in haystack.lower()
# Boolean check: truthy evaluation
# Handle "true", "false", "True", "False"
if substituted.strip().lower() == 'true':
return True
if substituted.strip().lower() == 'false':
return False
# Non-empty string is truthy
return bool(substituted.strip())
except Exception as e:
logger.warning(f"Condition evaluation failed for '{condition}': {e}")
return False
from praisonaiagents.conditions.evaluator import evaluate_condition
return evaluate_condition(condition, variables, previous_output)
def _execute_include(
self,
@@ -3502,13 +3398,17 @@ class WorkflowManager:
context_parts = []
if step.context_from:
# Use getattr with defaults for workflow-specific attributes that may not exist on Task
context_from = getattr(step, 'context_from', None)
retain_full_context = getattr(step, 'retain_full_context', False)
if context_from:
# Include only specified steps
for step_name in step.context_from:
for step_name in context_from:
for prev_result in results:
if prev_result["step"] == step_name and prev_result.get("output"):
context_parts.append(f"## {step_name} Output:\n{prev_result['output']}")
elif step.retain_full_context:
elif retain_full_context:
# Include all previous outputs
for prev_result in results:
if prev_result.get("output") and prev_result.get("status") == "success":
@@ -3867,18 +3767,21 @@ class WorkflowManager:
# Handle branching
next_step_idx = None
if step.branch_condition and step_result["output"]:
# Use getattr with defaults for workflow-specific attributes that may not exist on Task
branch_condition = getattr(step, 'branch_condition', None)
next_steps = getattr(step, 'next_steps', None)
if branch_condition and step_result["output"]:
# Evaluate branch condition based on output
output_lower = str(step_result["output"]).lower()
for branch_key, branch_targets in step.branch_condition.items():
for branch_key, branch_targets in branch_condition.items():
if branch_key.lower() in output_lower or output_lower.startswith(branch_key.lower()):
if branch_targets and branch_targets[0] in step_lookup:
next_step_idx, _ = step_lookup[branch_targets[0]]
break
elif step.next_steps:
elif next_steps:
# Use explicit next_steps
if step.next_steps[0] in step_lookup:
next_step_idx, _ = step_lookup[step.next_steps[0]]
if next_steps[0] in step_lookup:
next_step_idx, _ = step_lookup[next_steps[0]]
# Move to next step
if next_step_idx is not None:
+8 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.2.2"
version = "1.3.1"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -57,6 +57,13 @@ api = [
"uvicorn>=0.34.0"
]
# AgentOS dependencies (alias for api + auth for deployment)
os = [
"fastapi>=0.115.0",
"uvicorn>=0.34.0",
"PyJWT>=2.8.0"
]
# Telemetry dependencies
telemetry = [
"posthog>=3.0.0"
@@ -6,12 +6,13 @@ Requires OPENAI_API_KEY environment variable.
"""
import os
import sys
import pytest
# Skip if no API key
if not os.environ.get("OPENAI_API_KEY"):
print("Skipping integration tests - OPENAI_API_KEY not set")
sys.exit(0)
# Skip entire module if no API key
pytestmark = pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set"
)
def test_guardrail_string_preset():
@@ -0,0 +1,256 @@
"""
Integration tests comparing Task.condition vs AgentFlow when() syntax.
This test file validates both condition syntaxes work correctly and
documents the differences for analysis.
"""
import pytest
class TestAgentFlowConditionSyntax:
"""Test AgentFlow's when()/if_() string-based condition syntax."""
def test_agentflow_numeric_condition(self):
"""AgentFlow uses string expressions: '{{score}} > 80'"""
from praisonaiagents import AgentFlow
from praisonaiagents.workflows import if_
results = []
def approve(ctx):
results.append("approved")
return {"output": "approved"}
def reject(ctx):
results.append("rejected")
return {"output": "rejected"}
# Test passing condition
flow = AgentFlow(
steps=[
if_(
condition="{{score}} > 80",
then_steps=[approve],
else_steps=[reject]
)
],
variables={"score": 90}
)
flow.start("test")
assert "approved" in results
# Test failing condition
results.clear()
flow2 = AgentFlow(
steps=[
if_(
condition="{{score}} > 80",
then_steps=[approve],
else_steps=[reject]
)
],
variables={"score": 70}
)
flow2.start("test")
assert "rejected" in results
def test_agentflow_string_condition(self):
"""AgentFlow string equality: '{{status}} == approved'"""
from praisonaiagents import AgentFlow
from praisonaiagents.workflows import when
results = []
def process_approved(ctx):
results.append("processed_approved")
return {"output": "done"}
def process_rejected(ctx):
results.append("processed_rejected")
return {"output": "done"}
flow = AgentFlow(
steps=[
when(
condition="{{status}} == approved",
then_steps=[process_approved],
else_steps=[process_rejected]
)
],
variables={"status": "approved"}
)
flow.start("test")
assert "processed_approved" in results
class TestTaskConditionSyntax:
"""Test Task's Dict-based condition syntax for routing."""
def test_task_dict_condition_routing(self):
"""Task uses Dict[str, List[str]]: {'approved': ['next_task']}"""
from praisonaiagents import Task
# Task condition is a dict mapping decision values to next task names
task = Task(
name="review_task",
description="Review and decide",
task_type="decision",
condition={
"approved": ["publish_task"],
"rejected": ["revise_task"]
}
)
# Verify condition structure
assert isinstance(task.condition, dict)
assert "approved" in task.condition
assert task.condition["approved"] == ["publish_task"]
assert task.condition["rejected"] == ["revise_task"]
def test_task_should_run_callable(self):
"""Task also supports should_run callable for conditional execution."""
from praisonaiagents import Task
def check_condition(ctx):
return ctx.get("score", 0) > 80
task = Task(
name="conditional_task",
description="Only run if score > 80",
should_run=check_condition
)
assert task.should_run is not None
assert callable(task.should_run)
# Test the callable
assert task.should_run({"score": 90}) is True
assert task.should_run({"score": 70}) is False
class TestSyntaxComparison:
"""Compare the two syntaxes side-by-side."""
def test_document_syntax_differences(self):
"""Document the syntax differences for analysis."""
# ============================================================
# AGENTFLOW SYNTAX (Deterministic Pipelines)
# ============================================================
# Uses: String expressions with {{variable}} placeholders
# Location: workflows/workflows.py - when(), if_(), If class
# Evaluation: evaluate_condition() in conditions/evaluator.py
#
# Example:
# when(
# condition="{{score}} > 80", # STRING expression
# then_steps=[approve],
# else_steps=[reject]
# )
#
# Supported formats:
# - Numeric: "{{var}} > 80", "{{var}} >= 50"
# - String: "{{var}} == approved"
# - Contains: "error in {{message}}"
# - Boolean: "{{flag}}"
# - Nested: "{{item.score}} >= 60"
agentflow_condition = "{{score}} > 80" # STRING
# ============================================================
# TASK/AGENTTEAM SYNTAX (Multi-Agent Task Graphs)
# ============================================================
# Uses: Dict[str, List[str]] mapping decision values to task names
# Location: task/task.py - Task.condition parameter
# Evaluation: process/process.py - matches decision_str to dict keys
#
# Example:
# Task(
# condition={
# "approved": ["publish_task"], # DICT routing
# "rejected": ["revise_task"]
# }
# )
#
# How it works:
# 1. Task with task_type="decision" returns a decision value
# 2. Process looks up decision value in condition dict
# 3. Routes to the corresponding next task(s)
task_condition = {"approved": ["publish_task"], "rejected": ["revise_task"]} # DICT
# ============================================================
# KEY DIFFERENCES (CONFUSING FOR USERS)
# ============================================================
#
# | Aspect | AgentFlow | Task/AgentTeam |
# |-----------------|--------------------|-----------------------|
# | Condition Type | STRING expression | DICT routing map |
# | Evaluation | Expression parsing | Key lookup |
# | Purpose | Branch on value | Route to next task |
# | Variables | {{var}} syntax | decision_str from LLM |
# | Flexibility | Any comparison | Exact key match |
#
# CONFUSION POINTS:
# 1. Same word "condition" means different things
# 2. AgentFlow: condition="{{x}} > 5" (evaluates expression)
# 3. Task: condition={"yes": ["task_a"]} (routes by key)
# 4. No unified syntax for beginners
assert isinstance(agentflow_condition, str)
assert isinstance(task_condition, dict)
# This test documents the confusion - both use "condition" but differently
class TestRealWorldScenarios:
"""Test real-world scenarios with both syntaxes."""
def test_agentflow_score_based_routing(self):
"""Real scenario: Route based on numeric score."""
from praisonaiagents import AgentFlow
from praisonaiagents.workflows import when
execution_log = []
def high_score_handler(ctx):
execution_log.append("high_score")
return {"output": "Premium service"}
def low_score_handler(ctx):
execution_log.append("low_score")
return {"output": "Standard service"}
# AgentFlow: Uses string expression
flow = AgentFlow(
steps=[
when(
condition="{{customer_score}} >= 80",
then_steps=[high_score_handler],
else_steps=[low_score_handler]
)
],
variables={"customer_score": 85}
)
result = flow.start("Assign service tier")
assert "high_score" in execution_log
def test_task_decision_routing(self):
"""Real scenario: Task decision routes to next task."""
from praisonaiagents import Task
# Task: Uses dict for routing
review_task = Task(
name="content_review",
description="Review content and decide: approved or rejected",
task_type="decision",
condition={
"approved": ["publish_content"],
"rejected": ["request_revision"]
}
)
# The condition dict maps LLM decision output to next task names
assert review_task.task_type == "decision"
assert "approved" in review_task.condition
assert "rejected" in review_task.condition
@@ -0,0 +1,275 @@
"""Integration tests for EvaluationLoop with mocked LLM."""
import pytest
from unittest.mock import MagicMock, patch
class TestEvaluationLoopIntegration:
"""Integration tests for EvaluationLoop."""
def test_evaluation_loop_with_agent_and_judge(self):
"""Test full integration with Agent and Judge (mocked LLM)."""
from praisonaiagents import Agent
from praisonaiagents.eval import EvaluationLoop, EvaluationLoopResult
with patch('praisonaiagents.agent.agent.Agent.chat') as mock_chat, \
patch('litellm.completion') as mock_completion:
mock_chat.side_effect = [
"First attempt at analysis",
"Improved analysis with more detail",
"Final comprehensive analysis with all requirements",
]
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = """
SCORE: 9
REASONING: Excellent comprehensive analysis
SUGGESTIONS: None needed
"""
mock_completion.return_value = mock_response
agent = Agent(name="test_agent", instructions="Analyze systems")
loop = EvaluationLoop(
agent=agent,
criteria="Analysis is thorough",
threshold=8.0,
max_iterations=3,
)
result = loop.run("Analyze the auth flow")
assert isinstance(result, EvaluationLoopResult)
assert result.final_score == 9.0
assert result.success is True
def test_agent_run_until_method(self):
"""Test Agent.run_until() convenience method."""
from praisonaiagents import Agent
from praisonaiagents.eval.results import EvaluationLoopResult
with patch('praisonaiagents.agent.agent.Agent.chat') as mock_chat, \
patch('litellm.completion') as mock_completion:
mock_chat.return_value = "Good analysis output"
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = """
SCORE: 8.5
REASONING: Good quality analysis
SUGGESTIONS: Consider edge cases
"""
mock_completion.return_value = mock_response
agent = Agent(name="test_agent", instructions="Analyze systems")
result = agent.run_until(
"Analyze the codebase",
criteria="Analysis is thorough",
threshold=8.0,
)
assert isinstance(result, EvaluationLoopResult)
assert result.success is True
assert result.final_score == 8.5
def test_evaluation_loop_iteration_callback(self):
"""Test that iteration callback is called correctly."""
from praisonaiagents import Agent
from praisonaiagents.eval import EvaluationLoop
with patch('praisonaiagents.agent.agent.Agent.chat') as mock_chat, \
patch('litellm.completion') as mock_completion:
mock_chat.return_value = "Output"
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "SCORE: 9\nREASONING: Good"
mock_completion.return_value = mock_response
callback_results = []
def on_iteration(result):
callback_results.append(result)
agent = Agent(name="test", instructions="Test")
loop = EvaluationLoop(
agent=agent,
criteria="Be good",
threshold=8.0,
on_iteration=on_iteration,
)
loop.run("Test prompt")
assert len(callback_results) == 1
assert callback_results[0].score == 9.0
def test_evaluation_loop_multiple_iterations(self):
"""Test loop runs multiple iterations when score is below threshold."""
from praisonaiagents import Agent
from praisonaiagents.eval import EvaluationLoop
with patch('praisonaiagents.agent.agent.Agent.chat') as mock_chat, \
patch('litellm.completion') as mock_completion:
mock_chat.side_effect = ["Output 1", "Output 2", "Output 3"]
responses = [
"SCORE: 5\nREASONING: Needs work\nSUGGESTIONS: Improve detail",
"SCORE: 7\nREASONING: Better\nSUGGESTIONS: Add examples",
"SCORE: 9\nREASONING: Excellent\nSUGGESTIONS: None",
]
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = responses[0]
call_count = [0]
def side_effect(*args, **kwargs):
idx = min(call_count[0], len(responses) - 1)
mock_response.choices[0].message.content = responses[idx]
call_count[0] += 1
return mock_response
mock_completion.side_effect = side_effect
agent = Agent(name="test", instructions="Test")
loop = EvaluationLoop(
agent=agent,
criteria="Be thorough",
threshold=8.0,
max_iterations=5,
)
result = loop.run("Test prompt")
assert result.success is True
assert len(result.iterations) == 3
assert result.score_history == [5.0, 7.0, 9.0]
def test_evaluation_loop_max_iterations_reached(self):
"""Test loop stops at max iterations even if threshold not met."""
from praisonaiagents import Agent
from praisonaiagents.eval import EvaluationLoop
with patch('praisonaiagents.agent.agent.Agent.chat') as mock_chat, \
patch('litellm.completion') as mock_completion:
mock_chat.return_value = "Output"
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "SCORE: 5\nREASONING: Poor"
mock_completion.return_value = mock_response
agent = Agent(name="test", instructions="Test")
loop = EvaluationLoop(
agent=agent,
criteria="Be thorough",
threshold=8.0,
max_iterations=3,
)
result = loop.run("Test prompt")
assert result.success is False
assert len(result.iterations) == 3
assert result.final_score == 5.0
def test_evaluation_loop_review_mode(self):
"""Test review mode runs all iterations regardless of score."""
from praisonaiagents import Agent
from praisonaiagents.eval import EvaluationLoop
with patch('praisonaiagents.agent.agent.Agent.chat') as mock_chat, \
patch('litellm.completion') as mock_completion:
mock_chat.return_value = "Output"
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "SCORE: 10\nREASONING: Perfect"
mock_completion.return_value = mock_response
agent = Agent(name="test", instructions="Test")
loop = EvaluationLoop(
agent=agent,
criteria="Be thorough",
threshold=8.0,
max_iterations=3,
mode="review",
)
result = loop.run("Test prompt")
assert result.success is True
assert len(result.iterations) == 3
class TestEvaluationLoopResultSerialization:
"""Tests for result serialization."""
def test_result_to_json(self):
"""Test EvaluationLoopResult.to_json()."""
from praisonaiagents.eval.results import IterationResult, EvaluationLoopResult
import json
iterations = [
IterationResult(iteration=1, output="Out1", score=7.0, reasoning="OK"),
IterationResult(iteration=2, output="Out2", score=9.0, reasoning="Good"),
]
result = EvaluationLoopResult(
iterations=iterations,
success=True,
total_duration_seconds=5.0,
)
json_str = result.to_json()
parsed = json.loads(json_str)
assert parsed["success"] is True
assert parsed["final_score"] == 9.0
assert parsed["score_history"] == [7.0, 9.0]
assert len(parsed["iterations"]) == 2
def test_result_final_report(self):
"""Test EvaluationLoopResult.final_report property."""
from praisonaiagents.eval.results import IterationResult, EvaluationLoopResult
iterations = [
IterationResult(
iteration=1,
output="First output",
score=6.0,
reasoning="Needs improvement",
findings=["Add more detail"]
),
IterationResult(
iteration=2,
output="Better output",
score=8.5,
reasoning="Good quality",
findings=[]
),
]
result = EvaluationLoopResult(
iterations=iterations,
success=True,
total_duration_seconds=3.5,
threshold=8.0,
)
report = result.final_report
assert "# Evaluation Loop Report" in report
assert "Success" in report or "✅" in report
assert "8.5" in report
assert "Iteration 1" in report
assert "Iteration 2" in report
assert "Add more detail" in report
@@ -3,11 +3,20 @@ Tests for Agent context trace emission.
TDD tests to verify that Agent emits context events when a global emitter is set.
These tests use mocking to avoid actual LLM calls.
NOTE: These tests require OPENAI_API_KEY to be set for Agent initialization.
"""
import os
import pytest
from unittest.mock import MagicMock, patch
# Skip tests if OPENAI_API_KEY is not set
pytestmark = pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set"
)
class TestAgentTraceEmission:
"""Tests for Agent emitting context trace events."""
@@ -49,7 +49,8 @@ class TestCheckpointConfig:
def test_config_default_storage(self):
"""Test default storage directory."""
config = CheckpointConfig(workspace_dir="/tmp/test")
assert ".praison/checkpoints" in config.storage_dir
# Uses centralized paths module - .praisonai/checkpoints
assert ".praisonai/checkpoints" in config.storage_dir or "checkpoints" in config.storage_dir
def test_config_exclude_patterns(self):
"""Test default exclude patterns."""
@@ -2,10 +2,17 @@
Tests for bot CLI commands.
Tests the enhanced bot command with full capability options.
NOTE: These tests require the praisonai package to be installed.
They are skipped if praisonai is not available.
"""
import pytest
from unittest.mock import patch, MagicMock
# Skip entire module if praisonai is not installed
pytest.importorskip("praisonai", reason="praisonai package not installed")
class TestBotCapabilities:
"""Tests for BotCapabilities dataclass."""
@@ -0,0 +1 @@
# Tests for conditions module
@@ -0,0 +1,166 @@
"""
Integration tests for ConditionProtocol with AgentFlow and AgentTeam.
These tests verify that the shared condition evaluation works correctly
when used through the existing AgentFlow and AgentTeam APIs.
"""
class TestAgentFlowConditionIntegration:
"""Test that AgentFlow _evaluate_condition uses shared evaluator."""
def test_agentflow_evaluate_condition_numeric(self):
"""Test AgentFlow._evaluate_condition with numeric comparison."""
from praisonaiagents.workflows.workflows import AgentFlow
flow = AgentFlow(steps=[])
# Test numeric comparisons
assert flow._evaluate_condition("{{score}} > 80", {"score": 90}) is True
assert flow._evaluate_condition("{{score}} > 80", {"score": 70}) is False
assert flow._evaluate_condition("{{score}} >= 80", {"score": 80}) is True
assert flow._evaluate_condition("{{score}} < 50", {"score": 30}) is True
def test_agentflow_evaluate_condition_string(self):
"""Test AgentFlow._evaluate_condition with string comparison."""
from praisonaiagents.workflows.workflows import AgentFlow
flow = AgentFlow(steps=[])
# Test string equality
assert flow._evaluate_condition("{{status}} == approved", {"status": "approved"}) is True
assert flow._evaluate_condition("{{status}} == approved", {"status": "rejected"}) is False
assert flow._evaluate_condition("{{status}} != rejected", {"status": "approved"}) is True
def test_agentflow_evaluate_condition_contains(self):
"""Test AgentFlow._evaluate_condition with contains check."""
from praisonaiagents.workflows.workflows import AgentFlow
flow = AgentFlow(steps=[])
# Test contains
assert flow._evaluate_condition("error in {{message}}", {"message": "An error occurred"}) is True
assert flow._evaluate_condition("error in {{message}}", {"message": "Success!"}) is False
assert flow._evaluate_condition("{{message}} contains success", {"message": "Operation success"}) is True
def test_agentflow_evaluate_condition_boolean(self):
"""Test AgentFlow._evaluate_condition with boolean evaluation."""
from praisonaiagents.workflows.workflows import AgentFlow
flow = AgentFlow(steps=[])
# Test boolean
assert flow._evaluate_condition("{{flag}}", {"flag": True}) is True
assert flow._evaluate_condition("{{flag}}", {"flag": False}) is False
assert flow._evaluate_condition("{{flag}}", {"flag": "yes"}) is True
def test_agentflow_evaluate_condition_nested(self):
"""Test AgentFlow._evaluate_condition with nested property access."""
from praisonaiagents.workflows.workflows import AgentFlow
flow = AgentFlow(steps=[])
# Test nested property
assert flow._evaluate_condition("{{item.score}} >= 60", {"item": {"score": 75}}) is True
assert flow._evaluate_condition("{{item.score}} >= 60", {"item": {"score": 50}}) is False
def test_agentflow_evaluate_condition_previous_output(self):
"""Test AgentFlow._evaluate_condition with previous_output."""
from praisonaiagents.workflows.workflows import AgentFlow
flow = AgentFlow(steps=[])
# Test previous_output substitution
result = flow._evaluate_condition(
"success in {{previous_output}}",
{},
previous_output="Operation success"
)
assert result is True
class TestSharedEvaluatorConsistency:
"""Test that shared evaluator produces consistent results."""
def test_evaluator_and_agentflow_produce_same_results(self):
"""Verify shared evaluator and AgentFlow produce identical results."""
from praisonaiagents.conditions.evaluator import evaluate_condition
from praisonaiagents.workflows.workflows import AgentFlow
flow = AgentFlow(steps=[])
test_cases = [
("{{score}} > 80", {"score": 90}, None),
("{{score}} > 80", {"score": 70}, None),
("{{status}} == approved", {"status": "approved"}, None),
("error in {{message}}", {"message": "An error occurred"}, None),
("{{flag}}", {"flag": True}, None),
("{{item.score}} >= 60", {"item": {"score": 75}}, None),
("success in {{previous_output}}", {}, "Operation success"),
]
for condition, variables, previous_output in test_cases:
shared_result = evaluate_condition(condition, variables, previous_output)
flow_result = flow._evaluate_condition(condition, variables, previous_output)
assert shared_result == flow_result, f"Mismatch for {condition}"
class TestDictConditionForAgentTeam:
"""Test DictCondition for AgentTeam-style routing."""
def test_dict_condition_routing(self):
"""Test dict condition for task routing."""
from praisonaiagents.conditions.evaluator import DictCondition
# Simulate AgentTeam task routing
cond = DictCondition(
{"approved": ["publish_task"], "rejected": ["revise_task"]},
key="decision"
)
# Test routing
assert cond.evaluate({"decision": "approved"}) is True
assert cond.get_target({"decision": "approved"}) == ["publish_task"]
assert cond.evaluate({"decision": "rejected"}) is True
assert cond.get_target({"decision": "rejected"}) == ["revise_task"]
assert cond.evaluate({"decision": "unknown"}) is False
assert cond.get_target({"decision": "unknown"}) == []
def test_dict_condition_case_insensitive(self):
"""Test dict condition is case-insensitive for decision values."""
from praisonaiagents.conditions.evaluator import DictCondition
cond = DictCondition(
{"approved": ["publish"], "rejected": ["revise"]},
key="decision"
)
# Test case insensitivity
assert cond.evaluate({"decision": "APPROVED"}) is True
assert cond.evaluate({"decision": "Approved"}) is True
assert cond.get_target({"decision": "APPROVED"}) == ["publish"]
class TestExpressionConditionClass:
"""Test ExpressionCondition class wrapper."""
def test_expression_condition_class(self):
"""Test ExpressionCondition class usage."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("{{score}} > 80")
assert cond.evaluate({"score": 90}) is True
assert cond.evaluate({"score": 70}) is False
def test_expression_condition_with_previous_output(self):
"""Test ExpressionCondition with previous_output in context."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("success in {{previous_output}}")
# previous_output is passed via context
assert cond.evaluate({"previous_output": "Operation success"}) is True
assert cond.evaluate({"previous_output": "Operation failed"}) is False
@@ -0,0 +1,252 @@
"""
TDD Tests for ConditionProtocol.
These tests define the expected behavior BEFORE implementation.
All tests should FAIL initially, then PASS after implementation.
"""
from typing import Dict, Any
class TestConditionProtocolExists:
"""Test that ConditionProtocol can be imported."""
def test_condition_protocol_importable(self):
"""ConditionProtocol should be importable from conditions module."""
from praisonaiagents.conditions.protocols import ConditionProtocol
assert ConditionProtocol is not None
def test_condition_protocol_is_runtime_checkable(self):
"""ConditionProtocol should be runtime_checkable."""
from praisonaiagents.conditions.protocols import ConditionProtocol
# Create a mock that implements the protocol
class MockCondition:
def evaluate(self, context: Dict[str, Any]) -> bool:
return True
mock = MockCondition()
assert isinstance(mock, ConditionProtocol)
def test_condition_protocol_has_evaluate_method(self):
"""ConditionProtocol should define evaluate(context) -> bool."""
from praisonaiagents.conditions.protocols import ConditionProtocol
# Check that evaluate is defined
assert hasattr(ConditionProtocol, 'evaluate')
class TestExpressionCondition:
"""Test ExpressionCondition class for string-based conditions."""
def test_expression_condition_importable(self):
"""ExpressionCondition should be importable."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
assert ExpressionCondition is not None
def test_expression_condition_implements_protocol(self):
"""ExpressionCondition should implement ConditionProtocol."""
from praisonaiagents.conditions.protocols import ConditionProtocol
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("{{score}} > 80")
assert isinstance(cond, ConditionProtocol)
def test_numeric_greater_than(self):
"""Test numeric > comparison."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("{{score}} > 80")
assert cond.evaluate({"score": 90}) is True
assert cond.evaluate({"score": 80}) is False
assert cond.evaluate({"score": 70}) is False
def test_numeric_greater_equal(self):
"""Test numeric >= comparison."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("{{score}} >= 80")
assert cond.evaluate({"score": 90}) is True
assert cond.evaluate({"score": 80}) is True
assert cond.evaluate({"score": 70}) is False
def test_numeric_less_than(self):
"""Test numeric < comparison."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("{{score}} < 50")
assert cond.evaluate({"score": 40}) is True
assert cond.evaluate({"score": 50}) is False
assert cond.evaluate({"score": 60}) is False
def test_numeric_less_equal(self):
"""Test numeric <= comparison."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("{{score}} <= 50")
assert cond.evaluate({"score": 40}) is True
assert cond.evaluate({"score": 50}) is True
assert cond.evaluate({"score": 60}) is False
def test_numeric_equal(self):
"""Test numeric == comparison."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("{{score}} == 100")
assert cond.evaluate({"score": 100}) is True
assert cond.evaluate({"score": 99}) is False
def test_numeric_not_equal(self):
"""Test numeric != comparison."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("{{score}} != 0")
assert cond.evaluate({"score": 1}) is True
assert cond.evaluate({"score": 0}) is False
def test_string_equality(self):
"""Test string == comparison."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("{{status}} == approved")
assert cond.evaluate({"status": "approved"}) is True
assert cond.evaluate({"status": "rejected"}) is False
def test_string_not_equal(self):
"""Test string != comparison."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("{{status}} != rejected")
assert cond.evaluate({"status": "approved"}) is True
assert cond.evaluate({"status": "rejected"}) is False
def test_contains_check(self):
"""Test 'in' contains check."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("error in {{message}}")
assert cond.evaluate({"message": "An error occurred"}) is True
assert cond.evaluate({"message": "Success!"}) is False
def test_contains_keyword(self):
"""Test 'contains' keyword."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("{{message}} contains success")
assert cond.evaluate({"message": "Operation success"}) is True
assert cond.evaluate({"message": "Operation failed"}) is False
def test_boolean_truthy(self):
"""Test boolean truthy evaluation."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("{{flag}}")
assert cond.evaluate({"flag": True}) is True
assert cond.evaluate({"flag": "yes"}) is True
assert cond.evaluate({"flag": False}) is False
assert cond.evaluate({"flag": ""}) is False
def test_nested_property_access(self):
"""Test nested property access like {{item.score}}."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("{{item.score}} >= 60")
assert cond.evaluate({"item": {"score": 75}}) is True
assert cond.evaluate({"item": {"score": 50}}) is False
def test_missing_variable_returns_false(self):
"""Test that missing variables don't crash, return False."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("{{missing}} > 0")
assert cond.evaluate({}) is False
class TestDictCondition:
"""Test DictCondition class for dict-based routing conditions."""
def test_dict_condition_importable(self):
"""DictCondition should be importable."""
from praisonaiagents.conditions.evaluator import DictCondition
assert DictCondition is not None
def test_dict_condition_implements_protocol(self):
"""DictCondition should implement ConditionProtocol."""
from praisonaiagents.conditions.protocols import ConditionProtocol
from praisonaiagents.conditions.evaluator import DictCondition
cond = DictCondition({"approved": ["task_a"]}, key="decision")
assert isinstance(cond, ConditionProtocol)
def test_dict_condition_matches_key(self):
"""Test dict condition matches decision key."""
from praisonaiagents.conditions.evaluator import DictCondition
cond = DictCondition(
{"approved": ["publish"], "rejected": ["revise"]},
key="decision"
)
assert cond.evaluate({"decision": "approved"}) is True
assert cond.evaluate({"decision": "rejected"}) is True
assert cond.evaluate({"decision": "unknown"}) is False
def test_dict_condition_returns_target(self):
"""Test dict condition can return target tasks."""
from praisonaiagents.conditions.evaluator import DictCondition
cond = DictCondition(
{"approved": ["publish"], "rejected": ["revise"]},
key="decision"
)
assert cond.get_target({"decision": "approved"}) == ["publish"]
assert cond.get_target({"decision": "rejected"}) == ["revise"]
assert cond.get_target({"decision": "unknown"}) == []
class TestEvaluateConditionFunction:
"""Test the standalone evaluate_condition() function."""
def test_evaluate_condition_importable(self):
"""evaluate_condition should be importable."""
from praisonaiagents.conditions.evaluator import evaluate_condition
assert evaluate_condition is not None
def test_evaluate_condition_string(self):
"""Test evaluate_condition with string condition."""
from praisonaiagents.conditions.evaluator import evaluate_condition
result = evaluate_condition("{{score}} > 80", {"score": 90})
assert result is True
def test_evaluate_condition_with_previous_output(self):
"""Test evaluate_condition with previous_output substitution."""
from praisonaiagents.conditions.evaluator import evaluate_condition
result = evaluate_condition(
"success in {{previous_output}}",
{},
previous_output="Operation success"
)
assert result is True
class TestConditionExports:
"""Test that conditions are properly exported."""
def test_exports_from_conditions_module(self):
"""Test exports from conditions module."""
from praisonaiagents.conditions import (
ConditionProtocol,
ExpressionCondition,
DictCondition,
evaluate_condition,
)
assert ConditionProtocol is not None
assert ExpressionCondition is not None
assert DictCondition is not None
assert evaluate_condition is not None
def test_exports_from_main_package(self):
"""Test exports from main praisonaiagents package."""
from praisonaiagents import ConditionProtocol, evaluate_condition
assert ConditionProtocol is not None
assert evaluate_condition is not None
@@ -0,0 +1,131 @@
"""
Tests for memory="learn" shorthand preset.
Tests that memory="learn" enables Agent Learn functionality with a simple string parameter.
"""
import pytest
class TestMemoryLearnPreset:
"""Test that 'learn' preset exists in MEMORY_PRESETS."""
def test_learn_preset_exists(self):
"""MEMORY_PRESETS should contain 'learn' key."""
from praisonaiagents.config.presets import MEMORY_PRESETS
assert "learn" in MEMORY_PRESETS, "MEMORY_PRESETS should have 'learn' preset"
def test_learn_preset_has_learn_true(self):
"""Learn preset should have learn=True."""
from praisonaiagents.config.presets import MEMORY_PRESETS
preset = MEMORY_PRESETS.get("learn", {})
assert preset.get("learn") is True, "Learn preset should have learn=True"
def test_learn_preset_has_backend(self):
"""Learn preset should have a backend (file by default)."""
from praisonaiagents.config.presets import MEMORY_PRESETS
preset = MEMORY_PRESETS.get("learn", {})
assert "backend" in preset, "Learn preset should have backend"
assert preset["backend"] == "file", "Learn preset should use file backend by default"
class TestMemoryLearnResolution:
"""Test that memory='learn' resolves correctly."""
def test_resolve_memory_learn_string(self):
"""resolve() should convert 'learn' string to MemoryConfig with learn=True."""
from praisonaiagents.config.param_resolver import resolve, ArrayMode
from praisonaiagents.config.presets import MEMORY_PRESETS, MEMORY_URL_SCHEMES
from praisonaiagents.config.feature_configs import MemoryConfig
result = resolve(
value="learn",
param_name="memory",
config_class=MemoryConfig,
presets=MEMORY_PRESETS,
url_schemes=MEMORY_URL_SCHEMES,
array_mode=ArrayMode.SINGLE_OR_LIST,
default=None,
)
assert result is not None, "Should resolve 'learn' to a config"
assert isinstance(result, MemoryConfig), f"Should be MemoryConfig, got {type(result)}"
assert result.learn is True, "MemoryConfig should have learn=True"
def test_memory_config_from_preset_dict(self):
"""MemoryConfig should accept learn field from preset dict."""
from praisonaiagents.config.feature_configs import MemoryConfig
# Simulate what happens when preset is converted to MemoryConfig
preset_dict = {"backend": "file", "learn": True}
config = MemoryConfig(**preset_dict)
assert config.backend == "file"
assert config.learn is True
class TestAgentMemoryLearnShorthand:
"""Test Agent accepts memory='learn' parameter."""
def test_agent_accepts_memory_learn_string(self):
"""Agent should accept memory='learn' without error."""
from praisonaiagents import Agent
# Should not raise
agent = Agent(
instructions="Test agent with learn memory",
memory="learn"
)
assert agent is not None
def test_agent_memory_learn_sets_learn_config(self):
"""Agent with memory='learn' should have memory enabled."""
from praisonaiagents import Agent
agent = Agent(
instructions="Test agent",
memory="learn"
)
# Agent should have memory enabled (truthy value)
assert agent.memory, "Agent.memory should be enabled when using memory='learn'"
class TestMemoryLearnIntegration:
"""Integration tests for memory='learn' with Memory class."""
def test_memory_class_with_learn_config(self):
"""Memory class should initialize LearnManager when learn=True in config."""
from praisonaiagents.memory.memory import Memory
memory = Memory({"provider": "rag", "learn": True, "use_embedding": False})
# Access learn property (lazy-loaded)
learn_manager = memory.learn
assert learn_manager is not None, "Memory.learn should return LearnManager instance"
def test_memory_learn_captures_persona(self):
"""LearnManager should capture persona entries."""
import tempfile
import shutil
from praisonaiagents.memory.memory import Memory
temp_dir = tempfile.mkdtemp()
try:
memory = Memory({
"provider": "rag",
"learn": True,
"use_embedding": False,
"user_id": "test_user"
})
if memory.learn:
entry = memory.learn.capture_persona("User prefers concise responses")
assert entry is not None
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@@ -0,0 +1,528 @@
"""Unit tests for EvaluationLoop class and related dataclasses."""
import pytest
import time
from unittest.mock import MagicMock, patch, AsyncMock
from datetime import datetime
class TestIterationResult:
"""Tests for IterationResult dataclass."""
def test_iteration_result_creation(self):
"""Test basic IterationResult creation."""
from praisonaiagents.eval.results import IterationResult
result = IterationResult(
iteration=1,
output="Test output",
score=8.5,
reasoning="Good quality",
findings=["Finding 1", "Finding 2"],
)
assert result.iteration == 1
assert result.output == "Test output"
assert result.score == 8.5
assert result.reasoning == "Good quality"
assert result.findings == ["Finding 1", "Finding 2"]
assert result.timestamp is not None
def test_iteration_result_defaults(self):
"""Test IterationResult with default values."""
from praisonaiagents.eval.results import IterationResult
result = IterationResult(
iteration=1,
output="Test",
score=7.0,
reasoning="OK",
)
assert result.findings == []
assert result.metadata == {}
def test_iteration_result_to_dict(self):
"""Test IterationResult to_dict method."""
from praisonaiagents.eval.results import IterationResult
result = IterationResult(
iteration=1,
output="Test",
score=8.0,
reasoning="Good",
findings=["F1"],
)
d = result.to_dict()
assert d["iteration"] == 1
assert d["output"] == "Test"
assert d["score"] == 8.0
assert d["reasoning"] == "Good"
assert d["findings"] == ["F1"]
assert "timestamp" in d
def test_iteration_result_passed_property(self):
"""Test IterationResult passed property."""
from praisonaiagents.eval.results import IterationResult
# Default threshold is 7.0
result_pass = IterationResult(iteration=1, output="", score=8.0, reasoning="")
result_fail = IterationResult(iteration=1, output="", score=5.0, reasoning="")
assert result_pass.passed is True
assert result_fail.passed is False
class TestEvaluationLoopResult:
"""Tests for EvaluationLoopResult dataclass."""
def test_evaluation_loop_result_creation(self):
"""Test basic EvaluationLoopResult creation."""
from praisonaiagents.eval.results import EvaluationLoopResult, IterationResult
iterations = [
IterationResult(iteration=1, output="Out1", score=6.0, reasoning="R1"),
IterationResult(iteration=2, output="Out2", score=7.5, reasoning="R2"),
IterationResult(iteration=3, output="Out3", score=8.5, reasoning="R3"),
]
result = EvaluationLoopResult(
iterations=iterations,
success=True,
total_duration_seconds=5.5,
)
assert len(result.iterations) == 3
assert result.success is True
assert result.total_duration_seconds == 5.5
def test_evaluation_loop_result_final_score(self):
"""Test final_score property returns last iteration score."""
from praisonaiagents.eval.results import EvaluationLoopResult, IterationResult
iterations = [
IterationResult(iteration=1, output="", score=6.0, reasoning=""),
IterationResult(iteration=2, output="", score=8.5, reasoning=""),
]
result = EvaluationLoopResult(iterations=iterations, success=True)
assert result.final_score == 8.5
def test_evaluation_loop_result_score_history(self):
"""Test score_history property."""
from praisonaiagents.eval.results import EvaluationLoopResult, IterationResult
iterations = [
IterationResult(iteration=1, output="", score=5.0, reasoning=""),
IterationResult(iteration=2, output="", score=6.5, reasoning=""),
IterationResult(iteration=3, output="", score=8.0, reasoning=""),
]
result = EvaluationLoopResult(iterations=iterations, success=True)
assert result.score_history == [5.0, 6.5, 8.0]
def test_evaluation_loop_result_findings(self):
"""Test accumulated_findings property."""
from praisonaiagents.eval.results import EvaluationLoopResult, IterationResult
iterations = [
IterationResult(iteration=1, output="", score=5.0, reasoning="", findings=["F1", "F2"]),
IterationResult(iteration=2, output="", score=7.0, reasoning="", findings=["F3"]),
]
result = EvaluationLoopResult(iterations=iterations, success=True)
assert result.accumulated_findings == ["F1", "F2", "F3"]
def test_evaluation_loop_result_final_output(self):
"""Test final_output property."""
from praisonaiagents.eval.results import EvaluationLoopResult, IterationResult
iterations = [
IterationResult(iteration=1, output="First", score=5.0, reasoning=""),
IterationResult(iteration=2, output="Final", score=8.0, reasoning=""),
]
result = EvaluationLoopResult(iterations=iterations, success=True)
assert result.final_output == "Final"
def test_evaluation_loop_result_to_dict(self):
"""Test to_dict method."""
from praisonaiagents.eval.results import EvaluationLoopResult, IterationResult
iterations = [
IterationResult(iteration=1, output="Out", score=8.0, reasoning="Good"),
]
result = EvaluationLoopResult(
iterations=iterations,
success=True,
total_duration_seconds=2.5,
)
d = result.to_dict()
assert d["success"] is True
assert d["final_score"] == 8.0
assert d["score_history"] == [8.0]
assert d["total_duration_seconds"] == 2.5
assert len(d["iterations"]) == 1
def test_evaluation_loop_result_to_json(self):
"""Test to_json method."""
from praisonaiagents.eval.results import EvaluationLoopResult, IterationResult
import json
iterations = [
IterationResult(iteration=1, output="Out", score=8.0, reasoning="Good"),
]
result = EvaluationLoopResult(iterations=iterations, success=True)
json_str = result.to_json()
parsed = json.loads(json_str)
assert parsed["success"] is True
assert parsed["final_score"] == 8.0
def test_evaluation_loop_result_final_report(self):
"""Test final_report property generates markdown."""
from praisonaiagents.eval.results import EvaluationLoopResult, IterationResult
iterations = [
IterationResult(iteration=1, output="Out1", score=6.0, reasoning="Needs work", findings=["F1"]),
IterationResult(iteration=2, output="Out2", score=8.5, reasoning="Good", findings=["F2"]),
]
result = EvaluationLoopResult(
iterations=iterations,
success=True,
total_duration_seconds=3.0,
)
report = result.final_report
assert "# Evaluation Loop Report" in report
assert "8.5" in report # Final score
assert "Success" in report or "✅" in report
assert "Iteration 1" in report
assert "Iteration 2" in report
def test_evaluation_loop_result_empty_iterations(self):
"""Test with empty iterations list."""
from praisonaiagents.eval.results import EvaluationLoopResult
result = EvaluationLoopResult(iterations=[], success=False)
assert result.final_score == 0.0
assert result.score_history == []
assert result.final_output == ""
assert result.accumulated_findings == []
class TestEvaluationLoop:
"""Tests for EvaluationLoop class."""
def test_evaluation_loop_init_defaults(self):
"""Test EvaluationLoop initialization with defaults."""
from praisonaiagents.eval.loop import EvaluationLoop
agent = MagicMock()
loop = EvaluationLoop(
agent=agent,
criteria="Be helpful",
)
assert loop.agent == agent
assert loop.criteria == "Be helpful"
assert loop.threshold == 8.0
assert loop.max_iterations == 5
assert loop.mode == "optimize"
def test_evaluation_loop_init_custom(self):
"""Test EvaluationLoop initialization with custom values."""
from praisonaiagents.eval.loop import EvaluationLoop
agent = MagicMock()
judge = MagicMock()
callback = MagicMock()
loop = EvaluationLoop(
agent=agent,
criteria="Custom criteria",
threshold=9.0,
max_iterations=10,
mode="review",
judge=judge,
on_iteration=callback,
verbose=True,
)
assert loop.threshold == 9.0
assert loop.max_iterations == 10
assert loop.mode == "review"
assert loop.judge == judge
assert loop.on_iteration == callback
assert loop.verbose is True
def test_evaluation_loop_run_single_iteration_passes(self):
"""Test run() when first iteration passes threshold."""
from praisonaiagents.eval.loop import EvaluationLoop
from praisonaiagents.eval.results import EvaluationLoopResult
agent = MagicMock()
agent.chat.return_value = "Good output"
judge = MagicMock()
judge_result = MagicMock()
judge_result.score = 9.0
judge_result.reasoning = "Excellent"
judge_result.suggestions = []
judge.run.return_value = judge_result
loop = EvaluationLoop(
agent=agent,
criteria="Be helpful",
threshold=8.0,
judge=judge,
)
result = loop.run("Test prompt")
assert isinstance(result, EvaluationLoopResult)
assert result.success is True
assert result.final_score == 9.0
assert len(result.iterations) == 1
def test_evaluation_loop_run_multiple_iterations(self):
"""Test run() with multiple iterations before passing."""
from praisonaiagents.eval.loop import EvaluationLoop
agent = MagicMock()
agent.chat.side_effect = ["Output 1", "Output 2", "Output 3"]
judge = MagicMock()
judge_results = [
MagicMock(score=5.0, reasoning="Poor", suggestions=["Improve"]),
MagicMock(score=7.0, reasoning="Better", suggestions=["More"]),
MagicMock(score=8.5, reasoning="Good", suggestions=[]),
]
judge.run.side_effect = judge_results
loop = EvaluationLoop(
agent=agent,
criteria="Be helpful",
threshold=8.0,
max_iterations=5,
judge=judge,
)
result = loop.run("Test prompt")
assert result.success is True
assert result.final_score == 8.5
assert len(result.iterations) == 3
assert result.score_history == [5.0, 7.0, 8.5]
def test_evaluation_loop_run_max_iterations_reached(self):
"""Test run() when max iterations reached without passing."""
from praisonaiagents.eval.loop import EvaluationLoop
agent = MagicMock()
agent.chat.return_value = "Output"
judge = MagicMock()
judge_result = MagicMock(score=5.0, reasoning="Poor", suggestions=["Improve"])
judge.run.return_value = judge_result
loop = EvaluationLoop(
agent=agent,
criteria="Be helpful",
threshold=8.0,
max_iterations=3,
judge=judge,
)
result = loop.run("Test prompt")
assert result.success is False
assert len(result.iterations) == 3
assert result.final_score == 5.0
def test_evaluation_loop_run_review_mode(self):
"""Test run() in review mode (runs all iterations)."""
from praisonaiagents.eval.loop import EvaluationLoop
agent = MagicMock()
agent.chat.return_value = "Output"
judge = MagicMock()
# Even high score shouldn't stop in review mode
judge_result = MagicMock(score=9.0, reasoning="Great", suggestions=[])
judge.run.return_value = judge_result
loop = EvaluationLoop(
agent=agent,
criteria="Be helpful",
threshold=8.0,
max_iterations=3,
mode="review",
judge=judge,
)
result = loop.run("Test prompt")
# Review mode runs all iterations
assert len(result.iterations) == 3
assert result.success is True
def test_evaluation_loop_on_iteration_callback(self):
"""Test on_iteration callback is called."""
from praisonaiagents.eval.loop import EvaluationLoop
agent = MagicMock()
agent.chat.return_value = "Output"
judge = MagicMock()
judge_result = MagicMock(score=9.0, reasoning="Good", suggestions=[])
judge.run.return_value = judge_result
callback_calls = []
def on_iteration(iteration_result):
callback_calls.append(iteration_result)
loop = EvaluationLoop(
agent=agent,
criteria="Be helpful",
threshold=8.0,
judge=judge,
on_iteration=on_iteration,
)
loop.run("Test prompt")
assert len(callback_calls) == 1
assert callback_calls[0].score == 9.0
def test_evaluation_loop_extracts_findings(self):
"""Test that findings are extracted from suggestions."""
from praisonaiagents.eval.loop import EvaluationLoop
agent = MagicMock()
agent.chat.return_value = "Output"
judge = MagicMock()
judge_result = MagicMock(
score=9.0,
reasoning="Good",
suggestions=["Finding 1", "Finding 2"]
)
judge.run.return_value = judge_result
loop = EvaluationLoop(
agent=agent,
criteria="Be helpful",
threshold=8.0,
judge=judge,
)
result = loop.run("Test prompt")
assert result.iterations[0].findings == ["Finding 1", "Finding 2"]
class TestEvaluationLoopAsync:
"""Tests for EvaluationLoop async methods."""
@pytest.mark.asyncio
async def test_evaluation_loop_run_async(self):
"""Test run_async() method."""
from praisonaiagents.eval.loop import EvaluationLoop
from praisonaiagents.eval.results import EvaluationLoopResult
agent = MagicMock()
agent.chat_async = AsyncMock(return_value="Output")
judge = MagicMock()
judge_result = MagicMock(score=9.0, reasoning="Good", suggestions=[])
judge.run_async = AsyncMock(return_value=judge_result)
loop = EvaluationLoop(
agent=agent,
criteria="Be helpful",
threshold=8.0,
judge=judge,
)
result = await loop.run_async("Test prompt")
assert isinstance(result, EvaluationLoopResult)
assert result.success is True
assert result.final_score == 9.0
class TestAgentRunUntil:
"""Tests for Agent.run_until() method."""
def test_agent_run_until_exists(self):
"""Test that Agent has run_until method."""
from praisonaiagents import Agent
assert hasattr(Agent, 'run_until')
def test_agent_run_until_basic(self):
"""Test Agent.run_until() basic usage."""
from praisonaiagents import Agent
from praisonaiagents.eval.results import EvaluationLoopResult
with patch('praisonaiagents.eval.loop.EvaluationLoop') as MockLoop:
mock_result = MagicMock(spec=EvaluationLoopResult)
mock_result.success = True
mock_result.final_score = 8.5
mock_loop_instance = MagicMock()
mock_loop_instance.run.return_value = mock_result
MockLoop.return_value = mock_loop_instance
agent = Agent(name="test", instructions="Be helpful")
result = agent.run_until(
"Test prompt",
criteria="Be helpful",
threshold=8.0,
)
assert result.success is True
MockLoop.assert_called_once()
class TestEvaluationLoopProtocol:
"""Tests for EvaluationLoopProtocol."""
def test_protocol_exists(self):
"""Test that EvaluationLoopProtocol exists."""
from praisonaiagents.eval.protocols import EvaluationLoopProtocol
assert EvaluationLoopProtocol is not None
def test_evaluation_loop_implements_protocol(self):
"""Test that EvaluationLoop implements the protocol."""
from praisonaiagents.eval.loop import EvaluationLoop
from praisonaiagents.eval.protocols import EvaluationLoopProtocol
# Check that EvaluationLoop has required methods
assert hasattr(EvaluationLoop, 'run')
assert hasattr(EvaluationLoop, 'run_async')
class TestLazyLoading:
"""Tests for lazy loading of EvaluationLoop."""
def test_lazy_import_from_eval(self):
"""Test that EvaluationLoop can be imported from eval module."""
from praisonaiagents.eval import EvaluationLoop
assert EvaluationLoop is not None
def test_lazy_import_results(self):
"""Test that result classes can be imported from eval module."""
from praisonaiagents.eval import IterationResult, EvaluationLoopResult
assert IterationResult is not None
assert EvaluationLoopResult is not None
@@ -0,0 +1,156 @@
"""
TDD Tests for verbose= to output= migration in Process class.
These tests verify:
1. Process class accepts output= parameter
2. Process class still accepts verbose= for backward compat (with deprecation)
3. output= takes precedence over verbose=
"""
import pytest
from unittest.mock import MagicMock
class TestProcessOutputMigration:
"""Test Process class migration from verbose= to output=."""
def test_process_accepts_output_parameter(self):
"""Process should accept output= parameter."""
from praisonaiagents.process.process import Process
from praisonaiagents.agent.agent import Agent
from praisonaiagents.task.task import Task
# Create minimal mocks
agent = MagicMock(spec=Agent)
agent.name = "test_agent"
task = MagicMock(spec=Task)
task.id = "task1"
task.name = "test_task"
# This should work with output= parameter
process = Process(
tasks={"task1": task},
agents=[agent],
output="verbose"
)
# Verify verbose was set from output preset
assert process._verbose
def test_process_output_silent_sets_verbose_false(self):
"""output='silent' should set verbose=False."""
from praisonaiagents.process.process import Process
from praisonaiagents.agent.agent import Agent
from praisonaiagents.task.task import Task
agent = MagicMock(spec=Agent)
agent.name = "test_agent"
task = MagicMock(spec=Task)
task.id = "task1"
task.name = "test_task"
process = Process(
tasks={"task1": task},
agents=[agent],
output="silent"
)
assert not process._verbose
def test_process_backward_compat_verbose_true(self):
"""Process should still accept verbose=True for backward compat."""
from praisonaiagents.process.process import Process
from praisonaiagents.agent.agent import Agent
from praisonaiagents.task.task import Task
agent = MagicMock(spec=Agent)
agent.name = "test_agent"
task = MagicMock(spec=Task)
task.id = "task1"
task.name = "test_task"
# Old API should still work
process = Process(
tasks={"task1": task},
agents=[agent],
verbose=True
)
assert process._verbose
def test_process_output_takes_precedence_over_verbose(self):
"""output= should take precedence over verbose= if both provided."""
from praisonaiagents.process.process import Process
from praisonaiagents.agent.agent import Agent
from praisonaiagents.task.task import Task
agent = MagicMock(spec=Agent)
agent.name = "test_agent"
task = MagicMock(spec=Task)
task.id = "task1"
task.name = "test_task"
# output= should win
process = Process(
tasks={"task1": task},
agents=[agent],
output="silent",
verbose=True # This should be ignored
)
assert not process._verbose
class TestAgentVerboseRejection:
"""Test that Agent class rejects verbose= parameter."""
def test_agent_rejects_verbose_parameter(self):
"""Agent(verbose=True) should raise TypeError."""
from praisonaiagents import Agent
with pytest.raises(TypeError) as exc_info:
Agent(instructions="Test", verbose=True)
assert "verbose" in str(exc_info.value).lower()
def test_agent_accepts_output_verbose(self):
"""Agent(output='verbose') should work."""
from praisonaiagents import Agent
agent = Agent(instructions="Test", output="verbose")
assert agent.verbose
assert agent.markdown
class TestAgentTeamOutputParameter:
"""Test that AgentTeam uses output= not verbose=."""
def test_agent_team_accepts_output_parameter(self):
"""AgentTeam should accept output= parameter."""
from praisonaiagents import Agent, AgentTeam, Task
agent = Agent(instructions="Test agent")
task = Task(description="Test task", agent=agent)
team = AgentTeam(
agents=[agent],
tasks=[task],
output="verbose"
)
assert team.verbose
def test_agent_team_output_silent(self):
"""AgentTeam with output='silent' should have verbose=0 or False."""
from praisonaiagents import Agent, AgentTeam, Task
agent = Agent(instructions="Test agent")
task = Task(description="Test task", agent=agent)
team = AgentTeam(
agents=[agent],
tasks=[task],
output="silent"
)
assert not team.verbose
@@ -0,0 +1,241 @@
"""
TDD Tests for Task.when parameter - Unified Condition Syntax.
These tests define the expected behavior for the new unified condition syntax.
Tests should FAIL initially, then PASS after implementation.
The goal is to unify condition syntax between AgentFlow and Task:
- AgentFlow: when(condition="{{score}} > 80", then_steps=[...], else_steps=[...])
- Task: when="{{score}} > 80", then_task="approve", else_task="reject"
"""
class TestTaskWhenParameter:
"""Test Task.when parameter for string-based conditions."""
def test_task_accepts_when_parameter(self):
"""Task should accept a 'when' parameter as a string expression."""
from praisonaiagents import Task
task = Task(
name="conditional_task",
description="A task with a condition",
when="{{score}} > 80"
)
assert hasattr(task, 'when')
assert task.when == "{{score}} > 80"
def test_task_when_with_then_task(self):
"""Task should accept 'then_task' for routing when condition is True."""
from praisonaiagents import Task
task = Task(
name="review",
description="Review content",
when="{{score}} > 80",
then_task="approve"
)
assert hasattr(task, 'then_task')
assert task.then_task == "approve"
def test_task_when_with_else_task(self):
"""Task should accept 'else_task' for routing when condition is False."""
from praisonaiagents import Task
task = Task(
name="review",
description="Review content",
when="{{score}} > 80",
then_task="approve",
else_task="reject"
)
assert hasattr(task, 'else_task')
assert task.else_task == "reject"
def test_task_when_defaults_to_none(self):
"""Task.when should default to None when not provided."""
from praisonaiagents import Task
task = Task(
name="simple_task",
description="A simple task"
)
assert hasattr(task, 'when')
assert task.when is None
def test_task_then_task_defaults_to_none(self):
"""Task.then_task should default to None when not provided."""
from praisonaiagents import Task
task = Task(
name="simple_task",
description="A simple task"
)
assert hasattr(task, 'then_task')
assert task.then_task is None
def test_task_else_task_defaults_to_none(self):
"""Task.else_task should default to None when not provided."""
from praisonaiagents import Task
task = Task(
name="simple_task",
description="A simple task"
)
assert hasattr(task, 'else_task')
assert task.else_task is None
class TestTaskConditionRenameToRouting:
"""Test that 'condition' is renamed to 'routing' with backward compatibility."""
def test_task_accepts_routing_parameter(self):
"""Task should accept 'routing' parameter (new name for condition)."""
from praisonaiagents import Task
task = Task(
name="decision_task",
description="Make a decision",
task_type="decision",
routing={"approved": ["publish"], "rejected": ["revise"]}
)
# routing should be stored
assert hasattr(task, 'routing')
# For backward compat, condition should also work
assert task.condition == {"approved": ["publish"], "rejected": ["revise"]}
def test_task_condition_still_works_backward_compat(self):
"""Task should still accept 'condition' parameter for backward compatibility."""
from praisonaiagents import Task
task = Task(
name="decision_task",
description="Make a decision",
task_type="decision",
condition={"approved": ["publish"], "rejected": ["revise"]}
)
# condition should still work
assert task.condition == {"approved": ["publish"], "rejected": ["revise"]}
class TestTaskWhenEvaluation:
"""Test that Task.when conditions can be evaluated."""
def test_task_has_evaluate_when_method(self):
"""Task should have a method to evaluate the 'when' condition."""
from praisonaiagents import Task
task = Task(
name="conditional_task",
description="A task with a condition",
when="{{score}} > 80"
)
assert hasattr(task, 'evaluate_when')
assert callable(task.evaluate_when)
def test_evaluate_when_returns_true(self):
"""evaluate_when should return True when condition is met."""
from praisonaiagents import Task
task = Task(
name="conditional_task",
description="A task with a condition",
when="{{score}} > 80"
)
result = task.evaluate_when({"score": 90})
assert result is True
def test_evaluate_when_returns_false(self):
"""evaluate_when should return False when condition is not met."""
from praisonaiagents import Task
task = Task(
name="conditional_task",
description="A task with a condition",
when="{{score}} > 80"
)
result = task.evaluate_when({"score": 70})
assert result is False
def test_evaluate_when_returns_true_when_no_condition(self):
"""evaluate_when should return True when no 'when' condition is set."""
from praisonaiagents import Task
task = Task(
name="simple_task",
description="A simple task"
)
result = task.evaluate_when({})
assert result is True
def test_evaluate_when_with_string_comparison(self):
"""evaluate_when should handle string comparisons."""
from praisonaiagents import Task
task = Task(
name="conditional_task",
description="A task with a condition",
when="{{status}} == approved"
)
assert task.evaluate_when({"status": "approved"}) is True
assert task.evaluate_when({"status": "rejected"}) is False
class TestTaskGetNextTask:
"""Test Task routing based on when condition."""
def test_get_next_task_returns_then_task_when_true(self):
"""get_next_task should return then_task when condition is True."""
from praisonaiagents import Task
task = Task(
name="review",
description="Review content",
when="{{score}} > 80",
then_task="approve",
else_task="reject"
)
assert hasattr(task, 'get_next_task')
next_task = task.get_next_task({"score": 90})
assert next_task == "approve"
def test_get_next_task_returns_else_task_when_false(self):
"""get_next_task should return else_task when condition is False."""
from praisonaiagents import Task
task = Task(
name="review",
description="Review content",
when="{{score}} > 80",
then_task="approve",
else_task="reject"
)
next_task = task.get_next_task({"score": 70})
assert next_task == "reject"
def test_get_next_task_returns_none_when_no_routing(self):
"""get_next_task should return None when no routing is configured."""
from praisonaiagents import Task
task = Task(
name="simple_task",
description="A simple task"
)
next_task = task.get_next_task({})
assert next_task is None
@@ -2,18 +2,19 @@
Tests for Gap Closure - LLM Consolidation + Parity
TDD: These tests define the expected behavior for:
1. GAP A: No deprecation warnings for model/llm alias
1. GAP A: model= is preferred, llm= shows deprecation warning
2. GAP B: YAML parser supports consolidated params
3. GAP C: Wrapper agents use output= (not verbose/markdown)
4. GAP D: AutoAgents uses consolidated llm= (not function_calling_llm)
5. GAP E: model= and llm= are aliases with NO warnings
5. GAP E: model= and llm= both work (backward compatible)
"""
import os
import pytest
import warnings
class TestNoDeprecationWarnings:
"""GAP A + E: No deprecation warnings for model/llm or legacy params."""
"""GAP A + E: model= preferred (no warning), llm= deprecated (shows warning)."""
def test_no_warning_for_model_alias(self):
"""model= should work without any deprecation warning."""
@@ -27,16 +28,18 @@ class TestNoDeprecationWarnings:
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
assert len(deprecation_warnings) == 0, f"Unexpected warnings: {[str(x.message) for x in deprecation_warnings]}"
def test_no_warning_for_llm_param(self):
"""llm= should work without any deprecation warning."""
def test_llm_param_shows_deprecation_warning(self):
"""llm= should work but show deprecation warning (model= is preferred)."""
from praisonaiagents import Agent
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
agent = Agent(instructions="Test", llm="gpt-4o-mini")
# llm= is deprecated in favor of model=
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
assert len(deprecation_warnings) == 0, f"Unexpected warnings: {[str(x.message) for x in deprecation_warnings]}"
assert len(deprecation_warnings) >= 1, "llm= should produce deprecation warning"
assert 'model' in str(deprecation_warnings[0].message).lower(), "Warning should suggest using 'model'"
def test_llm_config_removed(self):
"""llm_config= is removed in v4 - should raise TypeError."""
@@ -82,6 +85,10 @@ class TestAutoAgentsConsolidation:
assert "function_calling_llm=self.function_calling_llm" not in source, \
"AutoAgents._create_agents_and_tasks still passes function_calling_llm= to Agent()"
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set"
)
def test_autoagents_accepts_llm_param(self):
"""AutoAgents should accept llm= parameter."""
from praisonaiagents.agents.autoagents import AutoAgents
@@ -0,0 +1,205 @@
"""
Unit tests for history injection via memory presets.
History is enabled via:
- memory="history" preset (10 messages)
- memory="chat" preset (20 messages)
- memory=MemoryConfig(history=True, history_limit=N)
The history= parameter and HistoryConfig class have been removed
to keep the API simple and consistent with other memory presets.
"""
import pytest
from unittest.mock import Mock
class TestDefaultHistoryDisabled:
"""Test that history is disabled by default."""
def test_default_history_disabled(self):
"""Agent() without memory should have history disabled."""
from praisonaiagents import Agent
agent = Agent(
name="test",
instructions="Test agent"
)
assert agent._history_enabled is False
def test_memory_true_does_not_enable_history(self):
"""Agent(memory=True) should NOT enable history by default."""
from praisonaiagents import Agent
agent = Agent(
name="test",
instructions="Test agent",
memory=True
)
# memory=True enables memory backend, not history injection
assert agent._history_enabled is False
class TestMemoryHistoryPreset:
"""Test memory='history' preset."""
def test_memory_history_preset_exists(self):
"""memory='history' should be a valid preset."""
from praisonaiagents.config.presets import MEMORY_PRESETS
assert "history" in MEMORY_PRESETS
def test_memory_history_enables_history(self):
"""Agent(memory='history') should enable history injection."""
from praisonaiagents import Agent
agent = Agent(
name="test",
instructions="Test agent",
memory="history"
)
# Memory should be enabled
assert agent.memory is not None or agent._memory_instance is not None
# History should be enabled
assert agent._history_enabled is True
def test_memory_session_preset_exists(self):
"""memory='session' should be a valid preset (alias for history)."""
from praisonaiagents.config.presets import MEMORY_PRESETS
assert "session" in MEMORY_PRESETS
def test_memory_chat_preset_exists(self):
"""memory='chat' should be a valid preset for conversational use."""
from praisonaiagents.config.presets import MEMORY_PRESETS
assert "chat" in MEMORY_PRESETS
class TestMemoryConfigHistoryFields:
"""Test new history fields in MemoryConfig."""
def test_memory_config_has_history_field(self):
"""MemoryConfig should have a history field."""
from praisonaiagents.config import MemoryConfig
config = MemoryConfig(history=True)
assert config.history is True
def test_memory_config_has_history_limit_field(self):
"""MemoryConfig should have a history_limit field."""
from praisonaiagents.config import MemoryConfig
config = MemoryConfig(history=True, history_limit=20)
assert config.history_limit == 20
def test_memory_config_history_defaults(self):
"""MemoryConfig history fields should have sensible defaults."""
from praisonaiagents.config import MemoryConfig
config = MemoryConfig()
assert config.history is False # Disabled by default
assert config.history_limit == 10 # Default limit
def test_agent_memory_config_with_history(self):
"""Agent with MemoryConfig(history=True) should enable history."""
from praisonaiagents import Agent
from praisonaiagents.config import MemoryConfig
agent = Agent(
name="test",
instructions="Test agent",
memory=MemoryConfig(history=True, history_limit=15)
)
assert agent._history_enabled is True
assert agent._history_limit == 15
class TestHistoryInjection:
"""Test that history is actually injected into messages."""
def test_history_injected_into_messages(self):
"""When memory='history', session history should be in messages."""
from praisonaiagents import Agent
# Create agent with history enabled via preset
agent = Agent(
name="test",
instructions="Test agent",
memory="history"
)
# Set _using_custom_llm to bypass OpenAI client initialization
agent._using_custom_llm = True
# Mock session store with some history
mock_store = Mock()
mock_store.get_chat_history.return_value = [
{"role": "user", "content": "Previous question"},
{"role": "assistant", "content": "Previous answer"}
]
agent._session_store = mock_store
agent._history_session_id = "test-session"
# Build messages
messages, _ = agent._build_messages("New question")
# Should contain the injected history
message_contents = [m.get("content", "") for m in messages]
assert "Previous question" in str(message_contents)
assert "Previous answer" in str(message_contents)
def test_history_respects_limit(self):
"""History injection should respect the limit from MemoryConfig."""
from praisonaiagents import Agent
from praisonaiagents.config import MemoryConfig
agent = Agent(
name="test",
instructions="Test agent",
memory=MemoryConfig(history=True, history_limit=2)
)
# Set _using_custom_llm to bypass OpenAI client initialization
agent._using_custom_llm = True
# Mock session store
mock_store = Mock()
mock_store.get_chat_history.return_value = [
{"role": "user", "content": "Msg 1"},
{"role": "assistant", "content": "Msg 2"}
]
agent._session_store = mock_store
agent._history_session_id = "test-session"
# Build messages
agent._build_messages("New question")
# Should have called with limit=2
mock_store.get_chat_history.assert_called_with("test-session", max_messages=2)
class TestHistoryWithSession:
"""Test history integration with session management."""
def test_history_uses_auto_save_session(self):
"""If auto_save is set, history should use that session."""
from praisonaiagents import Agent
agent = Agent(
name="test",
instructions="Test agent",
auto_save="my-auto-session",
memory="history"
)
# Should use auto_save session for history
assert agent._history_session_id == "my-auto-session"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -45,16 +45,17 @@ class TestLLMConsolidation:
assert agent1.llm == agent2.llm
def test_llm_takes_precedence_over_model(self):
"""If both llm= and model= are provided, llm= takes precedence."""
def test_model_takes_precedence_over_llm(self):
"""If both llm= and model= are provided, model= takes precedence (new canonical name)."""
from praisonaiagents import Agent
# model= is the new canonical name, so it takes precedence over llm=
agent = Agent(
instructions="Test",
llm="gpt-4o",
model="gpt-3.5-turbo" # Should be ignored
model="gpt-3.5-turbo" # model= takes precedence (new canonical name)
)
assert agent.llm == "gpt-4o"
assert agent.llm == "gpt-3.5-turbo"
def test_llm_config_removed_in_v4(self):
"""llm_config= is removed in v4 - should raise TypeError."""
@@ -0,0 +1,294 @@
"""
TDD Tests for Parameter Naming Standardization.
Tests the following changes:
1. llm= shows deprecation warning, model= is preferred
2. Task depends_on= is alias for context=
3. Task guardrails= is canonical (guardrail= deprecated)
4. YAML parser normalizes depends_on
These tests are written FIRST (TDD) - they should FAIL initially.
"""
import warnings
class TestAgentLLMDeprecation:
"""Test that llm= parameter shows deprecation warning."""
def test_model_param_no_warning(self):
"""Using model= should NOT produce a deprecation warning."""
from praisonaiagents import Agent
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
_agent = Agent( # noqa: F841
name="test",
instructions="Test agent",
model="gpt-4o-mini"
)
# Filter for DeprecationWarning about 'llm'
llm_warnings = [x for x in w if 'llm' in str(x.message).lower()]
assert len(llm_warnings) == 0, "model= should not produce deprecation warning"
def test_llm_param_shows_deprecation_warning(self):
"""Using llm= should produce a deprecation warning."""
from praisonaiagents import Agent
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
_agent = Agent( # noqa: F841
name="test",
instructions="Test agent",
llm="gpt-4o-mini"
)
# Filter for DeprecationWarning about 'llm'
llm_warnings = [x for x in w if 'llm' in str(x.message).lower() and issubclass(x.category, DeprecationWarning)]
assert len(llm_warnings) >= 1, "llm= should produce deprecation warning"
assert 'model' in str(llm_warnings[0].message).lower(), "Warning should suggest using 'model' instead"
def test_llm_and_model_both_work(self):
"""Both llm= and model= should work (backward compatibility)."""
from praisonaiagents import Agent
with warnings.catch_warnings():
warnings.simplefilter("ignore") # Ignore warnings for this test
agent1 = Agent(name="test1", instructions="Test", llm="gpt-4o-mini")
agent2 = Agent(name="test2", instructions="Test", model="gpt-4o-mini")
# Both should have the same model configured
assert agent1.llm == agent2.llm
class TestTaskDependsOnAlias:
"""Test that depends_on= is an alias for context= in Task."""
def test_depends_on_alias_works(self):
"""depends_on= should work as alias for context=."""
from praisonaiagents import Task
task1 = Task(description="First task")
task2 = Task(
description="Second task",
depends_on=[task1]
)
assert task2.context == [task1], "depends_on should set context"
def test_context_still_works(self):
"""context= should still work (backward compatibility)."""
from praisonaiagents import Task
task1 = Task(description="First task")
task2 = Task(
description="Second task",
context=[task1]
)
assert task2.context == [task1]
def test_depends_on_and_context_merge(self):
"""If both provided, depends_on takes precedence."""
from praisonaiagents import Task
task1 = Task(description="First task")
task2 = Task(description="Second task")
task3 = Task(
description="Third task",
context=[task1],
depends_on=[task2]
)
# depends_on should take precedence
assert task3.context == [task2], "depends_on should take precedence over context"
def test_depends_on_attribute_accessible(self):
"""depends_on should be accessible as an attribute."""
from praisonaiagents import Task
task1 = Task(description="First task")
task2 = Task(
description="Second task",
depends_on=[task1]
)
# Should be accessible via both names
assert task2.depends_on == [task1]
assert task2.context == [task1]
class TestTaskGuardrailsStandardization:
"""Test that guardrails= is the canonical name."""
def test_guardrails_plural_works(self):
"""guardrails= (plural) should work."""
from praisonaiagents import Task
def my_guardrail(output):
return (True, output)
task = Task(
description="Test task",
guardrails=my_guardrail
)
assert task.guardrail == my_guardrail
def test_guardrail_singular_shows_deprecation(self):
"""guardrail= (singular) should show deprecation warning."""
from praisonaiagents import Task
def my_guardrail(output):
return (True, output)
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
_task = Task( # noqa: F841
description="Test task",
guardrail=my_guardrail
)
# Filter for DeprecationWarning about 'guardrail'
gr_warnings = [x for x in w if 'guardrail' in str(x.message).lower() and issubclass(x.category, DeprecationWarning)]
assert len(gr_warnings) >= 1, "guardrail= (singular) should produce deprecation warning"
def test_guardrails_takes_precedence(self):
"""If both provided, guardrails= takes precedence."""
from praisonaiagents import Task
def guardrail1(output):
return (True, "guardrail1")
def guardrail2(output):
return (True, "guardrail2")
with warnings.catch_warnings():
warnings.simplefilter("ignore")
task = Task(
description="Test task",
guardrail=guardrail1,
guardrails=guardrail2
)
assert task.guardrail == guardrail2, "guardrails= should take precedence"
class TestYAMLParserNormalization:
"""Test YAML parser normalizes parameter names."""
def test_yaml_depends_on_normalized(self):
"""YAML with depends_on should work - parser should accept the field."""
from praisonaiagents.workflows.yaml_parser import YAMLWorkflowParser
yaml_content = """
name: Test Workflow
agents:
researcher:
name: Researcher
instructions: Research topics
steps:
- name: step1
agent: researcher
action: "Do research"
- name: step2
agent: researcher
action: "Summarize"
depends_on: [step1]
"""
parser = YAMLWorkflowParser()
# Should not raise an error - depends_on should be accepted
workflow = parser.parse_string(yaml_content)
# The workflow should have 2 steps
assert len(workflow.steps) == 2
# Workflow parsed successfully with depends_on field
assert workflow.name == "Test Workflow"
def test_yaml_context_still_works(self):
"""YAML with context should still work."""
from praisonaiagents.workflows.yaml_parser import YAMLWorkflowParser
yaml_content = """
name: Test Workflow
agents:
researcher:
name: Researcher
instructions: Research topics
steps:
- name: step1
agent: researcher
action: "Do research"
- name: step2
agent: researcher
action: "Summarize"
context: [step1]
"""
parser = YAMLWorkflowParser()
workflow = parser.parse_string(yaml_content)
assert len(workflow.steps) == 2
class TestBackwardCompatibility:
"""Ensure all changes are backward compatible."""
def test_agent_creation_unchanged(self):
"""Basic agent creation should work unchanged."""
from praisonaiagents import Agent
with warnings.catch_warnings():
warnings.simplefilter("ignore")
agent = Agent(
name="test",
role="Tester",
goal="Test things",
backstory="A test agent"
)
assert agent.name == "test"
assert agent.role == "Tester"
def test_task_creation_unchanged(self):
"""Basic task creation should work unchanged."""
from praisonaiagents import Task
task = Task(
description="Test task",
expected_output="Test output"
)
assert task.description == "Test task"
def test_action_alias_still_works(self):
"""action= should still work as alias for description=."""
from praisonaiagents import Task
task = Task(action="Do something")
assert task.description == "Do something"
assert task.action == "Do something"
class TestPerformance:
"""Ensure no performance regression."""
def test_import_time_acceptable(self):
"""Import time should be under 200ms."""
import subprocess
import time
# Run import in subprocess to get clean timing
start = time.time()
subprocess.run(
['python3', '-c', 'import praisonaiagents'],
capture_output=True,
timeout=10
)
elapsed = time.time() - start
# Allow some overhead for subprocess startup
# The actual import should be much faster
assert elapsed < 5.0, f"Import took {elapsed:.2f}s, expected < 5s (including subprocess overhead)"
@@ -0,0 +1,324 @@
"""
Unit tests for praisonaiagents.paths module.
Tests centralized path utilities for PraisonAI data storage.
TDD: These tests define the expected behavior of the paths module.
"""
import os
import warnings
from pathlib import Path
from unittest.mock import patch
class TestGetDataDir:
"""Tests for get_data_dir() function."""
def test_default_returns_praisonai_in_home(self):
"""Default data dir should be ~/.praisonai/"""
from praisonaiagents.paths import get_data_dir
result = get_data_dir()
expected = Path.home() / ".praisonai"
assert result == expected
def test_env_var_override(self):
"""PRAISONAI_HOME env var should override default."""
from praisonaiagents.paths import get_data_dir
with patch.dict(os.environ, {"PRAISONAI_HOME": "/custom/path"}):
result = get_data_dir()
assert result == Path("/custom/path")
def test_env_var_expands_tilde(self):
"""PRAISONAI_HOME should expand ~ to home directory."""
from praisonaiagents.paths import get_data_dir
with patch.dict(os.environ, {"PRAISONAI_HOME": "~/custom/praison"}):
result = get_data_dir()
assert result == Path.home() / "custom" / "praison"
def test_legacy_fallback_with_warning(self, tmp_path):
"""Should fall back to ~/.praison/ if it exists and ~/.praisonai/ doesn't."""
from praisonaiagents.paths import get_data_dir, _clear_cache
# Clear cache before test
_clear_cache()
# Create mock home with only legacy dir
mock_home = tmp_path / "home"
legacy_dir = mock_home / ".praison"
legacy_dir.mkdir(parents=True)
with patch("praisonaiagents.paths.Path.home", return_value=mock_home):
with patch.dict(os.environ, {}, clear=True):
# Remove PRAISONAI_HOME if set
os.environ.pop("PRAISONAI_HOME", None)
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = get_data_dir()
# Should return legacy path
assert result == legacy_dir
# Should emit deprecation warning
assert len(w) == 1
assert issubclass(w[0].category, DeprecationWarning)
assert ".praison" in str(w[0].message)
# Clear cache after test
_clear_cache()
class TestGetSessionsDir:
"""Tests for get_sessions_dir() function."""
def test_returns_sessions_subdir(self):
"""Should return sessions subdirectory of data dir."""
from praisonaiagents.paths import get_sessions_dir
result = get_sessions_dir()
assert result == Path.home() / ".praisonai" / "sessions"
def test_respects_env_override(self):
"""Should respect PRAISONAI_HOME override."""
from praisonaiagents.paths import get_sessions_dir
with patch.dict(os.environ, {"PRAISONAI_HOME": "/custom"}):
result = get_sessions_dir()
assert result == Path("/custom/sessions")
class TestGetSkillsDir:
"""Tests for get_skills_dir() function."""
def test_returns_skills_subdir(self):
"""Should return skills subdirectory of data dir."""
from praisonaiagents.paths import get_skills_dir
result = get_skills_dir()
assert result == Path.home() / ".praisonai" / "skills"
class TestGetPluginsDir:
"""Tests for get_plugins_dir() function."""
def test_returns_plugins_subdir(self):
"""Should return plugins subdirectory of data dir."""
from praisonaiagents.paths import get_plugins_dir
result = get_plugins_dir()
assert result == Path.home() / ".praisonai" / "plugins"
class TestGetMcpDir:
"""Tests for get_mcp_dir() function."""
def test_returns_mcp_subdir(self):
"""Should return mcp subdirectory of data dir."""
from praisonaiagents.paths import get_mcp_dir
result = get_mcp_dir()
assert result == Path.home() / ".praisonai" / "mcp"
class TestGetDocsDir:
"""Tests for get_docs_dir() function."""
def test_returns_docs_subdir(self):
"""Should return docs subdirectory of data dir."""
from praisonaiagents.paths import get_docs_dir
result = get_docs_dir()
assert result == Path.home() / ".praisonai" / "docs"
class TestGetRulesDir:
"""Tests for get_rules_dir() function."""
def test_returns_rules_subdir(self):
"""Should return rules subdirectory of data dir."""
from praisonaiagents.paths import get_rules_dir
result = get_rules_dir()
assert result == Path.home() / ".praisonai" / "rules"
class TestGetPermissionsDir:
"""Tests for get_permissions_dir() function."""
def test_returns_permissions_subdir(self):
"""Should return permissions subdirectory of data dir."""
from praisonaiagents.paths import get_permissions_dir
result = get_permissions_dir()
assert result == Path.home() / ".praisonai" / "permissions"
class TestGetStorageDir:
"""Tests for get_storage_dir() function."""
def test_returns_storage_subdir(self):
"""Should return storage subdirectory of data dir."""
from praisonaiagents.paths import get_storage_dir
result = get_storage_dir()
assert result == Path.home() / ".praisonai" / "storage"
class TestGetCheckpointsDir:
"""Tests for get_checkpoints_dir() function."""
def test_returns_checkpoints_subdir(self):
"""Should return checkpoints subdirectory of data dir."""
from praisonaiagents.paths import get_checkpoints_dir
result = get_checkpoints_dir()
assert result == Path.home() / ".praisonai" / "checkpoints"
class TestGetSnapshotsDir:
"""Tests for get_snapshots_dir() function."""
def test_returns_snapshots_subdir(self):
"""Should return snapshots subdirectory of data dir."""
from praisonaiagents.paths import get_snapshots_dir
result = get_snapshots_dir()
assert result == Path.home() / ".praisonai" / "snapshots"
class TestGetLearnDir:
"""Tests for get_learn_dir() function."""
def test_returns_learn_subdir(self):
"""Should return learn subdirectory of data dir."""
from praisonaiagents.paths import get_learn_dir
result = get_learn_dir()
assert result == Path.home() / ".praisonai" / "learn"
class TestGetProjectDataDir:
"""Tests for get_project_data_dir() function."""
def test_returns_praisonai_in_cwd(self):
"""Should return .praisonai/ in current working directory."""
from praisonaiagents.paths import get_project_data_dir
result = get_project_data_dir()
assert result == Path.cwd() / ".praisonai"
def test_accepts_custom_project_path(self, tmp_path):
"""Should accept custom project path."""
from praisonaiagents.paths import get_project_data_dir
result = get_project_data_dir(tmp_path)
assert result == tmp_path / ".praisonai"
class TestEnsureDir:
"""Tests for ensure_dir() function."""
def test_creates_directory(self, tmp_path):
"""Should create directory if it doesn't exist."""
from praisonaiagents.paths import ensure_dir
test_dir = tmp_path / "test" / "nested" / "dir"
assert not test_dir.exists()
result = ensure_dir(test_dir)
assert test_dir.exists()
assert test_dir.is_dir()
assert result == test_dir
def test_returns_existing_directory(self, tmp_path):
"""Should return existing directory without error."""
from praisonaiagents.paths import ensure_dir
test_dir = tmp_path / "existing"
test_dir.mkdir()
result = ensure_dir(test_dir)
assert result == test_dir
class TestGetMcpAuthPath:
"""Tests for get_mcp_auth_path() function."""
def test_returns_mcp_auth_json(self):
"""Should return path to mcp-auth.json."""
from praisonaiagents.paths import get_mcp_auth_path
result = get_mcp_auth_path()
assert result == Path.home() / ".praisonai" / "mcp-auth.json"
class TestConstants:
"""Tests for module constants."""
def test_env_var_name(self):
"""ENV_VAR should be PRAISONAI_HOME."""
from praisonaiagents.paths import ENV_VAR
assert ENV_VAR == "PRAISONAI_HOME"
def test_default_dir_name(self):
"""DEFAULT_DIR_NAME should be .praisonai."""
from praisonaiagents.paths import DEFAULT_DIR_NAME
assert DEFAULT_DIR_NAME == ".praisonai"
def test_legacy_dir_name(self):
"""LEGACY_DIR_NAME should be .praison."""
from praisonaiagents.paths import LEGACY_DIR_NAME
assert LEGACY_DIR_NAME == ".praison"
class TestGetAllPaths:
"""Tests for get_all_paths() function."""
def test_returns_dict_of_all_paths(self):
"""Should return dictionary with all path locations."""
from praisonaiagents.paths import get_all_paths
result = get_all_paths()
assert isinstance(result, dict)
assert "data_dir" in result
assert "sessions" in result
assert "skills" in result
assert "plugins" in result
assert "mcp" in result
assert "docs" in result
assert "rules" in result
assert "permissions" in result
assert "storage" in result
assert "checkpoints" in result
assert "snapshots" in result
assert "learn" in result
assert "mcp_auth" in result
class TestImportPerformance:
"""Tests for import performance."""
def test_import_time_under_10ms(self):
"""Module import should be fast (no heavy deps)."""
import time as time_module
import sys as sys_module
# Remove from cache if present
if "praisonaiagents.paths" in sys_module.modules:
del sys_module.modules["praisonaiagents.paths"]
start = time_module.perf_counter()
import praisonaiagents.paths as paths_module # noqa: F401
elapsed = time_module.perf_counter() - start
# Should import in under 10ms (no heavy deps)
assert elapsed < 0.01, f"Import took {elapsed*1000:.2f}ms, expected < 10ms"
@@ -352,8 +352,8 @@ class TestZeroOverhead:
Profiler.record_timing("test", 100.0)
elapsed = (time.perf_counter() - start) * 1000
# Should complete 10k calls in under 10ms when disabled
assert elapsed < 10, f"record_timing took {elapsed}ms for 10k calls"
# Should complete 10k calls in under 50ms when disabled (conservative for CI)
assert elapsed < 50, f"record_timing took {elapsed}ms for 10k calls"
def test_phase_context_overhead(self):
"""phase context manager should be fast when disabled."""
@@ -139,9 +139,14 @@ class TestRateLimiterAgentIntegration:
def test_agent_rate_limiter_applied_to_llm_calls(self):
"""Rate limiter should be applied before LLM calls."""
import os
from praisonaiagents import Agent
from praisonaiagents.llm import RateLimiter
# Skip if no API key - this test requires mocking at a deeper level
if not os.environ.get("OPENAI_API_KEY"):
pytest.skip("OPENAI_API_KEY not set")
limiter = RateLimiter(requests_per_minute=60)
acquire_count = [0]
original_acquire = limiter.acquire
@@ -0,0 +1,332 @@
"""
Tests for StreamEvent dataclass and streaming events.
TDD: These tests are written FIRST to define expected behavior.
"""
import time
class TestStreamEventDataclass:
"""Tests for StreamEvent dataclass fields and defaults."""
def test_stream_event_has_is_reasoning_field(self):
"""StreamEvent should have is_reasoning field."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
event = StreamEvent(type=StreamEventType.DELTA_TEXT)
assert hasattr(event, 'is_reasoning'), "StreamEvent missing 'is_reasoning' field"
def test_stream_event_is_reasoning_defaults_to_false(self):
"""is_reasoning should default to False for backward compatibility."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
event = StreamEvent(type=StreamEventType.DELTA_TEXT, content="Hello")
assert event.is_reasoning is False, "is_reasoning should default to False"
def test_stream_event_is_reasoning_can_be_set_true(self):
"""is_reasoning can be explicitly set to True."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
event = StreamEvent(
type=StreamEventType.DELTA_TEXT,
content="Thinking...",
is_reasoning=True
)
assert event.is_reasoning is True
def test_stream_event_has_agent_id_field(self):
"""StreamEvent should have optional agent_id field."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
event = StreamEvent(type=StreamEventType.DELTA_TEXT)
assert hasattr(event, 'agent_id'), "StreamEvent missing 'agent_id' field"
def test_stream_event_agent_id_defaults_to_none(self):
"""agent_id should default to None."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
event = StreamEvent(type=StreamEventType.DELTA_TEXT)
assert event.agent_id is None, "agent_id should default to None"
def test_stream_event_agent_id_can_be_set(self):
"""agent_id can be explicitly set."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
event = StreamEvent(
type=StreamEventType.DELTA_TEXT,
content="Hello",
agent_id="agent-123"
)
assert event.agent_id == "agent-123"
def test_stream_event_has_session_id_field(self):
"""StreamEvent should have optional session_id field."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
event = StreamEvent(type=StreamEventType.DELTA_TEXT)
assert hasattr(event, 'session_id'), "StreamEvent missing 'session_id' field"
def test_stream_event_session_id_defaults_to_none(self):
"""session_id should default to None."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
event = StreamEvent(type=StreamEventType.DELTA_TEXT)
assert event.session_id is None, "session_id should default to None"
def test_stream_event_has_run_id_field(self):
"""StreamEvent should have optional run_id field."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
event = StreamEvent(type=StreamEventType.DELTA_TEXT)
assert hasattr(event, 'run_id'), "StreamEvent missing 'run_id' field"
def test_stream_event_run_id_defaults_to_none(self):
"""run_id should default to None."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
event = StreamEvent(type=StreamEventType.DELTA_TEXT)
assert event.run_id is None, "run_id should default to None"
class TestStreamEventBackwardCompatibility:
"""Tests to ensure backward compatibility with existing code."""
def test_existing_code_works_without_new_fields(self):
"""Existing code that doesn't use new fields should work unchanged."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
# This is how existing code creates events
event = StreamEvent(
type=StreamEventType.DELTA_TEXT,
content="Hello world"
)
assert event.type == StreamEventType.DELTA_TEXT
assert event.content == "Hello world"
assert event.is_reasoning is False # Default
assert event.agent_id is None # Default
def test_stream_event_repr_works_with_new_fields(self):
"""__repr__ should work with new fields."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
event = StreamEvent(
type=StreamEventType.DELTA_TEXT,
content="Hello",
is_reasoning=True,
agent_id="agent-1"
)
repr_str = repr(event)
assert "DELTA_TEXT" in repr_str or "delta_text" in repr_str
def test_stream_metrics_update_from_event_works(self):
"""StreamMetrics.update_from_event should work with new fields."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType, StreamMetrics
metrics = StreamMetrics()
event = StreamEvent(
type=StreamEventType.DELTA_TEXT,
content="Hello",
is_reasoning=True
)
# Should not raise
metrics.update_from_event(event)
assert metrics.token_count == 1
class TestStreamEventEmitterWithNewFields:
"""Tests for StreamEventEmitter with new StreamEvent fields."""
def test_emitter_handles_reasoning_events(self):
"""StreamEventEmitter should handle events with is_reasoning=True."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType, StreamEventEmitter
emitter = StreamEventEmitter()
received_events = []
def callback(event):
received_events.append(event)
emitter.add_callback(callback)
# Emit a reasoning event
event = StreamEvent(
type=StreamEventType.DELTA_TEXT,
content="Thinking...",
is_reasoning=True
)
emitter.emit(event)
assert len(received_events) == 1
assert received_events[0].is_reasoning is True
def test_emitter_handles_agent_context(self):
"""StreamEventEmitter should handle events with agent context."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType, StreamEventEmitter
emitter = StreamEventEmitter()
received_events = []
def callback(event):
received_events.append(event)
emitter.add_callback(callback)
# Emit an event with agent context
event = StreamEvent(
type=StreamEventType.DELTA_TEXT,
content="Hello",
agent_id="agent-123",
session_id="session-456",
run_id="run-789"
)
emitter.emit(event)
assert len(received_events) == 1
assert received_events[0].agent_id == "agent-123"
assert received_events[0].session_id == "session-456"
assert received_events[0].run_id == "run-789"
class TestStreamEventReasoningCallback:
"""Tests for handling reasoning content in callbacks."""
def test_callback_can_distinguish_reasoning_content(self):
"""Callback should be able to distinguish reasoning from regular content."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
regular_events = []
reasoning_events = []
def callback(event):
if event.type == StreamEventType.DELTA_TEXT:
if event.is_reasoning:
reasoning_events.append(event)
else:
regular_events.append(event)
# Simulate receiving events
events = [
StreamEvent(type=StreamEventType.DELTA_TEXT, content="Hello", is_reasoning=False),
StreamEvent(type=StreamEventType.DELTA_TEXT, content="Thinking...", is_reasoning=True),
StreamEvent(type=StreamEventType.DELTA_TEXT, content="World", is_reasoning=False),
]
for event in events:
callback(event)
assert len(regular_events) == 2
assert len(reasoning_events) == 1
assert reasoning_events[0].content == "Thinking..."
class TestCreateTextPrinterCallbackWithReasoning:
"""Tests for text printer callback with reasoning support."""
def test_create_text_printer_callback_exists(self):
"""create_text_printer_callback should exist."""
from praisonaiagents.streaming.events import create_text_printer_callback
callback = create_text_printer_callback()
assert callable(callback)
class TestStreamEventAllFields:
"""Test all StreamEvent fields together."""
def test_stream_event_all_fields(self):
"""StreamEvent should support all fields together."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
event = StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content="Hello",
tool_call=None,
metadata={"key": "value"},
error=None,
is_reasoning=True,
agent_id="agent-1",
session_id="session-1",
run_id="run-1"
)
assert event.type == StreamEventType.DELTA_TEXT
assert event.content == "Hello"
assert event.is_reasoning is True
assert event.agent_id == "agent-1"
assert event.session_id == "session-1"
assert event.run_id == "run-1"
assert event.metadata == {"key": "value"}
class TestAgentIdPopulation:
"""Tests for agent_id population in streaming events."""
def test_agent_has_agent_id_property(self):
"""Agent should have agent_id property for streaming context."""
from praisonaiagents import Agent
agent = Agent(name="test-agent")
# Agent should have an agent_id (either explicit or derived from name)
assert hasattr(agent, 'agent_id') or hasattr(agent, 'name')
def test_stream_emitter_can_receive_agent_context(self):
"""StreamEventEmitter should handle events with agent context."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType, StreamEventEmitter
emitter = StreamEventEmitter()
received = []
emitter.add_callback(lambda e: received.append(e))
event = StreamEvent(
type=StreamEventType.DELTA_TEXT,
content="test",
agent_id="my-agent",
session_id="sess-1",
run_id="run-1"
)
emitter.emit(event)
assert len(received) == 1
assert received[0].agent_id == "my-agent"
class TestLiteLLMReasoningSupport:
"""Tests for LiteLLM reasoning content support."""
def test_llm_class_has_stream_callback_param(self):
"""LLM.get_response should accept stream_callback parameter."""
from praisonaiagents.llm import LLM
import inspect
# Check if get_response method exists and has stream_callback param
sig = inspect.signature(LLM.get_response)
params = list(sig.parameters.keys())
assert 'stream_callback' in params, "LLM.get_response should have stream_callback parameter"
def test_llm_class_has_emit_events_param(self):
"""LLM.get_response should accept emit_events parameter."""
from praisonaiagents.llm import LLM
import inspect
sig = inspect.signature(LLM.get_response)
params = list(sig.parameters.keys())
assert 'emit_events' in params, "LLM.get_response should have emit_events parameter"
class TestGeneratorStreamingPath:
"""Tests for generator-based streaming with callbacks."""
def test_openai_client_generator_has_stream_callback(self):
"""OpenAI client generator should support stream_callback."""
from praisonaiagents.llm.openai_client import OpenAIClient
import inspect
# Check chat_completion_with_tools_stream signature
sig = inspect.signature(OpenAIClient.chat_completion_with_tools_stream)
params = list(sig.parameters.keys())
assert 'stream_callback' in params, "chat_completion_with_tools_stream should have stream_callback"
@@ -4,10 +4,16 @@ TDD tests for:
1. list_processes NoneType bug fix
2. internet_search retry logic
3. search_web reliability
NOTE: These tests require psutil to be installed.
"""
import pytest
from unittest.mock import patch, MagicMock
# Skip entire module if psutil is not installed
pytest.importorskip("psutil", reason="psutil package not installed")
class TestListProcessesReliability:
"""Tests for list_processes NoneType bug fix."""
@@ -432,8 +432,8 @@ class TestPerformance:
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"
# Should complete 10000 iterations in under 500ms (conservative for CI)
assert elapsed < 0.5, f"NoOpSink too slow: {elapsed:.3f}s for 10000 iterations"
def test_disabled_emitter_overhead(self):
"""Test that disabled emitter has near-zero overhead."""
+10 -2
View File
@@ -2549,7 +2549,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.2.2"
version = "1.3.1"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -2617,6 +2617,11 @@ mongodb = [
{ name = "motor" },
{ name = "pymongo" },
]
os = [
{ name = "fastapi" },
{ name = "pyjwt" },
{ name = "uvicorn" },
]
telemetry = [
{ name = "posthog" },
]
@@ -2630,6 +2635,7 @@ requires-dist = [
{ name = "chromadb", marker = "extra == 'memory'", specifier = ">=1.0.0" },
{ name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" },
{ name = "fastapi", marker = "extra == 'mcp'", specifier = ">=0.115.0" },
{ name = "fastapi", marker = "extra == 'os'", specifier = ">=0.115.0" },
{ name = "litellm", marker = "extra == 'llm'", specifier = ">=1.81.0" },
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.81.0" },
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
@@ -2653,15 +2659,17 @@ requires-dist = [
{ name = "pydantic", specifier = ">=2.10.0" },
{ name = "pydantic", marker = "extra == 'llm'", specifier = ">=2.10.0" },
{ name = "pyjwt", marker = "extra == 'auth'", specifier = ">=2.8.0" },
{ name = "pyjwt", marker = "extra == 'os'", specifier = ">=2.8.0" },
{ name = "pymongo", marker = "extra == 'mongodb'", specifier = ">=4.6.3" },
{ name = "python-jose", extras = ["cryptography"], marker = "extra == 'auth'", specifier = ">=3.3.0" },
{ name = "python-multipart", marker = "extra == 'auth'", specifier = ">=0.0.6" },
{ name = "rich" },
{ name = "uvicorn", marker = "extra == 'api'", specifier = ">=0.34.0" },
{ name = "uvicorn", marker = "extra == 'mcp'", specifier = ">=0.34.0" },
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
]
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "telemetry", "mongodb", "auth", "all"]
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "auth", "all"]
[[package]]
name = "propcache"
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.2.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.2.2.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.3.1.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.3.1.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+178 -2
View File
@@ -314,9 +314,22 @@ def train_list(
@app.command("show")
def train_show(
session_id: str = typer.Argument(..., help="Session ID to show"),
iterations_flag: bool = typer.Option(False, "--iterations", "-i", help="Show detailed iteration info"),
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
):
"""Show details of a training session."""
"""
Show details of a training session.
Examples:
# Show session summary
praisonai train show train-abc123
# Show with detailed iterations
praisonai train show train-abc123 --iterations
# Output as JSON
praisonai train show train-abc123 --json
"""
from ..output.console import get_output_controller
output = get_output_controller()
@@ -346,11 +359,174 @@ def train_show(
if report:
report.print_summary()
# Show best iteration
best = report.get_best_iteration()
if best:
output.print(f"\n✨ Best Iteration: #{best.iteration_num} (Score: {best.score}/10)")
if iterations:
output.print("\nIterations:")
# Find best score for highlighting
best_score = max(it.score for it in iterations) if iterations else 0
for it in iterations:
output.print(f" [{it.iteration_num}] Score: {it.score}/10 - {it.feedback[:50]}...")
# Highlight best iteration
marker = "★" if it.score == best_score else " "
feedback_preview = it.feedback[:50] + "..." if len(it.feedback) > 50 else it.feedback
output.print(f" {marker} [{it.iteration_num}] Score: {it.score}/10 - {feedback_preview}")
# Show suggestions if --iterations flag
if iterations_flag and it.suggestions:
for suggestion in it.suggestions[:3]: # Show top 3 suggestions
output.print(f" → {suggestion}")
@app.command("apply")
def train_apply(
session_id: str = typer.Argument(..., help="Training session ID to apply"),
agent_file: Optional[str] = typer.Option(
None,
"--agent", "-a",
help="Path to agent YAML file"
),
iteration: Optional[int] = typer.Option(
None,
"--iteration", "-n",
help="Specific iteration number (default: best score)"
),
run_prompt: Optional[str] = typer.Option(
None,
"--run", "-r",
help="Run agent with this prompt after applying training"
),
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
):
"""
Apply training to an agent.
Loads training suggestions from a session and applies them to an agent
via hooks. By default, uses the iteration with the best score.
Examples:
# Apply best iteration to default agent
praisonai train apply train-abc123
# Apply specific iteration
praisonai train apply train-abc123 --iteration 2
# Apply to agent from YAML file
praisonai train apply train-abc123 --agent my_agent.yaml
# Apply and run immediately
praisonai train apply train-abc123 --run "Hello, how are you?"
"""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.train.agents import apply_training, get_training_profile
from praisonai.train.agents.storage import TrainingStorage
except ImportError as e:
output.print_error(f"Training module not available: {e}")
raise typer.Exit(1)
# Verify session exists
storage = TrainingStorage(session_id=session_id)
if not storage.storage_path.exists():
output.print_error(f"Session not found: {session_id}")
raise typer.Exit(1)
# Get the training profile
profile = get_training_profile(
session_id=session_id,
iteration=iteration,
agent_name="agent",
)
if profile is None:
output.print_error(f"Could not load training profile from session {session_id}")
raise typer.Exit(1)
if json_output or output.is_json_mode:
output.print_json({
"session_id": session_id,
"iteration": profile.iteration_num,
"score": profile.quality_score,
"suggestions": profile.suggestions,
"summary": profile.summary,
})
if not run_prompt:
return
# Show profile info
output.print_panel(
f"Session: {profile.session_id}\n"
f"Iteration: {profile.iteration_num}\n"
f"Score: {profile.quality_score}/10\n"
f"Suggestions: {len(profile.suggestions)}",
title="Training Profile"
)
if profile.suggestions:
output.print("\nSuggestions:")
for i, suggestion in enumerate(profile.suggestions, 1):
output.print(f" {i}. {suggestion}")
# If run_prompt provided, create agent and run
if run_prompt:
# Create or load agent
agent = None
if agent_file:
agent = _load_agent_from_file(agent_file, output)
if agent is None:
raise typer.Exit(1)
else:
try:
from praisonaiagents import Agent
agent = Agent(
instructions="You are a helpful assistant."
)
except ImportError:
output.print_error(
"praisonaiagents not installed",
remediation="pip install praisonaiagents"
)
raise typer.Exit(1)
# Apply training
success = apply_training(agent, profile=profile)
if not success:
output.print_error("Failed to apply training to agent")
raise typer.Exit(1)
output.print_success("Training applied successfully!")
output.print(f"\nRunning agent with prompt: {run_prompt}\n")
# Run the agent
try:
if hasattr(agent, 'chat'):
response = agent.chat(run_prompt)
elif hasattr(agent, 'start'):
response = agent.start(run_prompt)
else:
output.print_error("Agent has no chat or start method")
raise typer.Exit(1)
output.print_panel(str(response), title="Agent Response")
except Exception as e:
output.print_error(f"Agent execution failed: {e}")
raise typer.Exit(1)
else:
output.print_info(
"\nTo apply this training to an agent, use:\n"
f" praisonai train apply {session_id} --run 'Your prompt here'\n"
"\nOr in Python:\n"
f" from praisonai.train.agents import apply_training\n"
f" apply_training(agent, session_id='{session_id}')"
)
def _load_agent_from_file(file_path: str, output) -> Optional[object]:
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,463 @@
"""
Agent-Driven Migration Flow.
Uses AgentFlow with specialized agents to migrate code from various
agent frameworks to PraisonAI format.
Architecture:
1. Analyzer Agent - Scans all files, understands relationships
2. Converter Agent - Transforms code using migration spec
3. Evaluator Agent - Uses LLM-as-Judge to validate conversion
4. Error Agent - Analyzes failures and suggests fixes
The flow includes a retry loop (max 3 iterations) for error recovery.
"""
import os
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from pathlib import Path
logger = logging.getLogger(__name__)
@dataclass
class FileContext:
"""Context for a single file in the migration."""
path: str
content: str
patterns_found: List[str] = field(default_factory=list)
converted_content: Optional[str] = None
errors: List[str] = field(default_factory=list)
@dataclass
class MigrationContext:
"""Complete context for multi-file migration."""
source_path: str
files: Dict[str, FileContext] = field(default_factory=dict)
all_imports: List[str] = field(default_factory=list)
all_classes: List[str] = field(default_factory=list)
all_functions: List[str] = field(default_factory=list)
cross_file_deps: Dict[str, List[str]] = field(default_factory=dict)
def to_prompt(self) -> str:
"""Convert context to a prompt for LLM agents."""
lines = [
f"# Project Analysis: {self.source_path}",
f"Total files: {len(self.files)}",
"",
"## Files:",
]
for path, ctx in self.files.items():
lines.append(f"- {path}: {len(ctx.content)} chars, patterns: {ctx.patterns_found}")
if self.cross_file_deps:
lines.append("")
lines.append("## Cross-file Dependencies:")
for file, deps in self.cross_file_deps.items():
lines.append(f"- {file} imports from: {deps}")
return "\n".join(lines)
@dataclass
class MigrationResult:
"""Result of the migration flow."""
success: bool
files_converted: int = 0
files_failed: int = 0
iterations: int = 0
evaluation_score: float = 0.0
converted_files: Dict[str, str] = field(default_factory=dict)
errors: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
class MigrationFlow:
"""
Agent-driven migration flow using AgentFlow.
Uses specialized agents to analyze, convert, evaluate, and fix
code migration from various agent frameworks to PraisonAI.
Example:
flow = MigrationFlow(
source_path="./my-project",
output_path="./converted",
max_retries=3
)
result = flow.run()
"""
def __init__(
self,
source_path: str,
output_path: Optional[str] = None,
max_retries: int = 3,
llm: str = "gpt-4o-mini",
dry_run: bool = True,
):
self.source_path = source_path
self.output_path = output_path or f"{source_path}_converted"
self.max_retries = max_retries
self.llm = llm
self.dry_run = dry_run
self._context: Optional[MigrationContext] = None
self._spec = None
def _gather_files(self) -> MigrationContext:
"""Gather all Python files and build context."""
context = MigrationContext(source_path=self.source_path)
source = Path(self.source_path)
if source.is_file():
# Single file
with open(source, 'r', encoding='utf-8') as f:
content = f.read()
context.files[str(source)] = FileContext(
path=str(source),
content=content
)
else:
# Directory - scan for Python files
exclude_dirs = {'venv', '.venv', '__pycache__', '.git', 'node_modules', '.tox', 'dist', 'build'}
for root, dirs, files in os.walk(source):
# Filter out excluded directories
dirs[:] = [d for d in dirs if d not in exclude_dirs]
for filename in files:
if filename.endswith('.py'):
filepath = os.path.join(root, filename)
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
rel_path = os.path.relpath(filepath, source)
context.files[rel_path] = FileContext(
path=rel_path,
content=content
)
except Exception as e:
logger.warning(f"Could not read {filepath}: {e}")
# Analyze cross-file dependencies
self._analyze_dependencies(context)
return context
def _analyze_dependencies(self, context: MigrationContext) -> None:
"""Analyze imports and cross-file dependencies."""
import re
for path, file_ctx in context.files.items():
# Extract imports
import_pattern = r'^(?:from\s+(\S+)\s+)?import\s+(.+)$'
for line in file_ctx.content.split('\n'):
match = re.match(import_pattern, line.strip())
if match:
module = match.group(1) or match.group(2).split(',')[0].strip()
context.all_imports.append(module)
# Check if it's a local import
for other_path in context.files:
other_module = other_path.replace('/', '.').replace('.py', '')
if module.startswith(other_module) or other_module.endswith(module):
if path not in context.cross_file_deps:
context.cross_file_deps[path] = []
context.cross_file_deps[path].append(other_path)
# Extract class and function names
class_pattern = r'^class\s+(\w+)'
func_pattern = r'^def\s+(\w+)'
for line in file_ctx.content.split('\n'):
match = re.match(class_pattern, line)
if match:
context.all_classes.append(match.group(1))
match = re.match(func_pattern, line)
if match:
context.all_functions.append(match.group(1))
def _create_agents(self):
"""Create the specialized migration agents."""
# Lazy import to avoid circular dependencies
from praisonaiagents import Agent
from .migration_spec import get_default_spec, PRAISONAI_REFERENCE
self._spec = get_default_spec()
spec_prompt = self._spec.to_prompt()
# Analyzer Agent
analyzer = Agent(
name="Analyzer",
role="Code Analyzer",
goal="Analyze source code to identify agent patterns and dependencies",
instructions=f"""You are a code analysis expert. Analyze the provided source code to:
1. Identify all agent-related patterns (Agent, Task, Team, Workflow definitions)
2. Map dependencies between files
3. Identify import patterns that need conversion
4. List all classes and functions that are part of the agent system
Use this specification to identify patterns:
{spec_prompt}
Output a structured analysis with:
- List of detected patterns per file
- Cross-file dependencies
- Recommended conversion order
""",
llm=self.llm,
)
# Converter Agent
converter = Agent(
name="Converter",
role="Code Converter",
goal="Convert source code to PraisonAI format",
instructions=f"""You are a code conversion expert. Convert the provided source code to PraisonAI format.
Use this specification for conversion:
{spec_prompt}
Reference for PraisonAI code:
{PRAISONAI_REFERENCE}
Rules:
1. Preserve all logic and behavior
2. Convert imports to praisonaiagents
3. Map parameters according to the spec
4. Handle cross-file dependencies correctly
5. Ensure the converted code is syntactically valid Python
6. Do NOT include any framework names in comments
Output the complete converted code for each file.
""",
llm=self.llm,
)
# Evaluator Agent (LLM-as-Judge)
evaluator = Agent(
name="Evaluator",
role="Migration Evaluator",
goal="Evaluate the quality of code migration",
instructions="""You are a code quality evaluator using LLM-as-Judge methodology.
Evaluate the converted code on these criteria (score 1-10 each):
1. **Syntax Correctness**: Is the code valid Python?
2. **Import Completeness**: Are all necessary imports present?
3. **Functional Equivalence**: Does it preserve the original behavior?
4. **Pattern Accuracy**: Are agent patterns correctly converted?
5. **Dependency Handling**: Are cross-file dependencies handled?
Output format:
```json
{
"scores": {
"syntax": 8,
"imports": 9,
"equivalence": 7,
"patterns": 8,
"dependencies": 9
},
"overall_score": 8.2,
"passed": true,
"issues": ["list of specific issues if any"],
"suggestions": ["list of improvements if any"]
}
```
A score of 8.0 or higher means PASSED.
""",
llm=self.llm,
)
# Error Agent
error_agent = Agent(
name="ErrorAnalyzer",
role="Error Analyzer",
goal="Analyze migration errors and suggest fixes",
instructions="""You are an error analysis expert. When migration evaluation fails:
1. Analyze the specific errors reported
2. Identify the root cause
3. Suggest specific fixes
4. Provide corrected code snippets
Output format:
```json
{
"root_cause": "description of the main issue",
"affected_files": ["list of files with issues"],
"fixes": [
{
"file": "filename",
"issue": "what's wrong",
"fix": "how to fix it",
"corrected_code": "the fixed code snippet"
}
],
"retry_recommended": true
}
```
""",
llm=self.llm,
)
return analyzer, converter, evaluator, error_agent
def run(self) -> MigrationResult:
"""Run the migration flow."""
result = MigrationResult(success=False)
# Gather all files
logger.info(f"Gathering files from {self.source_path}")
self._context = self._gather_files()
if not self._context.files:
result.errors.append("No Python files found")
return result
logger.info(f"Found {len(self._context.files)} files to analyze")
# Create agents
analyzer, converter, evaluator, error_agent = self._create_agents()
# Build the combined context for all files
all_code = []
for path, file_ctx in self._context.files.items():
all_code.append(f"# File: {path}\n{file_ctx.content}")
combined_code = "\n\n---\n\n".join(all_code)
# Retry loop
for iteration in range(self.max_retries):
result.iterations = iteration + 1
logger.info(f"Migration iteration {iteration + 1}/{self.max_retries}")
try:
# Step 1: Analyze
analysis_prompt = f"""Analyze this codebase for migration:
{self._context.to_prompt()}
Full code:
{combined_code}
"""
analysis = analyzer.start(analysis_prompt)
logger.info("Analysis complete")
# Step 2: Convert
convert_prompt = f"""Convert this code to PraisonAI format.
Analysis results:
{analysis}
Original code:
{combined_code}
Convert ALL files and output the complete converted code for each file.
Format: Start each file with "# FILE: filename.py" followed by the converted code.
"""
converted = converter.start(convert_prompt)
logger.info("Conversion complete")
# Parse converted files
self._parse_converted_output(converted, result)
# Step 3: Evaluate
eval_prompt = f"""Evaluate this migration:
Original code:
{combined_code}
Converted code:
{converted}
Evaluate and score the migration quality.
"""
evaluation = evaluator.start(eval_prompt)
logger.info("Evaluation complete")
# Parse evaluation result
import json
import re
# Try to extract JSON from evaluation
json_match = re.search(r'\{[\s\S]*\}', evaluation)
if json_match:
try:
eval_result = json.loads(json_match.group())
result.evaluation_score = eval_result.get('overall_score', 0)
if eval_result.get('passed', False) or result.evaluation_score >= 8.0:
result.success = True
result.files_converted = len(result.converted_files)
logger.info(f"Migration PASSED with score {result.evaluation_score}")
break
else:
# Evaluation failed, run error analysis
issues = eval_result.get('issues', [])
result.warnings.extend(issues)
if iteration < self.max_retries - 1:
error_prompt = f"""Migration evaluation failed.
Issues found:
{issues}
Converted code:
{converted}
Analyze the errors and suggest fixes.
"""
_ = error_agent.start(error_prompt)
logger.info("Error analysis complete, retrying...")
# The next iteration will use the error analysis context
except json.JSONDecodeError:
result.warnings.append("Could not parse evaluation result")
else:
result.warnings.append("No evaluation JSON found")
except Exception as e:
result.errors.append(f"Iteration {iteration + 1} failed: {str(e)}")
logger.error(f"Migration error: {e}")
if not result.success:
result.errors.append(f"Migration failed after {self.max_retries} attempts")
# Save converted files if not dry run
if not self.dry_run and result.success:
self._save_converted_files(result)
return result
def _parse_converted_output(self, converted: str, result: MigrationResult) -> None:
"""Parse the converted output into individual files."""
import re
# Split by file markers
file_pattern = r'#\s*FILE:\s*(\S+\.py)'
parts = re.split(file_pattern, converted)
# parts[0] is before first file, then alternating filename, content
for i in range(1, len(parts), 2):
if i + 1 < len(parts):
filename = parts[i].strip()
content = parts[i + 1].strip()
# Clean up code blocks
content = re.sub(r'^```python\s*', '', content)
content = re.sub(r'\s*```$', '', content)
result.converted_files[filename] = content
def _save_converted_files(self, result: MigrationResult) -> None:
"""Save converted files to output directory."""
output_dir = Path(self.output_path)
output_dir.mkdir(parents=True, exist_ok=True)
for filename, content in result.converted_files.items():
filepath = output_dir / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
logger.info(f"Saved: {filepath}")
@@ -0,0 +1,276 @@
"""
Migration Specification for Agent-Driven Code Migration.
This module defines the feature mapping specification used by the migration agents
to convert code from various agent framework patterns to PraisonAI format.
The spec is framework-agnostic - it describes patterns generically without
explicitly naming source frameworks.
Design:
- Pattern-based: Describes what to look for, not where it comes from
- Dynamic: LLM agents use this spec to understand and convert code
- Extensible: New patterns can be added without code changes
"""
from dataclasses import dataclass, field
from typing import Dict, List
@dataclass
class PatternSpec:
"""Specification for a code pattern to detect and convert."""
name: str
description: str
source_indicators: List[str] # What to look for in source code
target_equivalent: str # PraisonAI equivalent
parameter_mapping: Dict[str, str] # source_param -> praisonai_param
notes: str = ""
@dataclass
class MigrationSpec:
"""Complete migration specification for agent-driven conversion."""
# Agent patterns
agent_patterns: List[PatternSpec] = field(default_factory=list)
# Task/Step patterns
task_patterns: List[PatternSpec] = field(default_factory=list)
# Team/Orchestrator patterns
team_patterns: List[PatternSpec] = field(default_factory=list)
# Workflow patterns
workflow_patterns: List[PatternSpec] = field(default_factory=list)
# Import patterns
import_patterns: Dict[str, str] = field(default_factory=dict)
# Method patterns
method_patterns: Dict[str, str] = field(default_factory=dict)
def to_prompt(self) -> str:
"""Convert spec to a prompt for LLM agents."""
lines = [
"# Code Migration Specification",
"",
"Use this specification to convert agent code to PraisonAI format.",
"",
"## Agent Patterns",
""
]
for p in self.agent_patterns:
lines.append(f"### {p.name}")
lines.append(f"Description: {p.description}")
lines.append(f"Look for: {', '.join(p.source_indicators)}")
lines.append(f"Convert to: {p.target_equivalent}")
lines.append("Parameter mapping:")
for src, tgt in p.parameter_mapping.items():
lines.append(f" - {src} → {tgt}")
if p.notes:
lines.append(f"Notes: {p.notes}")
lines.append("")
lines.append("## Task Patterns")
lines.append("")
for p in self.task_patterns:
lines.append(f"### {p.name}")
lines.append(f"Description: {p.description}")
lines.append(f"Look for: {', '.join(p.source_indicators)}")
lines.append(f"Convert to: {p.target_equivalent}")
lines.append("")
lines.append("## Team/Orchestrator Patterns")
lines.append("")
for p in self.team_patterns:
lines.append(f"### {p.name}")
lines.append(f"Description: {p.description}")
lines.append(f"Look for: {', '.join(p.source_indicators)}")
lines.append(f"Convert to: {p.target_equivalent}")
lines.append("Parameter mapping:")
for src, tgt in p.parameter_mapping.items():
lines.append(f" - {src} → {tgt}")
lines.append("")
lines.append("## Import Conversions")
lines.append("")
for src, tgt in self.import_patterns.items():
lines.append(f"- `{src}` → `{tgt}`")
lines.append("")
lines.append("## Method Conversions")
lines.append("")
for src, tgt in self.method_patterns.items():
lines.append(f"- `.{src}()` → `.{tgt}()`")
return "\n".join(lines)
def get_default_spec() -> MigrationSpec:
"""Get the default migration specification."""
return MigrationSpec(
agent_patterns=[
PatternSpec(
name="Role-Goal-Backstory Agent",
description="Agent defined with role, goal, and backstory attributes",
source_indicators=["role=", "goal=", "backstory=", "Agent("],
target_equivalent="from praisonaiagents import Agent",
parameter_mapping={
"role": "role",
"goal": "goal",
"backstory": "backstory",
"tools": "tools",
"llm": "llm",
"verbose": "verbose",
},
),
PatternSpec(
name="Name-Instructions Agent",
description="Agent defined with name and instructions",
source_indicators=["name=", "instructions=", "model=", "Agent("],
target_equivalent="from praisonaiagents import Agent",
parameter_mapping={
"name": "name",
"instructions": "instructions",
"model": "llm", # Convert model object to llm string
"tools": "tools",
"markdown": "markdown",
},
notes="model=ModelClass(...) should become llm='provider/model-name'",
),
],
task_patterns=[
PatternSpec(
name="Description-Output Task",
description="Task with description and expected_output",
source_indicators=["description=", "expected_output=", "Task("],
target_equivalent="from praisonaiagents import Task",
parameter_mapping={
"description": "description",
"expected_output": "expected_output",
"agent": "agent",
"context": "context",
},
),
PatternSpec(
name="Step Pattern",
description="Workflow step with agent and description",
source_indicators=["Step(", "agent=", "description="],
target_equivalent="from praisonaiagents import Task",
parameter_mapping={
"name": "name",
"agent": "agent",
"description": "description",
},
),
],
team_patterns=[
PatternSpec(
name="Crew/Team Orchestrator",
description="Multi-agent orchestrator with agents list",
source_indicators=["Crew(", "Team(", "agents=", "tasks=", "members="],
target_equivalent="from praisonaiagents import AgentTeam",
parameter_mapping={
"agents": "agents",
"members": "agents", # members → agents
"tasks": "tasks",
"process": "process",
"verbose": "verbose",
},
),
PatternSpec(
name="Workflow Orchestrator",
description="Sequential workflow with steps",
source_indicators=["Workflow(", "steps="],
target_equivalent="from praisonaiagents import AgentFlow",
parameter_mapping={
"name": "name",
"steps": "steps",
"description": "description",
},
),
],
workflow_patterns=[],
import_patterns={
# Generic patterns - the LLM will match these
"from X.agent import Agent": "from praisonaiagents import Agent",
"from X import Agent": "from praisonaiagents import Agent",
"from X import Task": "from praisonaiagents import Task",
"from X.team import Team": "from praisonaiagents import AgentTeam",
"from X import Crew": "from praisonaiagents import AgentTeam",
"from X.workflow import Workflow": "from praisonaiagents import AgentFlow",
"from X.models.Y import Z": "# Use llm parameter instead",
"from X.db.Y import Z": "# Use memory parameter instead",
},
method_patterns={
"kickoff": "start",
"run": "start",
"print_response": "start",
"execute": "start",
},
)
# PraisonAI reference code for the LLM to understand target format
PRAISONAI_REFERENCE = '''
# PraisonAI Agent Framework Reference
## Basic Agent
```python
from praisonaiagents import Agent
agent = Agent(
name="assistant", # Agent name
role="Helper", # Agent role
goal="Help users", # Agent goal
backstory="Expert helper", # Agent backstory
instructions="Be helpful", # System instructions
llm="gpt-4o-mini", # LLM model (provider/model format)
tools=[my_tool], # List of tools
memory=True, # Enable memory
markdown=True, # Enable markdown output
)
# Run the agent
response = agent.start("Hello!")
```
## Multi-Agent Team
```python
from praisonaiagents import Agent, AgentTeam
agent1 = Agent(name="researcher", role="Research")
agent2 = Agent(name="writer", role="Write")
team = AgentTeam(
agents=[agent1, agent2],
process="sequential", # or "hierarchical"
)
result = team.start("Research and write about AI")
```
## AgentFlow (Workflow)
```python
from praisonaiagents import Agent, AgentFlow
flow = AgentFlow(
name="my_flow",
steps=[
Agent(instructions="Step 1"),
Agent(instructions="Step 2"),
],
)
result = flow.run("Start the workflow")
```
## LLM Format
Use "provider/model" format:
- OpenAI: "gpt-4o-mini", "gpt-4o", "o1-mini"
- Google: "gemini/gemini-2.0-flash", "gemini/gemini-pro"
- Anthropic: "anthropic/claude-3-sonnet"
- Ollama: "ollama/llama3"
'''
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==4.2.2 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.3.1 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -35,8 +35,13 @@ __all__ = [
"TrainingScenario",
"TrainingIteration",
"TrainingReport",
"TrainingProfile",
"TrainingGrader",
"TrainingStorage",
"TrainingHook",
"apply_training",
"remove_training",
"get_training_profile",
]
@@ -58,6 +63,10 @@ def __getattr__(name: str):
from .models import TrainingReport
return TrainingReport
if name == "TrainingProfile":
from .models import TrainingProfile
return TrainingProfile
if name == "TrainingGrader":
from .grader import TrainingGrader
return TrainingGrader
@@ -66,4 +75,20 @@ def __getattr__(name: str):
from .storage import TrainingStorage
return TrainingStorage
if name == "TrainingHook":
from .hook import TrainingHook
return TrainingHook
if name == "apply_training":
from .hook import apply_training
return apply_training
if name == "remove_training":
from .hook import remove_training
return remove_training
if name == "get_training_profile":
from .hook import get_training_profile
return get_training_profile
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,320 @@
"""
Training Hook for Agent Runtime.
Provides hook-based injection of training suggestions into agent prompts.
This allows training data to be applied at runtime without modifying the Agent class.
Usage:
from praisonai.train.agents import apply_training, remove_training
from praisonaiagents import Agent
agent = Agent(name="assistant", instructions="Be helpful")
# Apply training from a session (uses best iteration by default)
apply_training(agent, session_id="train-abc123")
# Or apply training from a specific iteration
apply_training(agent, session_id="train-abc123", iteration=2)
# Or apply with a pre-loaded profile
apply_training(agent, profile=my_profile)
# Run the agent - training suggestions are automatically injected
response = agent.start("Hello!")
# Remove training when done
remove_training(agent)
"""
import logging
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from praisonaiagents import Agent
from .models import TrainingProfile
from .storage import TrainingStorage
logger = logging.getLogger(__name__)
# Attribute name for storing hook ID on agent
_TRAINING_HOOK_ATTR = "_training_hook_id"
class TrainingHook:
"""
Hook that injects training suggestions into agent prompts.
This hook is registered on the BEFORE_AGENT event and modifies
the prompt to include training guidance.
Example:
profile = TrainingProfile(
agent_name="assistant",
suggestions=["Be concise", "Use examples"],
quality_score=8.5,
summary="Focus on clarity",
iteration_num=2,
session_id="train-abc"
)
hook = TrainingHook(profile)
# Hook is called automatically by the agent's hook system
"""
def __init__(self, profile: TrainingProfile):
"""
Initialize the training hook.
Args:
profile: The training profile with suggestions to inject
"""
self.profile = profile
def __call__(self, event):
"""
Process the BEFORE_AGENT event.
Modifies the prompt to include training guidance.
Args:
event: BeforeAgentInput event data
Returns:
HookResult with modified prompt
"""
# Lazy import to avoid circular dependencies
from praisonaiagents.hooks import HookResult
# Build training guidance section
guidance = self._build_guidance()
# Get original prompt
original_prompt = getattr(event, 'prompt', '') or ''
# Append guidance to prompt
modified_prompt = original_prompt + guidance
return HookResult(
decision="allow",
modified_input={"prompt": modified_prompt},
reason=f"Training guidance applied from session {self.profile.session_id}"
)
def _build_guidance(self) -> str:
"""
Build the training guidance text to inject.
Returns:
Formatted guidance string
"""
if not self.profile.suggestions:
return ""
lines = [
"\n\n[TRAINING GUIDANCE]",
f"Based on training session {self.profile.session_id} (iteration {self.profile.iteration_num}, score: {self.profile.quality_score}/10):",
]
for suggestion in self.profile.suggestions:
lines.append(f"- {suggestion}")
if self.profile.summary:
lines.append(f"\nSummary: {self.profile.summary}")
lines.append("\nPlease follow these guidelines in your response.")
return "\n".join(lines)
def apply_training(
agent: "Agent",
session_id: Optional[str] = None,
iteration: Optional[int] = None,
profile: Optional[TrainingProfile] = None,
) -> bool:
"""
Apply training to an agent via BEFORE_AGENT hook.
This function registers a hook that injects training suggestions
into the agent's prompts at runtime.
Args:
agent: The agent to apply training to
session_id: Training session ID to load from
iteration: Specific iteration number to use (default: best score)
profile: Pre-loaded TrainingProfile (alternative to session_id)
Returns:
True if training was applied successfully, False otherwise
Raises:
ValueError: If neither session_id nor profile is provided
Example:
# Apply best iteration from a session
apply_training(agent, session_id="train-abc123")
# Apply specific iteration
apply_training(agent, session_id="train-abc123", iteration=2)
# Apply with pre-loaded profile
apply_training(agent, profile=my_profile)
"""
if profile is None and session_id is None:
raise ValueError("Either session_id or profile must be provided")
# Load profile from session if not provided
if profile is None:
profile = _load_profile_from_session(
session_id=session_id,
iteration=iteration,
agent_name=getattr(agent, 'name', 'agent'),
)
if profile is None:
logger.warning(f"No training data found for session {session_id}")
return False
# Create the hook
hook = TrainingHook(profile)
# Get the hook registry from the agent
try:
# Lazy import
from praisonaiagents.hooks import HookEvent
registry = agent._hook_runner.registry
# Register the hook
hook_id = registry.register_function(
event=HookEvent.BEFORE_AGENT,
func=hook,
name=f"training_hook_{profile.session_id}",
description=f"Training guidance from session {profile.session_id}",
)
# Store hook ID on agent for later removal
setattr(agent, _TRAINING_HOOK_ATTR, hook_id)
logger.info(
f"Applied training to agent '{agent.name}' from session "
f"{profile.session_id} (iteration {profile.iteration_num}, "
f"score: {profile.quality_score}/10)"
)
return True
except Exception as e:
logger.error(f"Failed to apply training: {e}")
return False
def remove_training(agent: "Agent") -> bool:
"""
Remove training hook from an agent.
Args:
agent: The agent to remove training from
Returns:
True if training was removed, False if no training was applied
Example:
remove_training(agent)
"""
hook_id = getattr(agent, _TRAINING_HOOK_ATTR, None)
if hook_id is None:
logger.debug("No training hook found on agent")
return False
try:
registry = agent._hook_runner.registry
registry.unregister(hook_id)
delattr(agent, _TRAINING_HOOK_ATTR)
logger.info(f"Removed training from agent '{agent.name}'")
return True
except Exception as e:
logger.error(f"Failed to remove training: {e}")
return False
def _load_profile_from_session(
session_id: str,
iteration: Optional[int],
agent_name: str,
) -> Optional[TrainingProfile]:
"""
Load a TrainingProfile from a training session.
Args:
session_id: The training session ID
iteration: Specific iteration number, or None for best
agent_name: Name of the agent
Returns:
TrainingProfile or None if not found
"""
try:
storage = TrainingStorage(session_id=session_id)
if not storage.storage_path.exists():
logger.warning(f"Training session not found: {session_id}")
return None
report = storage.load_report()
if report is None:
logger.warning(f"No report found for session: {session_id}")
return None
# Get the iteration
if iteration is not None:
it = report.get_iteration(iteration)
if it is None:
logger.warning(f"Iteration {iteration} not found in session {session_id}")
return None
else:
# Use best iteration
it = report.get_best_iteration()
if it is None:
logger.warning(f"No iterations found in session {session_id}")
return None
# Create profile from iteration
return TrainingProfile.from_iteration(
iteration=it,
agent_name=agent_name,
session_id=session_id,
)
except Exception as e:
logger.error(f"Failed to load training profile: {e}")
return None
def get_training_profile(
session_id: str,
iteration: Optional[int] = None,
agent_name: str = "agent",
) -> Optional[TrainingProfile]:
"""
Get a training profile from a session without applying it.
Useful for inspecting training data before applying.
Args:
session_id: The training session ID
iteration: Specific iteration number, or None for best
agent_name: Name to use for the profile
Returns:
TrainingProfile or None if not found
Example:
profile = get_training_profile("train-abc123")
print(f"Score: {profile.quality_score}")
print(f"Suggestions: {profile.suggestions}")
"""
return _load_profile_from_session(session_id, iteration, agent_name)
@@ -11,6 +11,86 @@ from typing import Any, Dict, List, Optional
import statistics
@dataclass
class TrainingProfile:
"""
Consolidated training profile for runtime application.
Contains the actionable suggestions from a training session that can be
injected into an agent's prompt at runtime via hooks.
Attributes:
agent_name: Name of the agent this profile is for
suggestions: List of improvement suggestions
quality_score: Score from the training iteration
summary: Summary of the training feedback
iteration_num: Which iteration this came from
session_id: The training session ID
created_at: When this profile was created
Example:
profile = TrainingProfile(
agent_name="assistant",
suggestions=["Be concise", "Use examples"],
quality_score=8.5,
summary="Focus on clarity and examples",
iteration_num=2,
session_id="train-abc123"
)
"""
agent_name: str
suggestions: List[str]
quality_score: float
summary: str
iteration_num: int
session_id: str
created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "TrainingProfile":
"""Create from dictionary."""
return cls(
agent_name=data["agent_name"],
suggestions=data.get("suggestions", []),
quality_score=data.get("quality_score", 0.0),
summary=data.get("summary", ""),
iteration_num=data.get("iteration_num", 0),
session_id=data.get("session_id", ""),
created_at=data.get("created_at", datetime.utcnow().isoformat()),
)
@classmethod
def from_iteration(
cls,
iteration: "TrainingIteration",
agent_name: str,
session_id: str,
) -> "TrainingProfile":
"""
Create a TrainingProfile from a TrainingIteration.
Args:
iteration: The training iteration to use
agent_name: Name of the agent
session_id: The training session ID
Returns:
TrainingProfile with data from the iteration
"""
return cls(
agent_name=agent_name,
suggestions=iteration.suggestions,
quality_score=iteration.score,
summary=iteration.feedback,
iteration_num=iteration.iteration_num,
session_id=session_id,
)
@dataclass
class TrainingScenario:
"""
@@ -190,6 +270,32 @@ class TrainingReport:
metadata=data.get("metadata", {})
)
def get_best_iteration(self) -> Optional[TrainingIteration]:
"""
Get the iteration with the highest score.
Returns:
TrainingIteration with highest score, or None if no iterations
"""
if not self.iterations:
return None
return max(self.iterations, key=lambda it: it.score)
def get_iteration(self, iteration_num: int) -> Optional[TrainingIteration]:
"""
Get a specific iteration by number.
Args:
iteration_num: The iteration number (1-based)
Returns:
TrainingIteration or None if not found
"""
for it in self.iterations:
if it.iteration_num == iteration_num:
return it
return None
def print_summary(self) -> None:
"""Print a summary of the training results."""
try:
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.2.2"
__version__ = "4.3.1"
+7 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.2.2",
"praisonaiagents>=1.3.1",
"python-dotenv>=0.19.0",
"litellm>=1.81.0",
"PyYAML>=6.0",
@@ -40,6 +40,12 @@ api = [
"uvicorn>=0.34.0",
"sse-starlette>=1.6.0"
]
os = [
"fastapi>=0.115.0",
"uvicorn>=0.34.0",
"PyJWT>=2.8.0",
"sse-starlette>=1.6.0"
]
agentops = ["agentops>=0.3.12"]
chat = [
"chainlit>=2.8.5,<=2.9.4",
@@ -0,0 +1,416 @@
"""
Unit tests for the migrate CLI feature.
Tests pattern detection and code conversion without exposing framework names.
Uses generic pattern terminology for stealth approach.
"""
import pytest
import tempfile
from pathlib import Path
class TestPatternDetection:
"""Test pattern detection from Python code."""
def test_detect_agent_class_pattern_with_role_goal_backstory(self):
"""Detect agent-like class with role, goal, backstory attributes."""
from praisonai.cli.features.migrate import PatternDetector
code = '''
class MyAgent:
role = "Researcher"
goal = "Find information"
backstory = "Expert researcher"
'''
detector = PatternDetector()
patterns = detector.detect_patterns(code)
assert len(patterns) >= 1
agent_patterns = [p for p in patterns if p.pattern_type.value == "agent"]
assert len(agent_patterns) == 1
assert agent_patterns[0].name == "MyAgent"
assert agent_patterns[0].confidence >= 0.8
def test_detect_task_class_pattern(self):
"""Detect task-like class with description, expected_output."""
from praisonai.cli.features.migrate import PatternDetector
code = '''
class ResearchTask:
description = "Research the topic"
expected_output = "A detailed report"
'''
detector = PatternDetector()
patterns = detector.detect_patterns(code)
task_patterns = [p for p in patterns if p.pattern_type.value == "task"]
assert len(task_patterns) == 1
assert task_patterns[0].name == "ResearchTask"
def test_detect_orchestrator_pattern(self):
"""Detect orchestrator-like class with agents and tasks lists."""
from praisonai.cli.features.migrate import PatternDetector
code = '''
class MyOrchestrator:
agents = [agent1, agent2]
tasks = [task1, task2]
def kickoff(self):
pass
'''
detector = PatternDetector()
patterns = detector.detect_patterns(code)
orch_patterns = [p for p in patterns if p.pattern_type.value == "orchestrator"]
assert len(orch_patterns) == 1
assert orch_patterns[0].name == "MyOrchestrator"
def test_detect_decorated_agent_function(self):
"""Detect @agent decorated functions."""
from praisonai.cli.features.migrate import PatternDetector
code = '''
@agent
def researcher():
return Agent(role="Researcher")
'''
detector = PatternDetector()
patterns = detector.detect_patterns(code)
agent_patterns = [p for p in patterns if p.pattern_type.value == "agent"]
assert len(agent_patterns) >= 1
def test_detect_pydantic_agent_model(self):
"""Detect Pydantic BaseModel with agent fields."""
from praisonai.cli.features.migrate import PatternDetector
code = '''
class AgentConfig(BaseModel):
role: str
goal: str
backstory: str = ""
'''
detector = PatternDetector()
patterns = detector.detect_patterns(code)
agent_patterns = [p for p in patterns if p.pattern_type.value == "agent"]
assert len(agent_patterns) >= 1
def test_confidence_scoring(self):
"""Test that confidence scoring works correctly."""
from praisonai.cli.features.migrate import PatternDetector
# High confidence: all agent attributes
high_conf_code = '''
class FullAgent:
role = "Role"
goal = "Goal"
backstory = "Backstory"
tools = []
'''
# Lower confidence: partial attributes
low_conf_code = '''
class PartialAgent:
role = "Role"
'''
detector = PatternDetector()
high_patterns = detector.detect_patterns(high_conf_code)
low_patterns = detector.detect_patterns(low_conf_code)
high_agent = [p for p in high_patterns if p.pattern_type.value == "agent"]
low_agent = [p for p in low_patterns if p.pattern_type.value == "agent"]
if high_agent and low_agent:
assert high_agent[0].confidence > low_agent[0].confidence
def test_no_patterns_in_regular_code(self):
"""Regular Python code should not trigger false positives."""
from praisonai.cli.features.migrate import PatternDetector
code = '''
def hello():
print("Hello world")
class Calculator:
def add(self, a, b):
return a + b
'''
detector = PatternDetector()
patterns = detector.detect_patterns(code)
# Should have no agent/task/orchestrator patterns
agent_patterns = [p for p in patterns if p.pattern_type.value in ("agent", "task", "orchestrator")]
assert len(agent_patterns) == 0
class TestCodeConversion:
"""Test code conversion to PraisonAI format."""
def test_convert_agent_pattern_to_praisonai(self):
"""Convert detected agent pattern to PraisonAI Agent."""
from praisonai.cli.features.migrate import CodeConverter
code = '''
from some_framework import Agent
agent = Agent(
role="Researcher",
goal="Find information",
backstory="Expert researcher",
tools=[search_tool]
)
'''
converter = CodeConverter()
result = converter.convert_code(code)
assert result.success
assert "from praisonaiagents import Agent" in result.converted_code
assert 'role="Researcher"' in result.converted_code
def test_convert_orchestrator_to_agent_team(self):
"""Convert orchestrator pattern to AgentTeam."""
from praisonai.cli.features.migrate import CodeConverter
code = '''
orchestrator = Orchestrator(
agents=[agent1, agent2],
tasks=[task1, task2]
)
result = orchestrator.kickoff()
'''
converter = CodeConverter()
result = converter.convert_code(code)
assert result.success
# Should convert to AgentTeam
assert "AgentTeam" in result.converted_code or "team" in result.converted_code.lower()
def test_preserve_unknown_code(self):
"""Unknown code should be preserved as-is."""
from praisonai.cli.features.migrate import CodeConverter
code = '''
def helper_function():
return "helper"
# Some comment
x = 42
'''
converter = CodeConverter()
result = converter.convert_code(code)
assert "helper_function" in result.converted_code
assert "x = 42" in result.converted_code
def test_dry_run_does_not_write(self):
"""Dry run should not write files."""
from praisonai.cli.features.migrate import CodeConverter
with tempfile.TemporaryDirectory() as tmpdir:
source = Path(tmpdir) / "source.py"
source.write_text('agent = Agent(role="Test")')
converter = CodeConverter()
result = converter.convert_file(str(source), dry_run=True)
assert result.success
# No output file should be created in dry run
output = Path(tmpdir) / "source_converted.py"
assert not output.exists()
class TestDirectoryScanning:
"""Test directory scanning for migratable files."""
def test_scan_directory_finds_python_files(self):
"""Scan directory should find all Python files."""
from praisonai.cli.features.migrate import CodeConverter
with tempfile.TemporaryDirectory() as tmpdir:
# Create test files
(Path(tmpdir) / "agents.py").write_text('role = "Test"')
(Path(tmpdir) / "tasks.py").write_text('description = "Test"')
(Path(tmpdir) / "readme.txt").write_text("Not Python")
converter = CodeConverter()
files = converter.scan_directory(tmpdir)
assert len(files) == 2
assert all(f.endswith(".py") for f in files)
def test_scan_excludes_venv_and_pycache(self):
"""Scan should exclude venv and __pycache__ directories."""
from praisonai.cli.features.migrate import CodeConverter
with tempfile.TemporaryDirectory() as tmpdir:
# Create test structure
(Path(tmpdir) / "main.py").write_text('role = "Test"')
venv = Path(tmpdir) / "venv"
venv.mkdir()
(venv / "lib.py").write_text('# venv file')
pycache = Path(tmpdir) / "__pycache__"
pycache.mkdir()
(pycache / "cache.pyc").write_text('# cache')
converter = CodeConverter()
files = converter.scan_directory(tmpdir)
assert len(files) == 1
assert "venv" not in files[0]
assert "__pycache__" not in files[0]
class TestYAMLMigration:
"""Test YAML config migration (existing functionality)."""
def test_yaml_migration_still_works(self):
"""Existing YAML migration should still work."""
from praisonai.cli.features.migrate import ConfigMigrator
with tempfile.TemporaryDirectory() as tmpdir:
source = Path(tmpdir) / "config.yaml"
source.write_text('''
agents:
- role: Researcher
goal: Find info
backstory: Expert
tasks:
- description: Research topic
expected_output: Report
agent: researcher
''')
migrator = ConfigMigrator()
result = migrator.migrate(str(source))
assert result["success"]
def test_detect_format_without_framework_names_in_output(self):
"""Format detection should work but not expose framework names in user output."""
from praisonai.cli.features.migrate import ConfigMigrator
with tempfile.TemporaryDirectory() as tmpdir:
source = Path(tmpdir) / "config.yaml"
source.write_text('''
agents:
- role: Test
tasks:
- description: Test task
''')
migrator = ConfigMigrator()
result = migrator.migrate(str(source))
# Result should succeed without exposing framework names
assert result["success"]
class TestCLIIntegration:
"""Test CLI command integration."""
def test_migrate_help_is_generic(self):
"""CLI help should use generic terminology."""
import subprocess
result = subprocess.run(
["python", "-m", "praisonai", "migrate", "--help"],
capture_output=True,
text=True,
timeout=30
)
# Help text should be generic, not mention specific competitor frameworks
help_text = result.stdout + result.stderr
# Should have help content
assert "migrate" in help_text.lower() or "usage" in help_text.lower() or result.returncode == 0
def test_migrate_dry_run_flag(self):
"""--dry-run flag should work."""
from praisonai.cli.features.migrate import handle_migrate_command
import argparse
with tempfile.TemporaryDirectory() as tmpdir:
source = Path(tmpdir) / "test.py"
source.write_text('agent = Agent(role="Test")')
# Create mock args
args = argparse.Namespace(
source=str(source),
target=None,
dry_run=True,
output=None,
from_format=None,
to_format="praisonai"
)
# Should not raise
try:
handle_migrate_command(args)
except SystemExit:
pass # CLI may exit
class TestValidation:
"""Test output validation."""
def test_converted_code_is_valid_python(self):
"""Converted code should be valid Python."""
from praisonai.cli.features.migrate import CodeConverter
import ast
code = '''
agent = Agent(
role="Researcher",
goal="Find info"
)
'''
converter = CodeConverter()
result = converter.convert_code(code)
if result.success and result.converted_code:
# Should parse without syntax errors
try:
ast.parse(result.converted_code)
except SyntaxError:
pytest.fail("Converted code has syntax errors")
def test_warnings_for_unsupported_patterns(self):
"""Unsupported patterns should generate warnings."""
from praisonai.cli.features.migrate import CodeConverter
code = '''
# Custom callback that can't be auto-converted
@custom_decorator
def special_callback():
pass
'''
converter = CodeConverter()
result = converter.convert_code(code)
# Should still succeed but may have warnings
assert result.success or len(result.warnings) > 0
class TestBackupCreation:
"""Test backup file creation."""
def test_backup_created_when_overwriting(self):
"""Backup should be created when overwriting files."""
from praisonai.cli.features.migrate import CodeConverter
with tempfile.TemporaryDirectory() as tmpdir:
source = Path(tmpdir) / "agents.py"
source.write_text('agent = Agent(role="Test")')
converter = CodeConverter()
result = converter.convert_file(
str(source),
output=str(source), # Overwrite
dry_run=False,
backup=True
)
if result.success:
backup = Path(tmpdir) / "agents.py.bak"
assert backup.exists()
+357
View File
@@ -0,0 +1,357 @@
"""
Tests for Training Hook System.
TDD: Tests written first for:
- TrainingProfile dataclass
- get_best_iteration() method
- apply_training() function
- remove_training() function
- TrainingHook class
"""
from unittest.mock import Mock, patch
class TestTrainingProfile:
"""Tests for TrainingProfile dataclass."""
def test_training_profile_creation(self):
"""Test creating a TrainingProfile."""
from praisonai.train.agents.models import TrainingProfile
profile = TrainingProfile(
agent_name="assistant",
suggestions=["Be concise", "Use examples"],
quality_score=8.5,
summary="Focus on clarity",
iteration_num=2,
session_id="train-abc123",
)
assert profile.agent_name == "assistant"
assert profile.suggestions == ["Be concise", "Use examples"]
assert profile.quality_score == 8.5
assert profile.summary == "Focus on clarity"
assert profile.iteration_num == 2
assert profile.session_id == "train-abc123"
def test_training_profile_to_dict(self):
"""Test converting TrainingProfile to dict."""
from praisonai.train.agents.models import TrainingProfile
profile = TrainingProfile(
agent_name="assistant",
suggestions=["Be concise"],
quality_score=8.0,
summary="Test summary",
iteration_num=1,
session_id="train-123",
)
d = profile.to_dict()
assert d["agent_name"] == "assistant"
assert d["suggestions"] == ["Be concise"]
assert d["quality_score"] == 8.0
def test_training_profile_from_dict(self):
"""Test creating TrainingProfile from dict."""
from praisonai.train.agents.models import TrainingProfile
data = {
"agent_name": "researcher",
"suggestions": ["Search first", "Cite sources"],
"quality_score": 9.0,
"summary": "Research focus",
"iteration_num": 3,
"session_id": "train-xyz",
}
profile = TrainingProfile.from_dict(data)
assert profile.agent_name == "researcher"
assert profile.quality_score == 9.0
assert profile.iteration_num == 3
class TestGetBestIteration:
"""Tests for get_best_iteration() method on TrainingReport."""
def test_get_best_iteration_returns_highest_score(self):
"""Test that get_best_iteration returns iteration with highest score."""
from praisonai.train.agents.models import TrainingReport, TrainingIteration
iterations = [
TrainingIteration(
iteration_num=1, scenario_id="s1", input_text="test",
output="out1", score=6.0, feedback="ok"
),
TrainingIteration(
iteration_num=2, scenario_id="s1", input_text="test",
output="out2", score=9.0, feedback="great" # Best
),
TrainingIteration(
iteration_num=3, scenario_id="s1", input_text="test",
output="out3", score=7.5, feedback="good"
),
]
report = TrainingReport(
session_id="train-test",
iterations=iterations,
total_iterations=3,
)
best = report.get_best_iteration()
assert best is not None
assert best.iteration_num == 2
assert best.score == 9.0
def test_get_best_iteration_empty_returns_none(self):
"""Test that get_best_iteration returns None for empty iterations."""
from praisonai.train.agents.models import TrainingReport
report = TrainingReport(
session_id="train-empty",
iterations=[],
total_iterations=0,
)
best = report.get_best_iteration()
assert best is None
def test_get_iteration_by_number(self):
"""Test getting a specific iteration by number."""
from praisonai.train.agents.models import TrainingReport, TrainingIteration
iterations = [
TrainingIteration(
iteration_num=1, scenario_id="s1", input_text="test",
output="out1", score=6.0, feedback="ok"
),
TrainingIteration(
iteration_num=2, scenario_id="s1", input_text="test",
output="out2", score=8.0, feedback="good"
),
]
report = TrainingReport(
session_id="train-test",
iterations=iterations,
total_iterations=2,
)
it = report.get_iteration(2)
assert it is not None
assert it.iteration_num == 2
assert it.score == 8.0
def test_get_iteration_not_found(self):
"""Test getting non-existent iteration returns None."""
from praisonai.train.agents.models import TrainingReport, TrainingIteration
iterations = [
TrainingIteration(
iteration_num=1, scenario_id="s1", input_text="test",
output="out1", score=6.0, feedback="ok"
),
]
report = TrainingReport(
session_id="train-test",
iterations=iterations,
total_iterations=1,
)
it = report.get_iteration(5)
assert it is None
class TestApplyTraining:
"""Tests for apply_training() function."""
def test_apply_training_registers_hook(self):
"""Test that apply_training registers a BEFORE_AGENT hook."""
from praisonai.train.agents.hook import apply_training
from praisonai.train.agents.models import TrainingProfile
# Create mock agent
mock_agent = Mock()
mock_agent.name = "assistant"
mock_registry = Mock()
mock_hook_runner = Mock()
mock_hook_runner.registry = mock_registry
mock_agent._hook_runner = mock_hook_runner
# Create profile
profile = TrainingProfile(
agent_name="assistant",
suggestions=["Be concise"],
quality_score=8.0,
summary="Test",
iteration_num=1,
session_id="train-123",
)
# Apply training
result = apply_training(mock_agent, profile=profile)
assert result is True
mock_registry.register_function.assert_called_once()
def test_apply_training_from_session(self):
"""Test apply_training loads from session ID."""
from praisonai.train.agents.hook import apply_training
mock_agent = Mock()
mock_agent.name = "assistant"
mock_registry = Mock()
mock_hook_runner = Mock()
mock_hook_runner.registry = mock_registry
mock_agent._hook_runner = mock_hook_runner
# Mock storage
with patch('praisonai.train.agents.hook.TrainingStorage') as MockStorage:
mock_storage = Mock()
mock_report = Mock()
mock_iteration = Mock()
mock_iteration.suggestions = ["Be helpful"]
mock_iteration.score = 8.5
mock_iteration.feedback = "Good"
mock_iteration.iteration_num = 2
mock_report.get_best_iteration.return_value = mock_iteration
mock_report.session_id = "train-abc"
mock_storage.load_report.return_value = mock_report
MockStorage.return_value = mock_storage
result = apply_training(mock_agent, session_id="train-abc")
assert result is True
class TestRemoveTraining:
"""Tests for remove_training() function."""
def test_remove_training_unregisters_hook(self):
"""Test that remove_training removes the training hook."""
from praisonai.train.agents.hook import apply_training, remove_training
from praisonai.train.agents.models import TrainingProfile
mock_agent = Mock()
mock_agent.name = "assistant"
mock_registry = Mock()
mock_hook_runner = Mock()
mock_hook_runner.registry = mock_registry
mock_agent._hook_runner = mock_hook_runner
mock_agent._training_hook_id = None
profile = TrainingProfile(
agent_name="assistant",
suggestions=["Be concise"],
quality_score=8.0,
summary="Test",
iteration_num=1,
session_id="train-123",
)
# Apply then remove
apply_training(mock_agent, profile=profile)
result = remove_training(mock_agent)
# Should attempt to unregister
assert result is True or result is False # Depends on implementation
class TestTrainingHook:
"""Tests for TrainingHook class."""
def test_training_hook_modifies_prompt(self):
"""Test that TrainingHook injects suggestions into prompt."""
from praisonai.train.agents.hook import TrainingHook
from praisonai.train.agents.models import TrainingProfile
from praisonaiagents.hooks import BeforeAgentInput
profile = TrainingProfile(
agent_name="assistant",
suggestions=["Be concise", "Use examples"],
quality_score=8.5,
summary="Focus on clarity",
iteration_num=2,
session_id="train-abc",
)
hook = TrainingHook(profile)
# Create input event
event = BeforeAgentInput(
session_id="session-1",
cwd="/tmp",
event_name="before_agent",
timestamp="2024-01-01T00:00:00",
agent_name="assistant",
prompt="Hello, how are you?",
)
result = hook(event)
# Should allow and modify prompt
assert result.decision == "allow"
assert result.modified_input is not None
assert "[TRAINING GUIDANCE]" in result.modified_input.get("prompt", "")
assert "Be concise" in result.modified_input.get("prompt", "")
assert "Use examples" in result.modified_input.get("prompt", "")
class TestCLIApplyCommand:
"""Tests for CLI apply command."""
def test_cli_apply_command_exists(self):
"""Test that apply command is registered."""
from praisonai.cli.commands.train import app
# Check command exists
command_names = [cmd.name for cmd in app.registered_commands]
assert "apply" in command_names
def test_cli_apply_requires_session_id(self):
"""Test that apply command requires session_id argument."""
from typer.testing import CliRunner
from praisonai.cli.commands.train import app
runner = CliRunner()
result = runner.invoke(app, ["apply"])
# Should fail without session_id
assert result.exit_code != 0
class TestCLIShowEnhanced:
"""Tests for enhanced CLI show command."""
def test_cli_show_displays_iterations(self):
"""Test that show command displays iteration details."""
from typer.testing import CliRunner
from praisonai.cli.commands.train import app
runner = CliRunner()
# Mock storage
with patch('praisonai.cli.commands.train.TrainingStorage') as MockStorage:
mock_storage = Mock()
mock_storage.storage_path.exists.return_value = True
# Create mock iterations
mock_iterations = [
Mock(iteration_num=1, score=6.0, feedback="ok", suggestions=["a"]),
Mock(iteration_num=2, score=9.0, feedback="great", suggestions=["b", "c"]),
]
mock_storage.load_iterations.return_value = mock_iterations
mock_storage.load_report.return_value = Mock(
session_id="train-test",
avg_score=7.5,
to_dict=lambda: {"session_id": "train-test"}
)
MockStorage.return_value = mock_storage
result = runner.invoke(app, ["show", "train-test", "--iterations"])
# Should show iteration details
assert "Iteration" in result.output or result.exit_code == 0
+15 -5
View File
@@ -3977,6 +3977,12 @@ dev = [
gradio = [
{ name = "gradio" },
]
os = [
{ name = "fastapi" },
{ name = "pyjwt" },
{ name = "sse-starlette" },
{ name = "uvicorn" },
]
realtime = [
{ name = "aiosqlite" },
{ name = "chainlit" },
@@ -4026,6 +4032,7 @@ requires-dist = [
{ name = "fastapi", marker = "extra == 'all'", specifier = ">=0.115.0" },
{ name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" },
{ name = "fastapi", marker = "extra == 'call'", specifier = ">=0.95.0" },
{ name = "fastapi", marker = "extra == 'os'", specifier = ">=0.115.0" },
{ name = "flaml", extras = ["automl"], marker = "extra == 'call'", specifier = ">=2.3.1" },
{ name = "flask", marker = "extra == 'all'", specifier = ">=3.0.0" },
{ name = "flask", marker = "extra == 'api'", specifier = ">=3.0.0" },
@@ -4047,8 +4054,9 @@ requires-dist = [
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
{ name = "praisonaiagents", specifier = ">=1.2.2" },
{ name = "praisonaiagents", specifier = ">=1.3.1" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
{ name = "pyjwt", marker = "extra == 'os'", specifier = ">=2.8.0" },
{ name = "pyngrok", marker = "extra == 'call'", specifier = ">=1.4.0" },
{ name = "pyparsing", specifier = ">=3.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
@@ -4067,6 +4075,7 @@ requires-dist = [
{ name = "sqlalchemy", marker = "extra == 'ui'", specifier = ">=2.0.36" },
{ name = "sse-starlette", marker = "extra == 'all'", specifier = ">=1.6.0" },
{ name = "sse-starlette", marker = "extra == 'api'", specifier = ">=1.6.0" },
{ name = "sse-starlette", marker = "extra == 'os'", specifier = ">=1.6.0" },
{ name = "tavily-python", marker = "extra == 'all'", specifier = "==0.5.0" },
{ name = "tavily-python", marker = "extra == 'chat'", specifier = "==0.5.0" },
{ name = "tavily-python", marker = "extra == 'code'", specifier = "==0.5.0" },
@@ -4078,13 +4087,14 @@ requires-dist = [
{ name = "uvicorn", marker = "extra == 'all'", specifier = ">=0.34.0" },
{ name = "uvicorn", marker = "extra == 'api'", specifier = ">=0.34.0" },
{ name = "uvicorn", marker = "extra == 'call'", specifier = ">=0.20.0" },
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
{ name = "websockets", marker = "extra == 'all'", specifier = ">=12.0" },
{ name = "websockets", marker = "extra == 'call'", specifier = ">=12.0" },
{ name = "websockets", marker = "extra == 'realtime'", specifier = ">=12.0" },
{ name = "yfinance", marker = "extra == 'all'", specifier = ">=0.2.44" },
{ name = "yfinance", marker = "extra == 'realtime'", specifier = ">=0.2.44" },
]
provides-extras = ["ui", "gradio", "api", "agentops", "chat", "code", "realtime", "call", "train", "crewai", "autogen", "autogen-v4", "acp", "lite", "all", "bot", "dev"]
provides-extras = ["ui", "gradio", "api", "os", "agentops", "chat", "code", "realtime", "call", "train", "crewai", "autogen", "autogen-v4", "acp", "lite", "all", "bot", "dev"]
[[package]]
name = "praisonai-tools"
@@ -4100,7 +4110,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.2.2"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -4109,9 +4119,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a5/fd/dc1591930e10108c04b21de9e620ef0910ad0d7c34ecb1a7616ce0ccc8c7/praisonaiagents-1.2.2.tar.gz", hash = "sha256:c790107dfd278a46c52a0191b77c13900f091fb5a7051195cb1926d9ca19e991", size = 1163165, upload-time = "2026-01-31T20:23:29.288Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/43/16763f409085b31beab6fef2db3f5b7c0163f3bec301d372b242b646a4e3/praisonaiagents-1.3.1.tar.gz", hash = "sha256:a43aac2595cce2360f3054ec80698b68584e9d3aa3794bdf6db92d57aa3898c0", size = 1176178, upload-time = "2026-02-02T15:02:57.2Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/21/406275ba1c6f1d7dcef710c7aa62a6fccb5cafbbb4883a39fe48715837ea/praisonaiagents-1.2.2-py3-none-any.whl", hash = "sha256:8732cdd90526b1fba0feb192c97b8c0b2387eda2827cf89d7d9867cd02aeaad8", size = 1156030, upload-time = "2026-01-31T20:23:27.414Z" },
{ url = "https://files.pythonhosted.org/packages/b7/d9/23874d35c9255983c86809f0fed7bbe37ba77a3b2d195619765eae698ce2/praisonaiagents-1.3.1-py3-none-any.whl", hash = "sha256:8142c3a6c9bf0fa74dbae37615db91ae50791c26eb7359e8b6f026f44328178b", size = 1170336, upload-time = "2026-02-02T15:02:55.128Z" },
]
[[package]]