Release v3.10.21

This commit is contained in:
Mervin Praison committed 2026-01-26 08:08:18 +00:00
1 parent 2e1ac193a5
commit fd600d81d1
65 files changed
+7130 -194

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.10.20" \
"praisonai>=3.10.21" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.10.20" \
"praisonai>=3.10.21" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.10.20" \
"praisonai>=3.10.21" \
"praisonai[ui]" \
"praisonai[crewai]"
+17 -14
View File
@@ -1,21 +1,24 @@
"""
Basic Memory Example - Agent-Centric API
Demonstrates the simplest memory usage with consolidated params.
"""
"""Basic memory example - minimal usage."""
from praisonaiagents import Agent
# Basic: Enable memory with preset
# Simple enable (uses default file-based memory)
agent = Agent(
instructions="You are a helpful assistant with memory.",
memory="sqlite", # Preset: sqlite, redis, postgres, file
memory=True,
)
# Run a simple task
response = agent.start("Remember that my favorite color is blue.")
print(response)
# With preset
agent_redis = Agent(
instructions="You are a helpful assistant.",
memory="redis", # Uses redis preset
)
# Follow-up to test memory
response = agent.start("What is my favorite color?")
print(response)
# With URL
agent_postgres = Agent(
instructions="You are a helpful assistant.",
memory="postgresql://localhost/mydb", # URL auto-detected
)
if __name__ == "__main__":
response = agent.chat("Remember that my favorite color is blue.")
print(response)
+16 -20
View File
@@ -1,26 +1,22 @@
"""
Basic Workflow Example - Agent-Centric API
Demonstrates minimal workflow with consolidated params.
"""
"""Basic workflow example with consolidated params."""
from praisonaiagents import Agent
from praisonaiagents.workflows import Workflow, WorkflowStep
# Create a simple workflow
# Create agents
writer = Agent(instructions="You are a content writer.")
editor = Agent(instructions="You are an editor.")
# Create workflow with consolidated params
workflow = Workflow(
name="simple_workflow",
output="verbose", # Workflow-level output preset
name="Content Pipeline",
steps=[
WorkflowStep(name="write", agent=writer, action="Write about {{topic}}"),
WorkflowStep(name="edit", agent=editor, action="Edit the content", context=["write"]),
],
output="verbose",
planning=True,
)
# Add a step
step = WorkflowStep(
name="writer",
action="Write a haiku about programming",
agent=Agent(instructions="You are a creative poet."),
output="result.txt", # Save output to file
)
# Note: This is a structural example - actual execution requires workflow.run()
print("Workflow created with output preset:", workflow.output)
print("Step output file:", step.output_file)
if __name__ == "__main__":
result = workflow.run(variables={"topic": "AI agents"})
print(result)
@@ -0,0 +1,391 @@
"""
Conditional Branching Example - PraisonAI Agents
This example demonstrates the `if:` pattern for conditional workflow execution:
1. Basic if/then/else branching
2. Numeric condition evaluation (>, <, >=, <=, ==, !=)
3. String condition evaluation (==, !=)
4. Contains condition (in, contains)
5. Combining if: with loop: for per-item decisions
The if: pattern enables dynamic workflow paths based on
runtime conditions and variable values.
"""
from praisonaiagents import Workflow, WorkflowContext, StepResult
from praisonaiagents.workflows import if_, loop
# =============================================================================
# Example 1: Basic If/Then/Else - Score-Based Routing
# =============================================================================
def basic_if_example():
"""
Route to different handlers based on a score threshold.
"""
print("\n" + "="*60)
print("Example 1: Basic If/Then/Else - Score Routing")
print("="*60)
def approve(ctx: WorkflowContext) -> StepResult:
score = ctx.variables.get("score", 0)
print(f" ✅ APPROVED: Score {score} meets threshold")
return StepResult(output="Application approved")
def reject(ctx: WorkflowContext) -> StepResult:
score = ctx.variables.get("score", 0)
print(f" ❌ REJECTED: Score {score} below threshold")
return StepResult(output="Application rejected")
# Test with passing score
workflow_pass = Workflow(
steps=[
if_(
condition="{{score}} >= 70",
then_steps=[approve],
else_steps=[reject]
)
],
variables={"score": 85}
)
print("\nTest 1: Score = 85")
workflow_pass.start("Evaluate application")
# Test with failing score
workflow_fail = Workflow(
steps=[
if_(
condition="{{score}} >= 70",
then_steps=[approve],
else_steps=[reject]
)
],
variables={"score": 55}
)
print("\nTest 2: Score = 55")
workflow_fail.start("Evaluate application")
# =============================================================================
# Example 2: String Condition - Status-Based Processing
# =============================================================================
def string_condition_example():
"""
Branch based on string equality.
"""
print("\n" + "="*60)
print("Example 2: String Condition - Status Processing")
print("="*60)
def process_active(ctx: WorkflowContext) -> StepResult:
print(" 🟢 Processing ACTIVE user")
return StepResult(output="Active user processed")
def process_inactive(ctx: WorkflowContext) -> StepResult:
print(" 🔴 Processing INACTIVE user")
return StepResult(output="Inactive user archived")
# Test with active status
workflow = Workflow(
steps=[
if_(
condition="{{status}} == active",
then_steps=[process_active],
else_steps=[process_inactive]
)
],
variables={"status": "active"}
)
print("\nTest: status = 'active'")
workflow.start("Process user")
# Test with inactive status
workflow2 = Workflow(
steps=[
if_(
condition="{{status}} == active",
then_steps=[process_active],
else_steps=[process_inactive]
)
],
variables={"status": "inactive"}
)
print("\nTest: status = 'inactive'")
workflow2.start("Process user")
# =============================================================================
# Example 3: Contains Condition - Content Filtering
# =============================================================================
def contains_condition_example():
"""
Branch based on whether a string contains a substring.
"""
print("\n" + "="*60)
print("Example 3: Contains Condition - Content Filtering")
print("="*60)
def flag_content(ctx: WorkflowContext) -> StepResult:
print(" 🚨 FLAGGED: Message contains sensitive content")
return StepResult(output="Content flagged for review")
def approve_content(ctx: WorkflowContext) -> StepResult:
print(" ✅ APPROVED: Message is clean")
return StepResult(output="Content approved")
# Test with flagged content
workflow1 = Workflow(
steps=[
if_(
condition="error in {{message}}",
then_steps=[flag_content],
else_steps=[approve_content]
)
],
variables={"message": "System error: connection failed"}
)
print("\nTest 1: Message contains 'error'")
workflow1.start("Filter content")
# Test with clean content
workflow2 = Workflow(
steps=[
if_(
condition="error in {{message}}",
then_steps=[flag_content],
else_steps=[approve_content]
)
],
variables={"message": "Operation completed successfully"}
)
print("\nTest 2: Message is clean")
workflow2.start("Filter content")
# =============================================================================
# Example 4: If Inside Loop - Per-Item Decisions
# =============================================================================
def if_inside_loop_example():
"""
Make decisions for each item in a collection.
Combines iteration with conditional branching.
"""
print("\n" + "="*60)
print("Example 4: If Inside Loop - Per-Item Decisions")
print("="*60)
results = {"premium": [], "standard": []}
def premium_service(ctx: WorkflowContext) -> StepResult:
customer = ctx.variables.get("customer", {})
name = customer.get("name", "Unknown")
results["premium"].append(name)
print(f" ⭐ {name}: Premium service activated")
return StepResult(output=f"Premium service for {name}")
def standard_service(ctx: WorkflowContext) -> StepResult:
customer = ctx.variables.get("customer", {})
name = customer.get("name", "Unknown")
results["standard"].append(name)
print(f" 📦 {name}: Standard service activated")
return StepResult(output=f"Standard service for {name}")
workflow = Workflow(
steps=[
loop(
steps=[
if_(
condition="{{customer.tier}} == premium",
then_steps=[premium_service],
else_steps=[standard_service]
)
],
over="customers",
var_name="customer"
)
],
variables={
"customers": [
{"name": "Alice", "tier": "premium"},
{"name": "Bob", "tier": "standard"},
{"name": "Charlie", "tier": "premium"},
{"name": "Diana", "tier": "standard"}
]
}
)
print("\nProcessing customers:")
workflow.start("Assign service tiers")
print(f"\nSummary:")
print(f" Premium customers: {results['premium']}")
print(f" Standard customers: {results['standard']}")
# =============================================================================
# Example 5: Nested Conditions - Multi-Level Decision Tree
# =============================================================================
def nested_conditions_example():
"""
Demonstrate nested if conditions for complex decision trees.
"""
print("\n" + "="*60)
print("Example 5: Nested Conditions - Decision Tree")
print("="*60)
def vip_treatment(ctx: WorkflowContext) -> StepResult:
print(" 👑 VIP Treatment: Dedicated support + Priority queue")
return StepResult(output="VIP treatment applied") # noqa: ARG001
def regular_premium(ctx: WorkflowContext) -> StepResult:
print(" ⭐ Premium: Priority support")
return StepResult(output="Premium support applied")
def basic_support(ctx: WorkflowContext) -> StepResult:
print(" 📞 Basic: Standard support queue")
return StepResult(output="Basic support applied")
# Outer condition: Is premium?
# Inner condition (if premium): Is VIP?
workflow = Workflow(
steps=[
if_(
condition="{{tier}} == premium",
then_steps=[
if_(
condition="{{spend}} > 10000",
then_steps=[vip_treatment],
else_steps=[regular_premium]
)
],
else_steps=[basic_support]
)
],
variables={"tier": "premium", "spend": 15000}
)
print("\nTest 1: Premium tier, high spend")
workflow.start("Determine support level")
workflow2 = Workflow(
steps=[
if_(
condition="{{tier}} == premium",
then_steps=[
if_(
condition="{{spend}} > 10000",
then_steps=[vip_treatment],
else_steps=[regular_premium]
)
],
else_steps=[basic_support]
)
],
variables={"tier": "premium", "spend": 5000}
)
print("\nTest 2: Premium tier, low spend")
workflow2.start("Determine support level")
workflow3 = Workflow(
steps=[
if_(
condition="{{tier}} == premium",
then_steps=[
if_(
condition="{{spend}} > 10000",
then_steps=[vip_treatment],
else_steps=[regular_premium]
)
],
else_steps=[basic_support]
)
],
variables={"tier": "basic", "spend": 20000}
)
print("\nTest 3: Basic tier (regardless of spend)")
workflow3.start("Determine support level")
# =============================================================================
# Example 6: No Else Branch - Optional Processing
# =============================================================================
def no_else_example():
"""
If condition without else branch - only execute if true.
"""
print("\n" + "="*60)
print("Example 6: No Else Branch - Optional Processing")
print("="*60)
def send_notification(ctx: WorkflowContext) -> StepResult:
print(" 📧 Notification sent!")
return StepResult(output="Notification sent")
def continue_processing(ctx: WorkflowContext) -> StepResult:
print(" ➡️ Continuing with main workflow...")
return StepResult(output="Continued")
# Only send notification if opted in
workflow = Workflow(
steps=[
if_(
condition="{{notify}} == true",
then_steps=[send_notification]
# No else_steps - just skip if false
),
continue_processing
],
variables={"notify": "true"}
)
print("\nTest 1: notify = true")
workflow.start("Process with notification")
workflow2 = Workflow(
steps=[
if_(
condition="{{notify}} == true",
then_steps=[send_notification]
),
continue_processing
],
variables={"notify": "false"}
)
print("\nTest 2: notify = false")
workflow2.start("Process without notification")
# =============================================================================
# Main - Run All Examples
# =============================================================================
if __name__ == "__main__":
print("\n" + "="*60)
print("PraisonAI Conditional Branching Examples")
print("="*60)
basic_if_example()
string_condition_example()
contains_condition_example()
if_inside_loop_example()
nested_conditions_example()
no_else_example()
print("\n" + "="*60)
print("All conditional branching examples completed!")
print("="*60)
@@ -0,0 +1,214 @@
"""Example: Merge Strategies for Multi-Agent Workflows
This example demonstrates how to use and create custom merge strategies
for combining outputs from parallel agent executions.
"""
from praisonaiagents.agents import (
MergeStrategyProtocol,
FirstWinsMerge,
ConcatMerge,
DictMerge,
)
def example_first_wins_merge():
"""Demonstrate FirstWinsMerge strategy."""
print("=" * 50)
print("FirstWinsMerge Examples")
print("=" * 50)
merge = FirstWinsMerge()
# 1. Basic usage - returns first non-None
print("\n1. Basic Usage:")
outputs = [None, "Agent 2 response", "Agent 3 response"]
result = merge.merge(outputs)
print(f" Inputs: {outputs}")
print(f" Result: {result}")
# 2. All None returns None
print("\n2. All None:")
outputs = [None, None, None]
result = merge.merge(outputs)
print(f" Inputs: {outputs}")
print(f" Result: {result}")
# 3. First value wins
print("\n3. First Value Wins:")
outputs = ["Fast agent", "Slow agent", "Slowest agent"]
result = merge.merge(outputs)
print(f" Inputs: {outputs}")
print(f" Result: {result}")
# 4. Works with any type
print("\n4. Various Types:")
print(f" Dict: {merge.merge([None, {'key': 'value'}])}")
print(f" List: {merge.merge([None, [1, 2, 3]])}")
print(f" Number: {merge.merge([None, 42])}")
def example_concat_merge():
"""Demonstrate ConcatMerge strategy."""
print("\n" + "=" * 50)
print("ConcatMerge Examples")
print("=" * 50)
# 1. Default separator (double newline)
print("\n1. Default Separator:")
merge = ConcatMerge()
outputs = ["Part 1: Introduction", "Part 2: Analysis", "Part 3: Conclusion"]
result = merge.merge(outputs)
print(f" Result:\n{result}")
# 2. Custom separator
print("\n2. Custom Separator:")
merge = ConcatMerge(separator=" | ")
outputs = ["Alice", "Bob", "Charlie"]
result = merge.merge(outputs)
print(f" Result: {result}")
# 3. Skips None values
print("\n3. Skips None:")
merge = ConcatMerge(separator=", ")
outputs = ["A", None, "C", None, "E"]
result = merge.merge(outputs)
print(f" Inputs: {outputs}")
print(f" Result: {result}")
# 4. Converts non-strings
print("\n4. Converts Non-Strings:")
merge = ConcatMerge(separator=" + ")
outputs = [1, 2, 3]
result = merge.merge(outputs)
print(f" Inputs: {outputs}")
print(f" Result: {result}")
def example_dict_merge():
"""Demonstrate DictMerge strategy."""
print("\n" + "=" * 50)
print("DictMerge Examples")
print("=" * 50)
# 1. Basic shallow merge
print("\n1. Shallow Merge (default):")
merge = DictMerge()
outputs = [
{"name": "Alice", "score": 10},
{"name": "Bob", "level": 5},
{"score": 20, "rank": 1}
]
result = merge.merge(outputs)
print(f" Inputs: {outputs}")
print(f" Result: {result}")
# 2. Later values override
print("\n2. Override Behavior:")
merge = DictMerge()
outputs = [
{"status": "pending"},
{"status": "processing"},
{"status": "complete"}
]
result = merge.merge(outputs)
print(f" Result: {result}")
# 3. Deep merge for nested dicts
print("\n3. Deep Merge:")
merge = DictMerge(deep=True)
outputs = [
{"config": {"a": 1, "b": 2}},
{"config": {"b": 3, "c": 4}},
{"config": {"d": 5}}
]
result = merge.merge(outputs)
print(f" Inputs: {outputs}")
print(f" Result: {result}")
# 4. Skips non-dict values
print("\n4. Skips Non-Dict Values:")
merge = DictMerge()
outputs = [{"a": 1}, "not a dict", None, {"b": 2}]
result = merge.merge(outputs)
print(f" Inputs: {outputs}")
print(f" Result: {result}")
def example_custom_merge():
"""Demonstrate creating custom merge strategies."""
print("\n" + "=" * 50)
print("Custom Merge Strategy Examples")
print("=" * 50)
# 1. Longest output merge
class LongestOutputMerge:
"""Returns the longest output."""
def merge(self, outputs, context=None):
valid = [o for o in outputs if o is not None]
if not valid:
return None
return max(valid, key=lambda x: len(str(x)))
print("\n1. LongestOutputMerge:")
merge = LongestOutputMerge()
outputs = ["short", "medium length", "this is the longest response"]
result = merge.merge(outputs)
print(f" Inputs: {outputs}")
print(f" Result: {result}")
# 2. Majority vote merge
class MajorityVoteMerge:
"""Returns the most common output."""
def merge(self, outputs, context=None):
from collections import Counter
valid = [o for o in outputs if o is not None]
if not valid:
return None
counts = Counter(valid)
return counts.most_common(1)[0][0]
print("\n2. MajorityVoteMerge:")
merge = MajorityVoteMerge()
outputs = ["yes", "yes", "no", "yes", "no"]
result = merge.merge(outputs)
print(f" Inputs: {outputs}")
print(f" Result: {result} (3 votes)")
# 3. Weighted merge
class WeightedMerge:
"""Merges with agent weights from context."""
def merge(self, outputs, context=None):
weights = context.get("weights", [1] * len(outputs)) if context else [1] * len(outputs)
best_idx = 0
best_weight = 0
for i, (output, weight) in enumerate(zip(outputs, weights)):
if output is not None and weight > best_weight:
best_idx = i
best_weight = weight
return outputs[best_idx] if outputs else None
print("\n3. WeightedMerge:")
merge = WeightedMerge()
outputs = ["junior answer", "senior answer", "expert answer"]
context = {"weights": [1, 5, 10]}
result = merge.merge(outputs, context=context)
print(f" Inputs: {outputs}")
print(f" Weights: {context['weights']}")
print(f" Result: {result}")
# 4. Protocol compliance
print("\n4. Protocol Compliance:")
print(f" LongestOutputMerge: {isinstance(LongestOutputMerge(), MergeStrategyProtocol)}")
print(f" MajorityVoteMerge: {isinstance(MajorityVoteMerge(), MergeStrategyProtocol)}")
print(f" WeightedMerge: {isinstance(WeightedMerge(), MergeStrategyProtocol)}")
if __name__ == "__main__":
example_first_wins_merge()
example_concat_merge()
example_dict_merge()
example_custom_merge()
print("\n" + "=" * 50)
print("All examples completed!")
print("=" * 50)
@@ -0,0 +1,174 @@
"""Example: Retry Policies and Fallback Chains
This example demonstrates how to configure retry behavior with
exponential backoff, jitter, and fallback chains.
"""
from praisonaiagents.tools import (
RetryPolicy,
FallbackChain,
ToolExecutionConfig,
)
def example_retry_policy():
"""Demonstrate RetryPolicy configuration and usage."""
print("=" * 50)
print("RetryPolicy Examples")
print("=" * 50)
# 1. Default policy
print("\n1. Default RetryPolicy:")
policy = RetryPolicy()
print(f" max_attempts: {policy.max_attempts}")
print(f" backoff_factor: {policy.backoff_factor}")
print(f" initial_delay_ms: {policy.initial_delay_ms}")
print(f" max_delay_ms: {policy.max_delay_ms}")
print(f" retry_on: {policy.retry_on}")
# 2. Exponential backoff calculation
print("\n2. Exponential Backoff (no jitter):")
policy = RetryPolicy(
initial_delay_ms=1000,
backoff_factor=2.0,
max_delay_ms=30000,
jitter=False
)
for attempt in range(5):
delay = policy.get_delay_ms(attempt)
print(f" Attempt {attempt}: delay = {delay}ms")
# 3. With jitter for production
print("\n3. With Jitter (production-ready):")
policy = RetryPolicy(
initial_delay_ms=1000,
backoff_factor=2.0,
jitter=True,
jitter_factor=0.25 # ±25%
)
print(" Delays for attempt 0 (10 samples):")
delays = [policy.get_delay_ms(0) for _ in range(10)]
print(f" {delays}")
print(f" Range: {min(delays)} - {max(delays)} (expected: 750-1250)")
# 4. should_retry logic
print("\n4. should_retry Logic:")
policy = RetryPolicy(max_attempts=3, retry_on={"timeout", "rate_limit"})
test_cases = [
("timeout", 0),
("timeout", 2),
("timeout", 3), # At max_attempts
("unknown_error", 0), # Not in retry_on
]
for error_type, attempt in test_cases:
result = policy.should_retry(error_type, attempt)
print(f" should_retry('{error_type}', {attempt}) = {result}")
# 5. Custom error types
print("\n5. Custom Error Types:")
policy = RetryPolicy(retry_on={"api_error", "network_error", "throttled"})
print(f" Retries on: {policy.retry_on}")
print(f" should_retry('api_error', 0) = {policy.should_retry('api_error', 0)}")
print(f" should_retry('timeout', 0) = {policy.should_retry('timeout', 0)}")
def example_fallback_chain():
"""Demonstrate FallbackChain configuration."""
print("\n" + "=" * 50)
print("FallbackChain Examples")
print("=" * 50)
# 1. Basic chain
print("\n1. Basic FallbackChain:")
chain = FallbackChain(
tools=["web_search", "cached_search", "default_response"]
)
print(f" Tools: {chain.tools}")
print(f" Length: {len(chain)}")
print(f" stop_on_success: {chain.stop_on_success}")
# 2. Iteration
print("\n2. Iterating over chain:")
for i, tool in enumerate(chain):
print(f" {i+1}. {tool}")
# 3. Try all (don't stop on success)
print("\n3. Try All Mode:")
chain = FallbackChain(
tools=["validator_1", "validator_2", "validator_3"],
stop_on_success=False
)
print(f" stop_on_success: {chain.stop_on_success}")
print(" (All tools will be tried regardless of success)")
def example_tool_execution_config():
"""Demonstrate ToolExecutionConfig combining retry and fallback."""
print("\n" + "=" * 50)
print("ToolExecutionConfig Examples")
print("=" * 50)
# 1. Default config
print("\n1. Default Config:")
config = ToolExecutionConfig()
print(f" retry_policy: {config.retry_policy}")
print(f" fallback_chain: {config.fallback_chain}")
print(f" timeout_ms: {config.timeout_ms}")
# 2. Using default() factory
print("\n2. Default Factory:")
config = ToolExecutionConfig.default()
print(f" retry_policy.max_attempts: {config.retry_policy.max_attempts}")
# 3. Full configuration
print("\n3. Full Configuration:")
config = ToolExecutionConfig(
retry_policy=RetryPolicy(
max_attempts=5,
backoff_factor=1.5,
initial_delay_ms=500,
jitter=True
),
fallback_chain=FallbackChain(
tools=["primary_api", "backup_api", "cache"]
),
timeout_ms=10000
)
print(f" retry_policy.max_attempts: {config.retry_policy.max_attempts}")
print(f" retry_policy.jitter: {config.retry_policy.jitter}")
print(f" fallback_chain tools: {config.fallback_chain.tools}")
print(f" timeout_ms: {config.timeout_ms}")
def example_validation():
"""Demonstrate validation of RetryPolicy parameters."""
print("\n" + "=" * 50)
print("Validation Examples")
print("=" * 50)
invalid_configs = [
("max_attempts=0", {"max_attempts": 0}),
("backoff_factor=0.5", {"backoff_factor": 0.5}),
("initial_delay_ms=-1", {"initial_delay_ms": -1}),
("max_delay < initial", {"initial_delay_ms": 1000, "max_delay_ms": 500}),
("jitter_factor=1.5", {"jitter_factor": 1.5}),
]
for name, kwargs in invalid_configs:
try:
RetryPolicy(**kwargs)
print(f"\n {name}: No error (unexpected!)")
except ValueError as e:
print(f"\n {name}:")
print(f" → ValueError: {e}")
if __name__ == "__main__":
example_retry_policy()
example_fallback_chain()
example_tool_execution_config()
example_validation()
print("\n" + "=" * 50)
print("All examples completed!")
print("=" * 50)
@@ -0,0 +1,163 @@
"""Example: Tool Validation with Custom Validators
This example demonstrates how to use ValidationResult and implement
custom validators using the ToolValidatorProtocol.
"""
from praisonaiagents.tools import (
ValidationResult,
ToolValidatorProtocol,
PassthroughValidator,
)
def example_validation_result():
"""Demonstrate ValidationResult usage."""
print("=" * 50)
print("ValidationResult Examples")
print("=" * 50)
# 1. Success factory
success = ValidationResult.success()
print(f"\n1. Success: valid={success.valid}, bool={bool(success)}")
# 2. Failure factory
failure = ValidationResult.failure(
errors=["Query cannot be empty", "Missing required field"],
remediation="Provide a non-empty query string"
)
print(f"\n2. Failure: valid={failure.valid}")
print(f" Errors: {failure.errors}")
print(f" Remediation: {failure.remediation}")
# 3. Fluent API - building incrementally
result = ValidationResult(valid=True)
result.add_warning("Query is very long, may be slow")
result.add_warning("Consider using pagination")
print(f"\n3. With warnings: valid={result.valid}, warnings={result.warnings}")
# 4. Adding errors changes validity
result.add_error("Rate limit exceeded")
print(f"\n4. After error: valid={result.valid}, errors={result.errors}")
class InputLengthValidator:
"""Custom validator that checks string input lengths."""
def __init__(self, max_length: int = 100):
self.max_length = max_length
def validate_args(self, tool_name, args, context=None):
"""Validate that string arguments don't exceed max length."""
for key, value in args.items():
if isinstance(value, str) and len(value) > self.max_length:
return ValidationResult.failure(
errors=[f"Argument '{key}' ({len(value)} chars) exceeds limit ({self.max_length})"],
remediation=f"Truncate '{key}' to {self.max_length} characters or less"
)
return ValidationResult.success()
def validate_result(self, tool_name, result, context=None):
"""Validate tool results."""
return ValidationResult.success()
class TypeValidator:
"""Custom validator that checks argument types."""
def __init__(self, type_specs: dict):
"""
Args:
type_specs: Dict mapping arg names to expected types
e.g., {"query": str, "limit": int}
"""
self.type_specs = type_specs
def validate_args(self, tool_name, args, context=None):
result = ValidationResult(valid=True)
for arg_name, expected_type in self.type_specs.items():
if arg_name in args:
actual_value = args[arg_name]
if not isinstance(actual_value, expected_type):
result.add_error(
f"Argument '{arg_name}' must be {expected_type.__name__}, "
f"got {type(actual_value).__name__}"
)
if not result.valid:
result.remediation = "Check argument types and try again"
return result
def validate_result(self, tool_name, result, context=None):
return ValidationResult.success()
def example_custom_validators():
"""Demonstrate custom validator implementations."""
print("\n" + "=" * 50)
print("Custom Validator Examples")
print("=" * 50)
# 1. InputLengthValidator
print("\n1. InputLengthValidator:")
validator = InputLengthValidator(max_length=50)
# Valid input
result = validator.validate_args("search", {"query": "short query"})
print(f" Short query: valid={result.valid}")
# Invalid input
long_query = "x" * 100
result = validator.validate_args("search", {"query": long_query})
print(f" Long query: valid={result.valid}")
print(f" Error: {result.errors[0]}")
print(f" Fix: {result.remediation}")
# 2. TypeValidator
print("\n2. TypeValidator:")
validator = TypeValidator({"query": str, "limit": int, "offset": int})
# Valid types
result = validator.validate_args("search", {"query": "test", "limit": 10})
print(f" Correct types: valid={result.valid}")
# Invalid types
result = validator.validate_args("search", {"query": 123, "limit": "ten"})
print(f" Wrong types: valid={result.valid}")
for error in result.errors:
print(f" - {error}")
# 3. Protocol compliance check
print("\n3. Protocol Compliance:")
print(f" InputLengthValidator: {isinstance(InputLengthValidator(), ToolValidatorProtocol)}")
print(f" TypeValidator: {isinstance(TypeValidator({}), ToolValidatorProtocol)}")
print(f" PassthroughValidator: {isinstance(PassthroughValidator(), ToolValidatorProtocol)}")
def example_passthrough_validator():
"""Demonstrate the default PassthroughValidator."""
print("\n" + "=" * 50)
print("PassthroughValidator Example")
print("=" * 50)
validator = PassthroughValidator()
# Always returns valid
result = validator.validate_args("any_tool", {"any": "args"})
print(f"\nvalidate_args: valid={result.valid}")
result = validator.validate_result("any_tool", {"any": "result"})
print(f"validate_result: valid={result.valid}")
print("\nPassthroughValidator is useful as a default when no validation is needed.")
if __name__ == "__main__":
example_validation_result()
example_custom_validators()
example_passthrough_validator()
print("\n" + "=" * 50)
print("All examples completed!")
print("=" * 50)
+252
View File
@@ -0,0 +1,252 @@
"""
Nested Workflows Example - PraisonAI Agents
This example demonstrates how to use nested workflow patterns:
1. Loop inside Loop (nested iteration)
2. Parallel inside Loop (concurrent processing per item)
3. Route inside Loop (conditional routing per item)
4. Depth limit protection (prevents infinite recursion)
These patterns enable complex data processing pipelines
while maintaining clean, readable workflow definitions.
"""
from praisonaiagents import Workflow, WorkflowContext, StepResult
from praisonaiagents.workflows import loop, parallel, route
# =============================================================================
# Example 1: Nested Loops - Process Matrix Data
# =============================================================================
def nested_loop_example():
"""
Process a 2D matrix using nested loops.
Outer loop iterates rows, inner loop iterates columns.
"""
print("\n" + "="*60)
print("Example 1: Nested Loops - Matrix Processing")
print("="*60)
results = []
def process_cell(ctx: WorkflowContext) -> StepResult:
row = ctx.variables.get("row", {})
col = ctx.variables.get("col", "")
row_name = row.get("name", "?")
results.append(f"{row_name}-{col}")
return StepResult(output=f"Processed cell {row_name}-{col}")
# Inner loop processes columns for each row
inner_loop = loop(
process_cell,
over="columns",
var_name="col"
)
# Outer loop iterates through rows
outer_loop = loop(
steps=[inner_loop],
over="rows",
var_name="row"
)
workflow = Workflow(
steps=[outer_loop],
variables={
"rows": [
{"name": "Row1"},
{"name": "Row2"}
],
"columns": ["A", "B", "C"]
}
)
workflow.start("Process matrix")
print(f"Processed cells: {results}")
# Output: ['Row1-A', 'Row1-B', 'Row1-C', 'Row2-A', 'Row2-B', 'Row2-C']
# =============================================================================
# Example 2: Parallel Inside Loop - Concurrent Task Processing
# =============================================================================
def parallel_inside_loop_example():
"""
For each project, run multiple analysis tasks in parallel.
Combines iteration with concurrent execution.
"""
print("\n" + "="*60)
print("Example 2: Parallel Inside Loop - Concurrent Analysis")
print("="*60)
results = []
def security_scan(ctx: WorkflowContext) -> StepResult:
project = ctx.variables.get("project", "unknown")
results.append(f"security:{project}")
return StepResult(output=f"Security scan complete for {project}")
def performance_test(ctx: WorkflowContext) -> StepResult:
project = ctx.variables.get("project", "unknown")
results.append(f"performance:{project}")
return StepResult(output=f"Performance test complete for {project}")
def code_review(ctx: WorkflowContext) -> StepResult:
project = ctx.variables.get("project", "unknown")
results.append(f"review:{project}")
return StepResult(output=f"Code review complete for {project}")
# Parallel tasks run concurrently for each project
parallel_analysis = parallel([security_scan, performance_test, code_review])
# Loop through projects, running parallel analysis for each
workflow = Workflow(
steps=[
loop(
steps=[parallel_analysis],
over="projects",
var_name="project"
)
],
variables={
"projects": ["frontend", "backend", "api"]
}
)
workflow.start("Analyze all projects")
print(f"Analysis results: {sorted(results)}")
# =============================================================================
# Example 3: Route Inside Loop - Conditional Processing Per Item
# =============================================================================
def route_inside_loop_example():
"""
Process items differently based on their type.
Each item in the loop is routed to the appropriate handler.
"""
print("\n" + "="*60)
print("Example 3: Route Inside Loop - Type-Based Processing")
print("="*60)
results = []
def process_image(ctx: WorkflowContext) -> StepResult:
item = ctx.variables.get("item", {})
results.append(f"image:{item.get('name')}")
return StepResult(output=f"Processed image: {item.get('name')}")
def process_video(ctx: WorkflowContext) -> StepResult:
item = ctx.variables.get("item", {})
results.append(f"video:{item.get('name')}")
return StepResult(output=f"Processed video: {item.get('name')}")
def process_document(ctx: WorkflowContext) -> StepResult:
item = ctx.variables.get("item", {})
results.append(f"document:{item.get('name')}")
return StepResult(output=f"Processed document: {item.get('name')}")
# Route based on item type
type_router = route({
"image": [process_image],
"video": [process_video],
"document": [process_document]
})
# First, determine the type, then route
def get_type(ctx: WorkflowContext) -> StepResult:
item = ctx.variables.get("item", {})
item_type = item.get("type", "unknown")
return StepResult(output=item_type)
workflow = Workflow(
steps=[
loop(
steps=[get_type, type_router],
over="files",
var_name="item"
)
],
variables={
"files": [
{"name": "photo.jpg", "type": "image"},
{"name": "movie.mp4", "type": "video"},
{"name": "report.pdf", "type": "document"},
{"name": "logo.png", "type": "image"}
]
}
)
workflow.start("Process all files")
print(f"Processing results: {results}")
# =============================================================================
# Example 4: Three-Level Nesting - Complex Data Pipeline
# =============================================================================
def three_level_nesting_example():
"""
Demonstrate three levels of nesting:
Department -> Team -> Member
"""
print("\n" + "="*60)
print("Example 4: Three-Level Nesting - Organization Processing")
print("="*60)
results = []
def process_member(ctx: WorkflowContext) -> StepResult:
dept = ctx.variables.get("dept", {}).get("name", "?")
team = ctx.variables.get("team", {}).get("name", "?")
member = ctx.variables.get("member", "?")
results.append(f"{dept}/{team}/{member}")
return StepResult(output=f"Processed {member}")
# Level 3: Process each member
member_loop = loop(process_member, over="members", var_name="member")
# Level 2: Process each team (contains member loop)
team_loop = loop(steps=[member_loop], over="teams", var_name="team")
# Level 1: Process each department (contains team loop)
dept_loop = loop(steps=[team_loop], over="departments", var_name="dept")
workflow = Workflow(
steps=[dept_loop],
variables={
"departments": [
{"name": "Engineering"},
{"name": "Marketing"}
],
"teams": [
{"name": "Frontend", "members": ["Alice", "Bob"]},
{"name": "Backend", "members": ["Charlie"]}
],
"members": ["Member1", "Member2"] # Simplified for demo
}
)
workflow.start("Process organization")
print(f"Processed paths: {results}")
# =============================================================================
# Main - Run All Examples
# =============================================================================
if __name__ == "__main__":
print("\n" + "="*60)
print("PraisonAI Nested Workflows Examples")
print("="*60)
nested_loop_example()
parallel_inside_loop_example()
route_inside_loop_example()
three_level_nesting_example()
print("\n" + "="*60)
print("All nested workflow examples completed!")
print("="*60)
+15 -4
View File
@@ -5,6 +5,11 @@ Demonstrates how to create custom trace sinks for PraisonAI agents.
The trace system uses a protocol-driven design - implement 3 methods
and your sink works with any agent.
Protocol: ContextTraceSinkProtocol (AGENTS.md naming: XProtocol for interfaces)
- emit(event) - Receive a trace event
- flush() - Flush any buffered events
- close() - Release resources
Examples:
1. HTTP Sink - Send events to a remote server
2. SQLite Sink - Store events in a database
@@ -14,6 +19,7 @@ Examples:
from praisonaiagents import (
Agent,
ContextTraceEmitter,
ContextTraceSinkProtocol, # Protocol for type hints (optional)
trace_context,
)
@@ -165,8 +171,14 @@ def example_with_real_agent():
Note: Requires OPENAI_API_KEY environment variable.
"""
import os
print("\n=== Real Agent with Custom Sink ===")
# Check for API key
if not os.environ.get("OPENAI_API_KEY"):
print("Skipping real agent test (no OPENAI_API_KEY)")
return
# Create custom sink
sink = ConsoleSink()
emitter = ContextTraceEmitter(sink=sink, session_id="real-agent-demo", enabled=True)
@@ -180,11 +192,10 @@ def example_with_real_agent():
)
# This chat will emit events to ConsoleSink
# Uncomment to run (requires API key):
# result = agent.chat("Say hello in 5 words or less")
# print(f"Agent response: {result}")
result = agent.chat("Say hello in 5 words or less")
print(f"\nAgent response: {result}")
print("Real agent example complete (agent.chat commented out)")
print("Real agent example complete")
if __name__ == "__main__":
+62
View File
@@ -0,0 +1,62 @@
# Nested Workflow Patterns Example
# Demonstrates: nested loops, if conditions, and combined patterns
agents:
data_processor:
role: Data Processor
goal: Process individual data items
backstory: Expert at transforming and validating data
quality_checker:
role: Quality Checker
goal: Verify data quality meets standards
backstory: Meticulous quality assurance specialist
approver:
role: Approver
goal: Approve high-quality items
backstory: Senior reviewer with approval authority
rejector:
role: Rejector
goal: Handle rejected items
backstory: Specialist in rejection workflows
workflow:
name: Nested Data Processing Pipeline
variables:
batches:
- name: batch_1
items: ["item_a", "item_b"]
- name: batch_2
items: ["item_c", "item_d"]
quality_threshold: 80
steps:
# Step 1: Process each batch with nested item loop
- loop:
over: batches
var_name: batch
steps:
# Nested loop: process each item in the batch
- loop:
over: "{{batch.items}}"
var_name: item
steps:
- agent: data_processor
action: "Process item {{item}} from {{batch.name}}"
# Step 2: Quality check with conditional branching
- agent: quality_checker
action: "Check quality of all processed items and return a score"
# Step 3: Conditional approval based on quality score
- if:
condition: "{{quality_score}} >= {{quality_threshold}}"
then:
- agent: approver
action: "Approve the batch - quality score meets threshold"
else:
- agent: rejector
action: "Reject the batch - quality score below threshold"
+630
View File
@@ -0,0 +1,630 @@
# AGENTS.md - PraisonAI SDK Comprehensive Guide
> **For AI Agents and Developers**: This document provides the complete context needed to work with the PraisonAI ecosystem, including design principles, architecture, repository structure, and implementation guidelines.
---
## 1. What is PraisonAI?
PraisonAI is a **high-performance, agentic AI framework** designed for building production-ready AI agents and multi-agent workflows.
### Core Philosophy
```
Simpler than competitors • More extensible • Faster • Agent-centric
```
| Principle | Description |
|-----------|-------------|
| **Agent-Centric** | Every design decision centers on Agents, workflows, sessions, tools, and memory |
| **Protocol-Driven Core** | Core SDK is lightweight with protocols/hooks/adapters only |
| **Minimal API** | Fewer parameters, sensible defaults, explicit overrides |
| **Performance-First** | Lazy loading, optional dependencies, no hot-path regressions |
| **Production-Ready** | Safe by default, multi-agent safe, async-safe |
---
## 2. Repository Structure
### 2.1 Canonical Paths
```
/Users/praison/praisonai-package/
├── src/
│ ├── praisonai-agents/ # Core SDK (praisonaiagents)
│ │ ├── praisonaiagents/ # Python package
│ │ ├── tests/ # Unit & integration tests
│ │ ├── examples/ # Package-level examples
│ │ └── pyproject.toml # Package config
│ │
│ ├── praisonai/ # Wrapper (praisonai)
│ │ ├── praisonai/ # CLI, integrations, heavy impls
│ │ └── pyproject.toml
│ │
│ └── praisonai-ts/ # TypeScript SDK
│ └── src/
│
├── examples/ # Main examples directory
│ ├── python/ # Python examples (primary)
│ ├── yaml/ # YAML configuration examples
│ ├── js/ # JavaScript/TypeScript examples
│ └── cookbooks/ # End-to-end recipes
│
/Users/praison/PraisonAI-tools/ # External tools/plugins
/Users/praison/PraisonAIDocs/ # Documentation (Mintlify)
└── docs/
├── agents/
├── api/
├── capabilities/
├── cli/
├── concepts/
├── features/
├── guides/
├── knowledge/
├── memory/
├── models/
├── observability/
├── rag/
├── sdk/
├── tools/
├── tutorials/
└── ... (49+ subdirectories)
```
### 2.2 Package Hierarchy
```
┌─────────────────────────────────────────────────────────────────┐
│ praisonai (Wrapper) │
│ CLI • Integrations • Heavy Implementations • Optional Deps │
├─────────────────────────────────────────────────────────────────┤
│ praisonaiagents (Core SDK) │
│ Protocols • Hooks • Adapters • Base Classes • Decorators │
├─────────────────────────────────────────────────────────────────┤
│ praisonai-tools (External) │
│ Optional Tools • Plugins • Community Extensions │
└─────────────────────────────────────────────────────────────────┘
```
---
## 3. Core SDK Architecture (praisonaiagents)
### 3.1 Key Modules
| Module | Size | Purpose |
|--------|------|---------|
| `agent/` | 1.4M | Agent class, protocols, handoff, autonomy |
| `memory/` | 984K | Memory protocols, adapters, file memory |
| `context/` | 960K | Context management, artifacts, fast context |
| `tools/` | 732K | Tool SDK, decorators, registry, protocols |
| `llm/` | 620K | LLM client, model router |
| `workflows/` | 456K | Workflow engine, patterns (Route, Parallel, Loop) |
| `knowledge/` | 436K | Knowledge/RAG protocols, vector stores |
| `hooks/` | 260K | Hook system, middleware, events |
| `eval/` | 240K | Evaluation framework |
| `rag/` | 264K | RAG protocols, retriever, reranker |
| `plugins/` | 56K | Plugin manager, registry |
| `policy/` | 64K | Policy engine |
| `bus/` | 48K | Event bus |
| `streaming/` | 56K | Streaming events, callbacks |
| `trace/` | 152K | Trace protocols, context events |
### 3.2 Protocol-First Design
Core SDK uses `typing.Protocol` for all extension points:
```
Pattern: Each major module has a protocols.py file
- Protocols define WHAT (interface contract)
- Adapters implement HOW (concrete implementation)
- Naming: XProtocol for interfaces, XAdapter for implementations
- Users can implement any protocol for custom behavior
```
**Discovery:** Find all protocols dynamically:
```bash
grep -r "class.*Protocol" praisonaiagents/ --include="*.py"
```
**Key principle:** When adding new extensibility, create a Protocol first, then implement adapters.
### 3.3 Core Dependencies
```toml
# Required (always loaded) - pyproject.toml
dependencies = [
"pydantic>=2.10.0", # Type validation
"rich", # Terminal UI
"openai>=2.0.0", # OpenAI client
"posthog>=3.0.0", # Telemetry
"aiohttp>=3.8.0" # Async HTTP
]
# Optional (lazy loaded)
[project.optional-dependencies]
memory = ["chromadb>=1.0.0", "litellm>=1.81.0"]
knowledge = ["mem0ai>=0.1.0", "chromadb>=1.0.0", "markitdown[all]>=0.1.0"]
mcp = ["mcp>=1.20.0", "fastapi>=0.115.0", "uvicorn>=0.34.0"]
llm = ["litellm>=1.81.0"]
api = ["fastapi>=0.115.0", "uvicorn>=0.34.0"]
auth = ["PyJWT>=2.8.0", "passlib[bcrypt]>=1.7.4"]
mongodb = ["pymongo>=4.6.3", "motor>=3.4.0"]
```
---
## 4. Core Engineering Principles
### 4.1 Protocol-Driven Core (MUST)
```
Core SDK (praisonaiagents) Wrapper (praisonai)
━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━
✅ Protocols ✅ Heavy implementations
✅ Hooks ✅ CLI commands
✅ Adapters ✅ External integrations
✅ Base classes ✅ Optional deps
✅ Decorators ✅ UI components
✅ Dataclasses ✅ Database adapters
❌ Heavy implementations ❌ Core logic
```
### 4.2 No Performance Impact (MUST)
```python
# ✅ CORRECT: Lazy import
def use_chromadb():
try:
import chromadb
except ImportError:
raise ImportError("Install with: pip install praisonaiagents[memory]")
# ❌ WRONG: Module-level heavy import
import chromadb # Adds 500ms+ to import time
```
**Rules:**
- No module-level imports of optional dependencies
- No heavy initialization in `__init__.py`
- Use `__getattr__` for lazy loading in package roots
- Target: < 200ms package import time
### 4.3 DRY Approach (MUST)
- Reuse existing abstractions; don't duplicate
- Refactor safely when duplication is found
- Check existing protocols before creating new ones
- Use existing EventBus instead of creating new messaging
### 4.4 Agent-Centric Design (MUST)
Every API decision must prioritize:
1. **Agents** - Core execution unit
2. **Multi-agent workflows** - Coordination patterns
3. **Sessions** - State management
4. **Tools** - Agent capabilities
5. **Memory** - Persistence and context
### 4.5 Async-Safe & Multi-Agent Safe (MUST)
```
Async-Safe:
- All I/O operations must have async variants
- Never block the event loop with sync I/O in async context
- Use asyncio primitives for coordination (not threading)
Multi-Agent Safe:
- No shared mutable global state between agents
- Each agent owns its context, memory, and session
- Cross-agent communication via explicit channels (EventBus, handoff)
- Resource isolation by default, explicit sharing when needed
```
### 4.6 Invariants (Never Break)
| Invariant | Meaning |
|-----------|---------|
| Protocol-driven core | Core SDK has NO heavy implementations |
| Lazy imports | Optional deps never imported at module level |
| Backward compatible | Public API changes require deprecation cycle |
| Safe defaults | New features are opt-in, not opt-out |
| Deterministic tests | Tests must not depend on timing or external state |
### 4.7 Concurrency Model
```
Sync/Async Boundaries:
- User-facing: Both sync and async entry points (run/start)
- Internal: Prefer async, wrap for sync callers
- Tools: Support both sync and async execution
- Hooks: Fire in the current execution context (sync or async)
Event Loop Rules:
- One event loop per thread
- Never nest event loops
- Use asyncio.run() only at top level
```
### 4.8 Error Handling & Observability
```
Error Model:
- Fail fast with clear error messages
- Include remediation hints in exceptions
- Propagate context (agent name, tool name, session ID)
- Hook points for error interception (on_error events)
Observability Hooks:
- All key operations emit events via EventBus
- Trace protocols for external observability integration
- Structured logging with correlation IDs
- Metrics collection via optional adapters
```
### 4.9 Naming Conventions
```python
# Registration/Mutation
add_X() # User-facing registration (add_hook, add_policy)
remove_X() # Unregistration
register_X() # Internal/framework registration
# Retrieval
get_X() # Single item by ID (get_tool, get_policy)
search_X() # Multiple by query (search_memory)
list_X() # All items (list_tools, list_plugins)
has_X() # Existence check
# Persistence
save() / load() # Disk persistence (NOT store)
# Configuration
class XConfig: # Configuration dataclass (MemoryConfig, HooksConfig)
# Protocols
class XProtocol(Protocol): # Abstract interface
class XAdapter: # Implementation
# Execution
run() # Synchronous, blocking
start() # Async/long-running
execute() # Internal/low-level
```
---
## 5. API Philosophy
### 5.1 Simple API (User-Facing)
```python
# Basic usage - 3 lines
from praisonaiagents import Agent
agent = Agent(name="assistant", instructions="Be helpful")
response = agent.start("Hello!")
# With tools - still simple
from praisonaiagents import Agent, tool
@tool
def search(query: str) -> list:
"""Search the web."""
return [{"result": query}]
agent = Agent(name="researcher", tools=[search])
```
### 5.2 Progressive Disclosure
```python
# Level 1: Minimal (most users)
agent = Agent(name="assistant")
# Level 2: With common options
agent = Agent(
name="assistant",
llm="gpt-4o-mini",
memory=True
)
# Level 3: Full control (power users)
from praisonaiagents import Agent, MemoryConfig, HooksConfig
agent = Agent(
name="assistant",
llm="gpt-4o-mini",
memory=MemoryConfig(provider="chroma", use_long_term=True),
hooks=HooksConfig(before_tool=[my_validator]),
guardrail=my_guardrail
)
```
---
## 6. Extension Points
### 6.1 Tools
```python
# Decorator style (simplest)
from praisonaiagents import tool
@tool
def my_tool(query: str) -> str:
"""Tool description."""
return f"Result: {query}"
# Class style (more control)
from praisonaiagents.tools import BaseTool
class MyTool(BaseTool):
name = "my_tool"
description = "Does something"
def run(self, query: str) -> str:
return f"Result: {query}"
```
**Base files:**
- `praisonaiagents/tools/base.py` - BaseTool class
- `praisonaiagents/tools/decorator.py` - @tool decorator
- `praisonaiagents/tools/registry.py` - Tool registry
### 6.2 Hooks & Middleware
```python
from praisonaiagents.hooks import add_hook, before_tool, after_tool
@before_tool
def validate_args(event):
print(f"Calling: {event.tool_name}")
@after_tool
def log_result(event):
print(f"Result: {event.result}")
# Or functional style
hook_id = add_hook("before_tool", my_handler)
```
### 6.3 Memory Adapters
```python
from praisonaiagents.memory.protocols import MemoryProtocol
class MyMemoryAdapter: # Implements MemoryProtocol
def store_short_term(self, text, metadata=None):
...
def search_short_term(self, query, limit=5):
...
```
### 6.4 Database Adapters
```python
# Base files in praisonaiagents/db/
from praisonaiagents.db import DbAdapter, AsyncDbAdapter
class MyDbAdapter(DbAdapter):
def connect(self): ...
def query(self, sql, params): ...
```
---
## 7. CLI Representation
Every feature must have CLI parity:
```bash
# Basic agent
praisonai agent run --name "assistant" --prompt "Hello"
# With tools
praisonai agent run --tools web_search --prompt "Search for Python"
# Multi-agent workflow
praisonai workflow run --file workflow.yaml
# Server mode
praisonai serve --port 8000
```
---
## 8. Documentation Standards
### 8.1 Mintlify Components
Use these Mintlify components for docs:
- `<Accordion>` / `<AccordionGroup>` - Collapsible content
- `<Card>` / `<CardGroup>` / `<Columns>` - Visual containers
- `<CodeGroup>` - Tabbed code blocks
- `<Steps>` / `<Step>` - Sequential instructions
- `<Tabs>` / `<Tab>` - Tabbed content
- `<Note>`, `<Warning>`, `<Info>`, `<Tip>` - Callouts
- `<Frame>` - Image containers
- `<Icon>` - Inline icons
- `<Tooltip>` - Hover text
### 8.2 Mermaid Diagram Standards
```
Two-color scheme:
- Dark Red (#8B0000) - Agents, inputs, outputs
- Teal/Cyan (#189AB4) - Tools
- White text (#fff) for contrast
```
```mermaid
graph LR
A[Input]:::agent --> B[Agent]:::agent
B --> C[Tool]:::tool
C --> D[Output]:::agent
classDef agent fill:#8B0000,color:#fff
classDef tool fill:#189AB4,color:#fff
```
### 8.3 Documentation Principles
| Principle | Description |
|-----------|-------------|
| **Beginner-friendly** | Non-developers should understand core concepts |
| **Copy-paste success** | Code examples must run with minimal setup |
| **Less text, more interaction** | Prefer components over long paragraphs |
| **Show, don't tell** | Use diagrams, tabs, and accordions to explain |
| **Few lines, big impact** | Examples should feel like "only 3 lines to do this" |
### 8.4 Page Title Conventions
```
SDK docs: "Memory Module", "Tools Module", "Hooks Module"
API docs: "Agent API", "Tool API", "Memory API"
Guides: "Single Agent Guide", "Multi-Agent Guide"
Tutorials: "Build a Research Agent", "Create Custom Tools"
```
### 8.5 Example Quality Standards
```
Every code example MUST:
- Run without modification (copy-paste success)
- Include necessary imports
- Use realistic but simple data
- Show expected output in comments
- Be the SHORTEST way to accomplish the task
```
---
## 9. Testing Standards
### 9.1 TDD Mandatory
```python
# 1. Write failing test first
def test_new_feature():
result = new_feature()
assert result == expected
# 2. Implement feature
# 3. Verify test passes
```
### 9.2 Test Structure
```
tests/
├── unit/ # Fast, isolated tests
├── integration/ # Cross-module tests
├── e2e/ # End-to-end tests
└── fixtures/ # Test data
```
### 9.3 Run Tests
```bash
# All tests
pytest tests/
# Specific category
pytest tests/unit/
# With coverage
pytest --cov=praisonaiagents tests/
```
---
## 10. Implementation Checklist
For every feature/change:
- [ ] **Protocol-first**: Add protocol to core if needed
- [ ] **No new deps**: Use optional dependencies only
- [ ] **Lazy imports**: Heavy deps imported inside functions
- [ ] **Naming**: Follow conventions (add_*, get_*, XConfig)
- [ ] **Tests**: TDD - write failing tests first
- [ ] **CLI**: Add corresponding CLI command/option
- [ ] **Docs**: Update Mintlify documentation
- [ ] **Examples**: Add to examples/ directory
- [ ] **Multi-agent safe**: No shared mutable state
- [ ] **Async-safe**: Support async/await patterns
- [ ] **Performance**: No import-time or hot-path regressions
---
## 11. Quick Reference
### 11.1 Core Imports
```python
# Most common
from praisonaiagents import Agent, Agents, Task, tool, Tools
# Configuration
from praisonaiagents import (
MemoryConfig, KnowledgeConfig, PlanningConfig,
OutputConfig, ExecutionConfig, HooksConfig
)
# Hooks
from praisonaiagents.hooks import add_hook, before_tool, after_tool
# Event Bus
from praisonaiagents.bus import EventBus, get_default_bus
# Memory
from praisonaiagents import Memory, Session
# Workflows
from praisonaiagents import Workflow, Route, Parallel, Loop
```
### 11.2 File Locations
| What | Where |
|------|-------|
| Agent class | `praisonaiagents/agent/agent.py` |
| Tool decorator | `praisonaiagents/tools/decorator.py` |
| Base tool | `praisonaiagents/tools/base.py` |
| Hook system | `praisonaiagents/hooks/` |
| Memory protocols | `praisonaiagents/memory/protocols.py` |
| Event bus | `praisonaiagents/bus/bus.py` |
| Workflow engine | `praisonaiagents/workflows/workflows.py` |
| Policy engine | `praisonaiagents/policy/engine.py` |
---
## 12. Design Goals
> Make PraisonAI the **best agent framework in the world**
1. **Simpler** than competitors (fewer concepts, cleaner API)
2. **More extensible** (protocol-driven, plugin-ready)
3. **Faster** (lazy loading, minimal overhead)
4. **Safer** (guardrails, policies, HITL by default)
5. **Production-ready** (observability, checkpoints, replay)
---
## 13. Paid Value Path
The core remains **free and open source**. Clear upgrade paths to paid value:
| Free (Core) | Paid (Cloud/Services) |
|-------------|----------------------|
| All protocols | Managed hosting |
| All base classes | Enterprise support |
| Local execution | Budget management |
| File-based memory | Multi-tenant RBAC |
| Basic observability | Advanced analytics |
| Community support | SLA-backed support |
---
*This document is the source of truth for the PraisonAI SDK architecture and design principles.*
@@ -1,24 +0,0 @@
"""Basic memory example - minimal usage."""
from praisonaiagents import Agent
# Simple enable (uses default file-based memory)
agent = Agent(
instructions="You are a helpful assistant with memory.",
memory=True,
)
# With preset
agent_redis = Agent(
instructions="You are a helpful assistant.",
memory="redis", # Uses redis preset
)
# With URL
agent_postgres = Agent(
instructions="You are a helpful assistant.",
memory="postgresql://localhost/mydb", # URL auto-detected
)
if __name__ == "__main__":
response = agent.chat("Remember that my favorite color is blue.")
print(response)
@@ -1,22 +0,0 @@
"""Basic workflow example with consolidated params."""
from praisonaiagents import Agent
from praisonaiagents.workflows import Workflow, WorkflowStep
# Create agents
writer = Agent(instructions="You are a content writer.")
editor = Agent(instructions="You are an editor.")
# Create workflow with consolidated params
workflow = Workflow(
name="Content Pipeline",
steps=[
WorkflowStep(name="write", agent=writer, action="Write about {{topic}}"),
WorkflowStep(name="edit", agent=editor, action="Edit the content", context=["write"]),
],
output="verbose",
planning=True,
)
if __name__ == "__main__":
result = workflow.run(variables={"topic": "AI agents"})
print(result)
@@ -269,8 +269,11 @@ _LAZY_IMPORTS = {
'READ_ONLY_TOOLS': ('praisonaiagents.planning', 'READ_ONLY_TOOLS'),
'RESTRICTED_TOOLS': ('praisonaiagents.planning', 'RESTRICTED_TOOLS'),
# Trace (protocol-driven, for custom sinks)
'ContextTraceSink': ('praisonaiagents.trace', 'ContextTraceSink'),
# Trace (protocol-driven, for custom sinks) - AGENTS.md naming: XProtocol
'ContextTraceSinkProtocol': ('praisonaiagents.trace', 'ContextTraceSinkProtocol'),
'ContextTraceSink': ('praisonaiagents.trace', 'ContextTraceSink'), # Backward compat alias
'TraceSinkProtocol': ('praisonaiagents.trace', 'TraceSinkProtocol'),
'TraceSink': ('praisonaiagents.trace', 'TraceSink'), # Backward compat alias
'ContextTraceEmitter': ('praisonaiagents.trace', 'ContextTraceEmitter'),
'ContextEvent': ('praisonaiagents.trace', 'ContextEvent'),
'ContextEventType': ('praisonaiagents.trace', 'ContextEventType'),
@@ -2,5 +2,10 @@
from .agents import Agents, PraisonAIAgents
from .autoagents import AutoAgents
from .auto_rag_agent import AutoRagAgent, AutoRagConfig, RetrievalPolicy
from .protocols import MergeStrategyProtocol, FirstWinsMerge, ConcatMerge, DictMerge
__all__ = ['Agents', 'PraisonAIAgents', 'AutoAgents', 'AutoRagAgent', 'AutoRagConfig', 'RetrievalPolicy']
__all__ = [
'Agents', 'PraisonAIAgents', 'AutoAgents',
'AutoRagAgent', 'AutoRagConfig', 'RetrievalPolicy',
'MergeStrategyProtocol', 'FirstWinsMerge', 'ConcatMerge', 'DictMerge',
]
@@ -0,0 +1,115 @@
"""Agent protocols for extensibility."""
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
@runtime_checkable
class MergeStrategyProtocol(Protocol):
"""Protocol for merging outputs from multiple agents.
Used in parallel execution workflows where multiple agents
produce outputs that need to be combined.
Example:
class CustomMerge:
def merge(self, outputs, context=None):
return max(outputs, key=len) # Return longest output
# Check protocol compliance
assert isinstance(CustomMerge(), MergeStrategyProtocol)
"""
def merge(
self,
outputs: List[Any],
context: Optional[Dict[str, Any]] = None
) -> Any:
"""Merge multiple agent outputs into one.
Args:
outputs: List of outputs from agents
context: Optional context including agent names, etc.
Returns:
Merged output
"""
...
class FirstWinsMerge:
"""Returns the first non-None output."""
def merge(
self,
outputs: List[Any],
context: Optional[Dict[str, Any]] = None
) -> Any:
for output in outputs:
if output is not None:
return output
return None
class ConcatMerge:
"""Concatenates all string outputs with a separator."""
def __init__(self, separator: str = "\n\n"):
self.separator = separator
def merge(
self,
outputs: List[Any],
context: Optional[Dict[str, Any]] = None
) -> str:
string_outputs = [str(o) for o in outputs if o is not None]
return self.separator.join(string_outputs)
class DictMerge:
"""Merges dictionary outputs by combining keys.
Useful when agents return structured data that needs to be combined.
Later values override earlier ones for duplicate keys.
Example:
merge = DictMerge()
result = merge.merge([
{"name": "Alice", "score": 10},
{"name": "Bob", "level": 5}
])
# Result: {"name": "Bob", "score": 10, "level": 5}
"""
def __init__(self, deep: bool = False):
"""Initialize DictMerge.
Args:
deep: If True, recursively merge nested dicts
"""
self.deep = deep
def merge(
self,
outputs: List[Any],
context: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
result: Dict[str, Any] = {}
for output in outputs:
if output is None:
continue
if not isinstance(output, dict):
continue
if self.deep:
result = self._deep_merge(result, output)
else:
result.update(output)
return result
def _deep_merge(self, base: Dict, update: Dict) -> Dict:
"""Recursively merge dictionaries."""
result = base.copy()
for key, value in update.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = self._deep_merge(result[key], value)
else:
result[key] = value
return result
@@ -44,6 +44,12 @@ __all__ = [
"ScoredResultProtocol",
"AsyncGraderProtocol",
"parse_score_reasoning",
# Eval package types
"EvalCase",
"EvalResult",
"EvalReport",
"EvalPackage",
"EvalRunnerProtocol",
]
_LAZY_IMPORTS = {
@@ -70,6 +76,12 @@ _LAZY_IMPORTS = {
"ScoredResultProtocol": ("protocols", "ScoredResultProtocol"),
"AsyncGraderProtocol": ("protocols", "AsyncGraderProtocol"),
"parse_score_reasoning": ("grader", "parse_score_reasoning"),
# Eval package types
"EvalCase": ("package", "EvalCase"),
"EvalResult": ("package", "EvalResult"),
"EvalReport": ("package", "EvalReport"),
"EvalPackage": ("package", "EvalPackage"),
"EvalRunnerProtocol": ("package", "EvalRunnerProtocol"),
}
@@ -0,0 +1,203 @@
"""Evaluation package and case definitions."""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Protocol
@dataclass
class EvalCase:
"""A single evaluation case.
Example:
case = EvalCase(
name="math_addition",
input="What is 2 + 2?",
expected="4",
criteria=["answer_correct", "response_concise"]
)
"""
name: str
input: str
expected: Optional[str] = None
criteria: List[str] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
timeout_seconds: float = 30.0
def __post_init__(self):
if not self.name:
raise ValueError("EvalCase must have a name")
if not self.input:
raise ValueError("EvalCase must have an input")
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"name": self.name,
"input": self.input,
"expected": self.expected,
"criteria": self.criteria,
"metadata": self.metadata,
"timeout_seconds": self.timeout_seconds,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "EvalCase":
"""Create from dictionary."""
return cls(
name=data["name"],
input=data["input"],
expected=data.get("expected"),
criteria=data.get("criteria", []),
metadata=data.get("metadata", {}),
timeout_seconds=data.get("timeout_seconds", 30.0),
)
@dataclass
class EvalResult:
"""Result of running a single eval case."""
case_name: str
passed: bool
score: float # 0.0 to 1.0
actual_output: Optional[str] = None
error: Optional[str] = None
latency_ms: float = 0.0
criteria_scores: Dict[str, float] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"case_name": self.case_name,
"passed": self.passed,
"score": self.score,
"actual_output": self.actual_output,
"error": self.error,
"latency_ms": self.latency_ms,
"criteria_scores": self.criteria_scores,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "EvalResult":
"""Create from dictionary."""
return cls(
case_name=data["case_name"],
passed=data["passed"],
score=data["score"],
actual_output=data.get("actual_output"),
error=data.get("error"),
latency_ms=data.get("latency_ms", 0.0),
criteria_scores=data.get("criteria_scores", {}),
)
@dataclass
class EvalReport:
"""Aggregated report from running an eval package."""
package_name: str
total_cases: int
passed_cases: int
failed_cases: int
average_score: float
results: List[EvalResult] = field(default_factory=list)
thresholds_met: Dict[str, bool] = field(default_factory=dict)
@property
def pass_rate(self) -> float:
if self.total_cases == 0:
return 0.0
return self.passed_cases / self.total_cases
@property
def all_passed(self) -> bool:
return self.failed_cases == 0
def to_dict(self) -> Dict[str, Any]:
return {
"package_name": self.package_name,
"total_cases": self.total_cases,
"passed_cases": self.passed_cases,
"failed_cases": self.failed_cases,
"average_score": self.average_score,
"pass_rate": self.pass_rate,
"thresholds_met": self.thresholds_met,
"results": [r.to_dict() for r in self.results],
}
@dataclass
class EvalPackage:
"""A distributable evaluation package.
Example:
package = EvalPackage(
name="math_eval",
description="Basic math evaluation",
cases=[case1, case2, case3],
thresholds={"accuracy": 0.9, "latency_p95_ms": 1000}
)
"""
name: str
description: str = ""
version: str = "1.0.0"
cases: List[EvalCase] = field(default_factory=list)
thresholds: Dict[str, float] = field(default_factory=dict)
seed: Optional[int] = None # For deterministic runs
def __post_init__(self):
if not self.name:
raise ValueError("EvalPackage must have a name")
def add_case(self, case: EvalCase) -> None:
"""Add an eval case to the package."""
self.cases.append(case)
def add_cases(self, cases: List[EvalCase]) -> None:
"""Add multiple eval cases."""
self.cases.extend(cases)
def __len__(self) -> int:
return len(self.cases)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"name": self.name,
"description": self.description,
"version": self.version,
"cases": [c.to_dict() for c in self.cases],
"thresholds": self.thresholds,
"seed": self.seed,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "EvalPackage":
"""Create from dictionary."""
cases = [EvalCase.from_dict(c) for c in data.get("cases", [])]
return cls(
name=data["name"],
description=data.get("description", ""),
version=data.get("version", "1.0.0"),
cases=cases,
thresholds=data.get("thresholds", {}),
seed=data.get("seed"),
)
class EvalRunnerProtocol(Protocol):
"""Protocol for eval package runners."""
def run(
self,
package: EvalPackage,
agent: Any,
**kwargs
) -> EvalReport:
"""Run an eval package against an agent."""
...
async def run_async(
self,
package: EvalPackage,
agent: Any,
**kwargs
) -> EvalReport:
"""Async version of run."""
...
@@ -206,6 +206,52 @@ class PolicyEngine:
for policy_data in data.get("policies", []):
policy = Policy.from_dict(policy_data)
self.add_policy(policy)
def load_from_yaml(self, filepath: str) -> None:
"""Load policies from a YAML file.
Args:
filepath: Path to YAML policy file
Raises:
ImportError: If PyYAML is not installed
FileNotFoundError: If the file doesn't exist
ValueError: If the YAML is malformed or invalid
Example YAML format:
policies:
- name: deny_shell
priority: 100
rules:
- match: "tool:shell_*"
action: deny
reason: "Shell tools disabled"
"""
try:
import yaml
except ImportError:
raise ImportError(
"PyYAML is required for YAML policy loading. "
"Install with: pip install pyyaml"
)
import os
if not os.path.exists(filepath):
raise FileNotFoundError(f"Policy file not found: {filepath}")
try:
with open(filepath, 'r') as f:
data = yaml.safe_load(f)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML in policy file: {e}")
if data is None:
raise ValueError("Policy file is empty")
if not isinstance(data, dict):
raise ValueError("Policy file must contain a YAML dictionary")
self.load_from_dict(data)
# Convenience functions for common policies
@@ -14,6 +14,15 @@ from .tools import Tools
# Export Injected type directly for easy access
from .injected import Injected, AgentState
# Export validation and retry protocols
from .validators import (
ValidationResult,
ToolValidatorProtocol,
AsyncToolValidatorProtocol,
PassthroughValidator,
)
from .retry import RetryPolicy, FallbackChain, ToolExecutionConfig
# Map of function names to their module and class (if any)
TOOL_MAPPINGS = {
# Direct functions
@@ -183,9 +192,12 @@ def __getattr__(name: str) -> Any:
return method
__all__ = list(TOOL_MAPPINGS.keys()) + [
'Injected',
'Injected', 'AgentState',
'BaseTool', 'ToolResult', 'ToolValidationError', 'validate_tool',
'tool', 'FunctionTool',
'get_registry', 'register_tool', 'get_tool', 'add_tool', 'has_tool', 'remove_tool', 'list_tools', 'ToolRegistry',
'Tools'
'Tools',
# Validation and retry protocols
'ValidationResult', 'ToolValidatorProtocol', 'AsyncToolValidatorProtocol', 'PassthroughValidator',
'RetryPolicy', 'FallbackChain', 'ToolExecutionConfig',
]
@@ -0,0 +1,90 @@
"""Retry policy and fallback configuration for tools."""
import random
from dataclasses import dataclass, field
from typing import List, Optional, Set
@dataclass
class RetryPolicy:
"""Configuration for tool retry behavior with exponential backoff and jitter.
Example:
policy = RetryPolicy(
max_attempts=3,
backoff_factor=2.0,
retry_on={"timeout", "rate_limit"}
)
# With jitter for production use
policy = RetryPolicy(jitter=True, jitter_factor=0.25)
"""
max_attempts: int = 3
backoff_factor: float = 2.0
initial_delay_ms: int = 1000
max_delay_ms: int = 30000
retry_on: Set[str] = field(default_factory=lambda: {"timeout", "rate_limit", "connection_error"})
jitter: bool = False
jitter_factor: float = 0.25 # ±25% randomization
def __post_init__(self):
"""Validate configuration values."""
if self.max_attempts < 1:
raise ValueError("max_attempts must be >= 1")
if self.backoff_factor < 1.0:
raise ValueError("backoff_factor must be >= 1.0")
if self.initial_delay_ms < 0:
raise ValueError("initial_delay_ms must be >= 0")
if self.max_delay_ms < self.initial_delay_ms:
raise ValueError("max_delay_ms must be >= initial_delay_ms")
if not 0.0 <= self.jitter_factor <= 1.0:
raise ValueError("jitter_factor must be between 0.0 and 1.0")
def should_retry(self, error_type: str, attempt: int) -> bool:
"""Check if should retry given error type and attempt number."""
if attempt >= self.max_attempts:
return False
return error_type in self.retry_on
def get_delay_ms(self, attempt: int) -> int:
"""Calculate delay for given attempt (exponential backoff with optional jitter)."""
delay = self.initial_delay_ms * (self.backoff_factor ** attempt)
delay = min(delay, self.max_delay_ms)
if self.jitter:
# Add random jitter: delay * (1 ± jitter_factor)
jitter_range = delay * self.jitter_factor
delay = delay + random.uniform(-jitter_range, jitter_range)
delay = max(0, delay) # Ensure non-negative
return int(delay)
@dataclass
class FallbackChain:
"""Chain of fallback tools to try if primary fails.
Example:
chain = FallbackChain(
tools=["web_search", "cached_search", "default_response"]
)
"""
tools: List[str] = field(default_factory=list)
stop_on_success: bool = True
def __iter__(self):
return iter(self.tools)
def __len__(self) -> int:
return len(self.tools)
@dataclass
class ToolExecutionConfig:
"""Combined execution configuration for a tool."""
retry_policy: Optional[RetryPolicy] = None
fallback_chain: Optional[FallbackChain] = None
timeout_ms: Optional[int] = None
@classmethod
def default(cls) -> "ToolExecutionConfig":
return cls(retry_policy=RetryPolicy())
@@ -0,0 +1,124 @@
"""Tool validation protocols and types."""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
@dataclass
class ValidationResult:
"""Result of a validation check.
Example:
result = ValidationResult(valid=False, errors=["Invalid input"])
if not result:
print(f"Validation failed: {result.errors}")
"""
valid: bool
errors: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
remediation: Optional[str] = None
def __bool__(self) -> bool:
return self.valid
@classmethod
def success(cls) -> "ValidationResult":
"""Create a successful validation result."""
return cls(valid=True)
@classmethod
def failure(cls, errors: List[str], remediation: Optional[str] = None) -> "ValidationResult":
"""Create a failed validation result."""
return cls(valid=False, errors=errors, remediation=remediation)
def add_error(self, error: str) -> "ValidationResult":
"""Add an error and mark as invalid."""
self.errors.append(error)
self.valid = False
return self
def add_warning(self, warning: str) -> "ValidationResult":
"""Add a warning (doesn't affect validity)."""
self.warnings.append(warning)
return self
@runtime_checkable
class ToolValidatorProtocol(Protocol):
"""Protocol for tool argument and result validators."""
def validate_args(
self,
tool_name: str,
args: Dict[str, Any],
context: Optional[Dict[str, Any]] = None
) -> ValidationResult:
"""Validate tool arguments before execution.
Args:
tool_name: Name of the tool being called
args: Arguments to validate
context: Optional execution context
Returns:
ValidationResult with valid=True if args are valid
"""
...
def validate_result(
self,
tool_name: str,
result: Any,
context: Optional[Dict[str, Any]] = None
) -> ValidationResult:
"""Validate tool result after execution.
Args:
tool_name: Name of the tool that was called
result: Result to validate
context: Optional execution context
Returns:
ValidationResult with valid=True if result is valid
"""
...
@runtime_checkable
class AsyncToolValidatorProtocol(Protocol):
"""Async version of tool validator."""
async def validate_args(
self,
tool_name: str,
args: Dict[str, Any],
context: Optional[Dict[str, Any]] = None
) -> ValidationResult:
...
async def validate_result(
self,
tool_name: str,
result: Any,
context: Optional[Dict[str, Any]] = None
) -> ValidationResult:
...
class PassthroughValidator:
"""Validator that always passes. Default when no validator configured."""
def validate_args(
self,
tool_name: str,
args: Dict[str, Any],
context: Optional[Dict[str, Any]] = None
) -> ValidationResult:
return ValidationResult(valid=True)
def validate_result(
self,
tool_name: str,
result: Any,
context: Optional[Dict[str, Any]] = None
) -> ValidationResult:
return ValidationResult(valid=True)
@@ -35,8 +35,9 @@ __all__ = [
"ActionEvent",
"ActionEventType",
"ActionTraceConfig",
# Sinks
"TraceSink",
# Sink protocols (AGENTS.md naming: XProtocol)
"TraceSinkProtocol",
"TraceSink", # Backward compat alias
"NoOpSink",
"ListSink",
# Emitter
@@ -47,7 +48,8 @@ __all__ = [
# Context events (for replay)
"ContextEvent",
"ContextEventType",
"ContextTraceSink",
"ContextTraceSinkProtocol",
"ContextTraceSink", # Backward compat alias
"ContextNoOpSink",
"ContextListSink",
"ContextTraceEmitter",
@@ -66,8 +68,8 @@ def __getattr__(name: str):
from .protocol import ActionEvent, ActionEventType, ActionTraceConfig
return locals()[name]
if name in ("TraceSink", "NoOpSink", "ListSink"):
from .protocol import TraceSink, NoOpSink, ListSink
if name in ("TraceSinkProtocol", "TraceSink", "NoOpSink", "ListSink"):
from .protocol import TraceSinkProtocol, TraceSink, NoOpSink, ListSink
return locals()[name]
if name == "TraceEmitter":
@@ -83,8 +85,8 @@ def __getattr__(name: str):
from .context_events import ContextEvent, ContextEventType
return locals()[name]
if name in ("ContextTraceSink", "ContextNoOpSink", "ContextListSink"):
from .context_events import ContextTraceSink, ContextNoOpSink, ContextListSink
if name in ("ContextTraceSinkProtocol", "ContextTraceSink", "ContextNoOpSink", "ContextListSink"):
from .context_events import ContextTraceSinkProtocol, ContextTraceSink, ContextNoOpSink, ContextListSink
return locals()[name]
if name == "ContextTraceEmitter":
@@ -258,12 +258,14 @@ class ContextEvent:
@runtime_checkable
class ContextTraceSink(Protocol):
class ContextTraceSinkProtocol(Protocol):
"""
Protocol for context trace event sinks.
Implementations receive context events and handle them
(e.g., write to file, collect in memory, send to server).
Naming follows AGENTS.md convention: XProtocol for interfaces.
"""
def emit(self, event: ContextEvent) -> None:
@@ -279,6 +281,10 @@ class ContextTraceSink(Protocol):
...
# Backward compatibility alias (deprecated)
ContextTraceSink = ContextTraceSinkProtocol
class ContextNoOpSink:
"""
No-operation sink that discards all events.
@@ -85,12 +85,14 @@ class ActionEvent:
@runtime_checkable
class TraceSink(Protocol):
class TraceSinkProtocol(Protocol):
"""
Protocol for trace event sinks.
Implementations receive action events and handle them
(e.g., print to stdout, write to file, send to server).
Naming follows AGENTS.md convention: XProtocol for interfaces.
"""
def emit(self, event: ActionEvent) -> None:
@@ -106,6 +108,10 @@ class TraceSink(Protocol):
...
# Backward compatibility alias (deprecated)
TraceSink = TraceSinkProtocol
class NoOpSink:
"""
No-operation sink that discards all events.
@@ -19,6 +19,7 @@ from .workflows import (
Loop,
Repeat,
Include,
If,
# Convenience functions
route,
@@ -26,6 +27,10 @@ from .workflows import (
loop,
repeat,
include,
if_,
# Constants
MAX_NESTING_DEPTH,
# Backward compatibility aliases
StepInput,
@@ -75,11 +80,16 @@ __all__ = [
"Loop",
"Repeat",
"Include",
"If",
"route",
"parallel",
"loop",
"repeat",
"include",
"if_",
# Constants
"MAX_NESTING_DEPTH",
# Workflow Config Classes
"WorkflowOutputConfig",
@@ -390,6 +390,82 @@ def include(recipe: str, input: Optional[str] = None) -> Include:
return Include(recipe=recipe, input=input)
# Maximum nesting depth for patterns (prevents stack overflow)
MAX_NESTING_DEPTH = 5
@dataclass
class If:
"""
Conditional branching pattern for workflows.
Evaluates a condition and executes either then_steps or else_steps.
Usage:
workflow = Workflow(steps=[
if_(
condition="{{score}} > 80",
then_steps=[approve_step],
else_steps=[reject_step]
)
])
YAML syntax:
steps:
- if:
condition: "{{score}} > 80"
then:
- agent: approver
else:
- agent: rejector
Supported condition 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)
"""
condition: str = "" # Condition expression with {{var}} placeholders
then_steps: List[Any] = field(default_factory=list) # Steps if condition is true
else_steps: Optional[List[Any]] = None # Steps if condition is false (optional)
def __init__(
self,
condition: str,
then_steps: List[Any],
else_steps: Optional[List[Any]] = None
):
self.condition = condition
self.then_steps = then_steps
self.else_steps = else_steps or []
def if_(
condition: str,
then_steps: List[Any],
else_steps: Optional[List[Any]] = None
) -> If:
"""
Create a conditional branching step.
Args:
condition: Condition expression with {{var}} placeholders
then_steps: Steps to execute if condition is true
else_steps: Steps to execute if condition is false (optional)
Returns:
If object for conditional execution
Example:
if_(
condition="{{score}} > 80",
then_steps=[approve_agent],
else_steps=[reject_agent]
)
"""
return If(condition=condition, then_steps=then_steps, else_steps=else_steps)
@dataclass
class WorkflowStep:
"""
@@ -1227,6 +1303,17 @@ class Workflow:
i += 1
continue
elif isinstance(step, If):
# Conditional branching
if_result = self._execute_if(
step, previous_output, input, all_variables, model, verbose, stream
)
results.extend(if_result["steps"])
previous_output = if_result["output"]
all_variables.update(if_result.get("variables", {}))
i += 1
continue
# Normalize single step
step = self._normalize_single_step(step, i)
@@ -1801,9 +1888,88 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
model: str,
verbose: bool,
index: int = 0,
stream: bool = True
stream: bool = True,
depth: int = 0
) -> Dict[str, Any]:
"""Execute a single step and return result."""
"""Execute a single step and return result.
Args:
step: The step to execute (Agent, function, or pattern like Loop/Parallel/Route/If)
previous_output: Output from previous step
input: Original workflow input
all_variables: Current workflow variables
model: LLM model to use
verbose: Whether to print verbose output
index: Step index
stream: Whether to stream responses
depth: Current nesting depth (for pattern recursion safety)
Returns:
Dict with 'step', 'output', 'stop', 'variables'
"""
# Check nesting depth limit for nested patterns
if depth > MAX_NESTING_DEPTH:
raise ValueError(
f"Maximum nesting depth ({MAX_NESTING_DEPTH}) exceeded. "
"Simplify your workflow or reduce pattern nesting."
)
# Handle nested patterns (Loop, Parallel, Route, Repeat, If)
if isinstance(step, Loop):
loop_result = self._execute_loop(
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1
)
return {
"step": f"loop_{index}",
"output": loop_result.get("output", ""),
"stop": False,
"variables": loop_result.get("variables", all_variables)
}
if isinstance(step, Parallel):
parallel_result = self._execute_parallel(
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1
)
return {
"step": f"parallel_{index}",
"output": parallel_result.get("output", ""),
"stop": False,
"variables": parallel_result.get("variables", all_variables)
}
if isinstance(step, Route):
route_result = self._execute_route(
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1
)
return {
"step": f"route_{index}",
"output": route_result.get("output", ""),
"stop": False,
"variables": route_result.get("variables", all_variables)
}
if isinstance(step, Repeat):
repeat_result = self._execute_repeat(
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1
)
return {
"step": f"repeat_{index}",
"output": repeat_result.get("output", ""),
"stop": False,
"variables": repeat_result.get("variables", all_variables)
}
if isinstance(step, If):
if_result = self._execute_if(
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1
)
return {
"step": f"if_{index}",
"output": if_result.get("output", ""),
"stop": False,
"variables": if_result.get("variables", all_variables)
}
# Handle Include steps (for include inside loop)
if isinstance(step, Include):
include_result = self._execute_include(
@@ -1928,7 +2094,8 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
all_variables: Dict[str, Any],
model: str,
verbose: bool,
stream: bool = True
stream: bool = True,
depth: int = 0
) -> Dict[str, Any]:
"""Execute routing based on previous output."""
results = []
@@ -1954,7 +2121,7 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
# Execute matched route steps
for idx, step in enumerate(matched_route):
step_result = self._execute_single_step_internal(
step, output, input, all_variables, model, verbose, idx, stream=stream
step, output, input, all_variables, model, verbose, idx, stream=stream, depth=depth+1
)
results.append({"step": step_result["step"], "output": step_result["output"]})
output = step_result["output"]
@@ -2024,7 +2191,8 @@ CONCISE SUMMARY:"""
all_variables: Dict[str, Any],
model: str,
verbose: bool,
stream: bool = True
stream: bool = True,
depth: int = 0
) -> Dict[str, Any]:
"""Execute steps in parallel (simulated with sequential for now)."""
import concurrent.futures
@@ -2071,7 +2239,7 @@ CONCISE SUMMARY:"""
emitter.set_branch(f"parallel_{idx}")
try:
return self._execute_single_step_internal(
step, opt_prev, input, all_variables.copy(), model, False, idx, stream
step, opt_prev, input, all_variables.copy(), model, False, idx, stream, depth=depth+1
)
finally:
emitter.clear_branch()
@@ -2105,7 +2273,8 @@ CONCISE SUMMARY:"""
all_variables: Dict[str, Any],
model: str,
verbose: bool,
stream: bool = True
stream: bool = True,
depth: int = 0
) -> Dict[str, Any]:
"""Execute step for each item in loop.
@@ -2199,7 +2368,7 @@ CONCISE SUMMARY:"""
iteration_results = []
for step_idx, step in enumerate(steps_to_run):
step_result = self._execute_single_step_internal(
step, iteration_output, input, loop_vars, model, False, step_idx, stream=False
step, iteration_output, input, loop_vars, model, False, step_idx, stream=False, depth=depth+1
)
iteration_output = step_result.get("output")
iteration_results.append(step_result)
@@ -2266,7 +2435,7 @@ CONCISE SUMMARY:"""
iteration_output = previous_output
for step_idx, step in enumerate(steps_to_run):
step_result = self._execute_single_step_internal(
step, iteration_output, input, loop_vars, model, verbose, step_idx, stream=stream
step, iteration_output, input, loop_vars, model, verbose, step_idx, stream=stream, depth=depth+1
)
iteration_output = step_result.get("output")
# Update variables if step set any
@@ -2377,7 +2546,8 @@ CONCISE SUMMARY:"""
all_variables: Dict[str, Any],
model: str,
verbose: bool,
stream: bool = True
stream: bool = True,
depth: int = 0
) -> Dict[str, Any]:
"""Repeat step until condition is met."""
results = []
@@ -2388,7 +2558,7 @@ CONCISE SUMMARY:"""
for iteration in range(repeat_step.max_iterations):
step_result = self._execute_single_step_internal(
repeat_step.step, output, input, all_variables, model, verbose, iteration, stream=stream
repeat_step.step, output, input, all_variables, model, verbose, iteration, stream=stream, depth=depth+1
)
results.append({"step": f"{step_result['step']}_{iteration}", "output": step_result["output"]})
output = step_result["output"]
@@ -2416,6 +2586,192 @@ CONCISE SUMMARY:"""
all_variables["repeat_iterations"] = iteration + 1
return {"steps": results, "output": output, "variables": all_variables}
def _execute_if(
self,
if_step: If,
previous_output: Optional[str],
input: str,
all_variables: Dict[str, Any],
model: str,
verbose: bool,
stream: bool = True,
depth: int = 0
) -> Dict[str, Any]:
"""Execute conditional branching based on condition evaluation.
Args:
if_step: The If step with condition and branches
previous_output: Output from previous step
input: Original workflow input
all_variables: Current workflow variables
model: LLM model to use
verbose: Verbose output
stream: Enable streaming
depth: Current nesting depth
Returns:
Dict with 'steps', 'output', and 'variables'
"""
results = []
output = previous_output
# Evaluate the condition
condition_result = self._evaluate_condition(if_step.condition, all_variables, previous_output)
if verbose:
branch = "then" if condition_result else "else"
print(f"🔀 Condition '{if_step.condition}' → {condition_result}, executing {branch} branch")
# Select the appropriate branch
steps_to_execute = if_step.then_steps if condition_result else if_step.else_steps
# Execute the selected branch
for idx, step in enumerate(steps_to_execute):
step_result = self._execute_single_step_internal(
step, output, input, all_variables, model, verbose, idx, stream=stream, depth=depth
)
results.append({"step": step_result["step"], "output": step_result["output"]})
output = step_result["output"]
all_variables.update(step_result.get("variables", {}))
if step_result.get("stop"):
break
return {"steps": results, "output": output, "variables": all_variables}
def _evaluate_condition(
self,
condition: str,
variables: Dict[str, Any],
previous_output: Optional[str] = None
) -> bool:
"""Evaluate a condition expression with variable substitution.
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: Current workflow variables
previous_output: Output from previous step
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
def _execute_include(
self,
include_step: Include,
@@ -731,6 +731,8 @@ class YAMLWorkflowParser:
return self._parse_repeat_step(step_data)
elif 'include' in step_data:
return self._parse_include_step(step_data)
elif 'if' in step_data:
return self._parse_if_step(step_data)
elif 'agent' in step_data:
return self._parse_agent_step(step_data)
else:
@@ -854,6 +856,52 @@ class YAMLWorkflowParser:
return Include(recipe=recipe_name, input=input_template)
def _parse_if_step(self, step_data: Dict):
"""
Parse a conditional branching (if:) pattern step.
Args:
step_data: Step definition with 'if' key
Returns:
If pattern object
YAML syntax:
- if:
condition: "{{score}} > 80"
then:
- agent: approver
else:
- agent: rejector
"""
from .workflows import If
if_config = step_data['if']
condition = if_config.get('condition', '')
# Parse then steps
then_steps = []
then_config = if_config.get('then', [])
for step in then_config:
parsed = self._parse_single_step(step)
if parsed:
then_steps.append(parsed)
# Parse else steps (optional)
else_steps = []
else_config = if_config.get('else', [])
for step in else_config:
parsed = self._parse_single_step(step)
if parsed:
else_steps.append(parsed)
return If(
condition=condition,
then_steps=then_steps,
else_steps=else_steps if else_steps else None
)
def _parse_route_step(self, step_data: Dict) -> Dict:
"""
Parse a route pattern step.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.13.18"
version = "0.13.19"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,230 @@
"""Unit tests for agents/protocols.py"""
from praisonaiagents.agents.protocols import (
MergeStrategyProtocol,
FirstWinsMerge,
ConcatMerge,
DictMerge,
)
class TestMergeStrategyProtocol:
"""Tests for MergeStrategyProtocol."""
def test_protocol_is_runtime_checkable(self):
"""Test protocol is runtime checkable."""
assert isinstance(FirstWinsMerge(), MergeStrategyProtocol)
assert isinstance(ConcatMerge(), MergeStrategyProtocol)
assert isinstance(DictMerge(), MergeStrategyProtocol)
def test_custom_implementation(self):
"""Test custom implementation matches protocol."""
class CustomMerge:
def merge(self, outputs, context=None):
return outputs[-1] if outputs else None
assert isinstance(CustomMerge(), MergeStrategyProtocol)
def test_incomplete_implementation(self):
"""Test incomplete implementation doesn't match."""
class IncompleteMerge:
def other_method(self):
pass
assert not isinstance(IncompleteMerge(), MergeStrategyProtocol)
class TestFirstWinsMerge:
"""Tests for FirstWinsMerge."""
def test_returns_first_non_none(self):
"""Test returns first non-None value."""
merge = FirstWinsMerge()
result = merge.merge([None, "first", "second"])
assert result == "first"
def test_skips_leading_nones(self):
"""Test skips leading None values."""
merge = FirstWinsMerge()
result = merge.merge([None, None, "third"])
assert result == "third"
def test_all_none_returns_none(self):
"""Test returns None when all values are None."""
merge = FirstWinsMerge()
result = merge.merge([None, None, None])
assert result is None
def test_empty_list_returns_none(self):
"""Test returns None for empty list."""
merge = FirstWinsMerge()
result = merge.merge([])
assert result is None
def test_single_value(self):
"""Test with single value."""
merge = FirstWinsMerge()
result = merge.merge(["only"])
assert result == "only"
def test_with_context(self):
"""Test context parameter is accepted."""
merge = FirstWinsMerge()
result = merge.merge(["value"], context={"agent": "test"})
assert result == "value"
def test_various_types(self):
"""Test with various types."""
merge = FirstWinsMerge()
assert merge.merge([None, 42]) == 42
assert merge.merge([None, {"key": "value"}]) == {"key": "value"}
assert merge.merge([None, [1, 2, 3]]) == [1, 2, 3]
class TestConcatMerge:
"""Tests for ConcatMerge."""
def test_default_separator(self):
"""Test default separator is double newline."""
merge = ConcatMerge()
result = merge.merge(["a", "b", "c"])
assert result == "a\n\nb\n\nc"
def test_custom_separator(self):
"""Test custom separator."""
merge = ConcatMerge(separator=" | ")
result = merge.merge(["x", "y", "z"])
assert result == "x | y | z"
def test_skips_none_values(self):
"""Test None values are skipped."""
merge = ConcatMerge(separator=", ")
result = merge.merge(["a", None, "c"])
assert result == "a, c"
def test_converts_to_string(self):
"""Test non-string values are converted."""
merge = ConcatMerge(separator="-")
result = merge.merge([1, 2, 3])
assert result == "1-2-3"
def test_empty_list(self):
"""Test empty list returns empty string."""
merge = ConcatMerge()
result = merge.merge([])
assert result == ""
def test_all_none(self):
"""Test all None values returns empty string."""
merge = ConcatMerge()
result = merge.merge([None, None])
assert result == ""
def test_single_value(self):
"""Test single value."""
merge = ConcatMerge()
result = merge.merge(["only"])
assert result == "only"
def test_with_context(self):
"""Test context parameter is accepted."""
merge = ConcatMerge()
result = merge.merge(["a", "b"], context={"session": "123"})
assert result == "a\n\nb"
class TestDictMerge:
"""Tests for DictMerge."""
def test_basic_merge(self):
"""Test basic dictionary merge."""
merge = DictMerge()
result = merge.merge([{"a": 1}, {"b": 2}])
assert result == {"a": 1, "b": 2}
def test_later_overrides_earlier(self):
"""Test later values override earlier for same key."""
merge = DictMerge()
result = merge.merge([{"a": 1}, {"a": 2}])
assert result == {"a": 2}
def test_skips_none_values(self):
"""Test None values are skipped."""
merge = DictMerge()
result = merge.merge([{"a": 1}, None, {"b": 2}])
assert result == {"a": 1, "b": 2}
def test_skips_non_dict_values(self):
"""Test non-dict values are skipped."""
merge = DictMerge()
result = merge.merge([{"a": 1}, "not a dict", {"b": 2}])
assert result == {"a": 1, "b": 2}
def test_empty_list(self):
"""Test empty list returns empty dict."""
merge = DictMerge()
result = merge.merge([])
assert result == {}
def test_all_none(self):
"""Test all None returns empty dict."""
merge = DictMerge()
result = merge.merge([None, None])
assert result == {}
def test_shallow_merge_default(self):
"""Test shallow merge is default."""
merge = DictMerge()
result = merge.merge([
{"nested": {"a": 1}},
{"nested": {"b": 2}}
])
# Shallow merge replaces entire nested dict
assert result == {"nested": {"b": 2}}
def test_deep_merge(self):
"""Test deep merge combines nested dicts."""
merge = DictMerge(deep=True)
result = merge.merge([
{"nested": {"a": 1, "b": 2}},
{"nested": {"b": 3, "c": 4}}
])
assert result == {"nested": {"a": 1, "b": 3, "c": 4}}
def test_deep_merge_multiple_levels(self):
"""Test deep merge with multiple nesting levels."""
merge = DictMerge(deep=True)
result = merge.merge([
{"l1": {"l2": {"a": 1}}},
{"l1": {"l2": {"b": 2}}}
])
assert result == {"l1": {"l2": {"a": 1, "b": 2}}}
def test_deep_merge_non_dict_override(self):
"""Test deep merge replaces non-dict with dict."""
merge = DictMerge(deep=True)
result = merge.merge([
{"key": "string"},
{"key": {"nested": "value"}}
])
assert result == {"key": {"nested": "value"}}
def test_with_context(self):
"""Test context parameter is accepted."""
merge = DictMerge()
result = merge.merge([{"a": 1}], context={"agent": "test"})
assert result == {"a": 1}
def test_complex_merge(self):
"""Test complex merge scenario."""
merge = DictMerge()
result = merge.merge([
{"name": "Alice", "score": 10, "tags": ["a"]},
{"name": "Bob", "level": 5},
{"score": 20, "tags": ["b", "c"]}
])
assert result == {
"name": "Bob",
"score": 20,
"level": 5,
"tags": ["b", "c"]
}
@@ -0,0 +1,377 @@
"""Unit tests for eval/package.py"""
from praisonaiagents.eval.package import (
EvalCase,
EvalResult,
EvalReport,
EvalPackage,
EvalRunnerProtocol,
)
class TestEvalCase:
"""Tests for EvalCase dataclass."""
def test_minimal_case(self):
"""Test creating a minimal eval case."""
case = EvalCase(name="test", input="hello")
assert case.name == "test"
assert case.input == "hello"
assert case.expected is None
assert case.criteria == []
assert case.metadata == {}
assert case.timeout_seconds == 30.0
def test_full_case(self):
"""Test creating a fully specified case."""
case = EvalCase(
name="math_test",
input="What is 2+2?",
expected="4",
criteria=["accuracy", "conciseness"],
metadata={"difficulty": "easy", "category": "math"},
timeout_seconds=10.0
)
assert case.name == "math_test"
assert case.expected == "4"
assert len(case.criteria) == 2
assert case.metadata["difficulty"] == "easy"
assert case.timeout_seconds == 10.0
def test_validation_empty_name(self):
"""Test validation rejects empty name."""
try:
EvalCase(name="", input="test")
assert False, "Should have raised ValueError"
except ValueError as e:
assert "name" in str(e).lower()
def test_validation_empty_input(self):
"""Test validation rejects empty input."""
try:
EvalCase(name="test", input="")
assert False, "Should have raised ValueError"
except ValueError as e:
assert "input" in str(e).lower()
def test_to_dict(self):
"""Test to_dict serialization."""
case = EvalCase(
name="test",
input="hello",
expected="world",
criteria=["accuracy"]
)
d = case.to_dict()
assert d["name"] == "test"
assert d["input"] == "hello"
assert d["expected"] == "world"
assert d["criteria"] == ["accuracy"]
assert "metadata" in d
assert "timeout_seconds" in d
def test_from_dict(self):
"""Test from_dict deserialization."""
data = {
"name": "test",
"input": "hello",
"expected": "world",
"criteria": ["accuracy"],
"metadata": {"key": "value"},
"timeout_seconds": 15.0
}
case = EvalCase.from_dict(data)
assert case.name == "test"
assert case.input == "hello"
assert case.expected == "world"
assert case.criteria == ["accuracy"]
assert case.metadata == {"key": "value"}
assert case.timeout_seconds == 15.0
def test_from_dict_minimal(self):
"""Test from_dict with minimal data."""
data = {"name": "test", "input": "hello"}
case = EvalCase.from_dict(data)
assert case.name == "test"
assert case.expected is None
assert case.criteria == []
def test_roundtrip(self):
"""Test to_dict/from_dict roundtrip."""
original = EvalCase(
name="roundtrip",
input="test input",
expected="expected output",
criteria=["a", "b"],
metadata={"x": "y"},
timeout_seconds=20.0
)
restored = EvalCase.from_dict(original.to_dict())
assert restored.name == original.name
assert restored.input == original.input
assert restored.expected == original.expected
assert restored.criteria == original.criteria
assert restored.timeout_seconds == original.timeout_seconds
class TestEvalResult:
"""Tests for EvalResult dataclass."""
def test_minimal_result(self):
"""Test creating a minimal result."""
result = EvalResult(case_name="test", passed=True, score=1.0)
assert result.case_name == "test"
assert result.passed is True
assert result.score == 1.0
assert result.actual_output is None
assert result.error is None
assert result.latency_ms == 0.0
assert result.criteria_scores == {}
def test_full_result(self):
"""Test creating a fully specified result."""
result = EvalResult(
case_name="test",
passed=False,
score=0.5,
actual_output="wrong answer",
error="Assertion failed",
latency_ms=150.5,
criteria_scores={"accuracy": 0.3, "format": 0.7}
)
assert result.passed is False
assert result.score == 0.5
assert result.actual_output == "wrong answer"
assert result.error == "Assertion failed"
assert result.latency_ms == 150.5
assert result.criteria_scores["accuracy"] == 0.3
def test_to_dict(self):
"""Test to_dict serialization."""
result = EvalResult(
case_name="test",
passed=True,
score=0.9,
actual_output="output",
latency_ms=100.0
)
d = result.to_dict()
assert d["case_name"] == "test"
assert d["passed"] is True
assert d["score"] == 0.9
assert d["actual_output"] == "output"
assert d["latency_ms"] == 100.0
def test_from_dict(self):
"""Test from_dict deserialization."""
data = {
"case_name": "test",
"passed": True,
"score": 0.95,
"actual_output": "result",
"error": None,
"latency_ms": 50.0,
"criteria_scores": {"a": 1.0}
}
result = EvalResult.from_dict(data)
assert result.case_name == "test"
assert result.passed is True
assert result.score == 0.95
assert result.criteria_scores == {"a": 1.0}
class TestEvalReport:
"""Tests for EvalReport dataclass."""
def test_basic_report(self):
"""Test creating a basic report."""
report = EvalReport(
package_name="test_pkg",
total_cases=10,
passed_cases=8,
failed_cases=2,
average_score=0.85
)
assert report.package_name == "test_pkg"
assert report.total_cases == 10
assert report.passed_cases == 8
assert report.failed_cases == 2
assert report.average_score == 0.85
def test_pass_rate(self):
"""Test pass_rate property."""
report = EvalReport(
package_name="test",
total_cases=10,
passed_cases=8,
failed_cases=2,
average_score=0.8
)
assert report.pass_rate == 0.8
def test_pass_rate_zero_cases(self):
"""Test pass_rate with zero cases."""
report = EvalReport(
package_name="empty",
total_cases=0,
passed_cases=0,
failed_cases=0,
average_score=0.0
)
assert report.pass_rate == 0.0
def test_all_passed_true(self):
"""Test all_passed when all pass."""
report = EvalReport(
package_name="perfect",
total_cases=5,
passed_cases=5,
failed_cases=0,
average_score=1.0
)
assert report.all_passed is True
def test_all_passed_false(self):
"""Test all_passed when some fail."""
report = EvalReport(
package_name="partial",
total_cases=5,
passed_cases=4,
failed_cases=1,
average_score=0.9
)
assert report.all_passed is False
def test_to_dict(self):
"""Test to_dict serialization."""
result = EvalResult(case_name="c1", passed=True, score=1.0)
report = EvalReport(
package_name="test",
total_cases=1,
passed_cases=1,
failed_cases=0,
average_score=1.0,
results=[result],
thresholds_met={"accuracy": True}
)
d = report.to_dict()
assert d["package_name"] == "test"
assert d["pass_rate"] == 1.0
assert len(d["results"]) == 1
assert d["thresholds_met"]["accuracy"] is True
class TestEvalPackage:
"""Tests for EvalPackage dataclass."""
def test_minimal_package(self):
"""Test creating a minimal package."""
pkg = EvalPackage(name="test")
assert pkg.name == "test"
assert pkg.description == ""
assert pkg.version == "1.0.0"
assert pkg.cases == []
assert pkg.thresholds == {}
assert pkg.seed is None
assert len(pkg) == 0
def test_full_package(self):
"""Test creating a fully specified package."""
case = EvalCase(name="c1", input="test")
pkg = EvalPackage(
name="full_pkg",
description="A test package",
version="2.0.0",
cases=[case],
thresholds={"accuracy": 0.9},
seed=42
)
assert pkg.name == "full_pkg"
assert pkg.description == "A test package"
assert pkg.version == "2.0.0"
assert len(pkg) == 1
assert pkg.thresholds["accuracy"] == 0.9
assert pkg.seed == 42
def test_validation_empty_name(self):
"""Test validation rejects empty name."""
try:
EvalPackage(name="")
assert False, "Should have raised ValueError"
except ValueError as e:
assert "name" in str(e).lower()
def test_add_case(self):
"""Test add_case method."""
pkg = EvalPackage(name="test")
case = EvalCase(name="c1", input="test")
pkg.add_case(case)
assert len(pkg) == 1
assert pkg.cases[0].name == "c1"
def test_add_cases(self):
"""Test add_cases method."""
pkg = EvalPackage(name="test")
cases = [
EvalCase(name="c1", input="test1"),
EvalCase(name="c2", input="test2")
]
pkg.add_cases(cases)
assert len(pkg) == 2
def test_to_dict(self):
"""Test to_dict serialization."""
case = EvalCase(name="c1", input="test")
pkg = EvalPackage(
name="test",
description="desc",
cases=[case],
seed=123
)
d = pkg.to_dict()
assert d["name"] == "test"
assert d["description"] == "desc"
assert len(d["cases"]) == 1
assert d["seed"] == 123
def test_from_dict(self):
"""Test from_dict deserialization."""
data = {
"name": "test_pkg",
"description": "Test package",
"version": "2.0.0",
"cases": [{"name": "c1", "input": "test"}],
"thresholds": {"accuracy": 0.9},
"seed": 42
}
pkg = EvalPackage.from_dict(data)
assert pkg.name == "test_pkg"
assert pkg.version == "2.0.0"
assert len(pkg.cases) == 1
assert pkg.cases[0].name == "c1"
assert pkg.thresholds["accuracy"] == 0.9
assert pkg.seed == 42
def test_roundtrip(self):
"""Test to_dict/from_dict roundtrip."""
original = EvalPackage(
name="roundtrip",
description="Test",
version="3.0.0",
cases=[EvalCase(name="c1", input="test")],
thresholds={"x": 0.5},
seed=99
)
restored = EvalPackage.from_dict(original.to_dict())
assert restored.name == original.name
assert restored.version == original.version
assert len(restored.cases) == len(original.cases)
assert restored.seed == original.seed
class TestEvalRunnerProtocol:
"""Tests for EvalRunnerProtocol."""
def test_protocol_methods_exist(self):
"""Test protocol has required methods."""
assert hasattr(EvalRunnerProtocol, "run")
assert hasattr(EvalRunnerProtocol, "run_async")
@@ -0,0 +1,204 @@
"""Unit tests for tools/retry.py"""
from praisonaiagents.tools.retry import (
RetryPolicy,
FallbackChain,
ToolExecutionConfig,
)
class TestRetryPolicy:
"""Tests for RetryPolicy dataclass."""
def test_default_values(self):
"""Test default configuration values."""
policy = RetryPolicy()
assert policy.max_attempts == 3
assert policy.backoff_factor == 2.0
assert policy.initial_delay_ms == 1000
assert policy.max_delay_ms == 30000
assert "timeout" in policy.retry_on
assert "rate_limit" in policy.retry_on
assert "connection_error" in policy.retry_on
assert policy.jitter is False
assert policy.jitter_factor == 0.25
def test_custom_values(self):
"""Test custom configuration."""
policy = RetryPolicy(
max_attempts=5,
backoff_factor=1.5,
initial_delay_ms=500,
max_delay_ms=10000,
retry_on={"custom_error"},
jitter=True,
jitter_factor=0.1
)
assert policy.max_attempts == 5
assert policy.backoff_factor == 1.5
assert policy.initial_delay_ms == 500
assert policy.max_delay_ms == 10000
assert policy.retry_on == {"custom_error"}
assert policy.jitter is True
assert policy.jitter_factor == 0.1
def test_should_retry_within_attempts(self):
"""Test should_retry returns True within max_attempts."""
policy = RetryPolicy(max_attempts=3)
assert policy.should_retry("timeout", 0) is True
assert policy.should_retry("timeout", 1) is True
assert policy.should_retry("timeout", 2) is True
def test_should_retry_at_max_attempts(self):
"""Test should_retry returns False at max_attempts."""
policy = RetryPolicy(max_attempts=3)
assert policy.should_retry("timeout", 3) is False
assert policy.should_retry("timeout", 4) is False
def test_should_retry_unknown_error(self):
"""Test should_retry returns False for unknown errors."""
policy = RetryPolicy()
assert policy.should_retry("unknown_error", 0) is False
def test_should_retry_custom_errors(self):
"""Test should_retry with custom error types."""
policy = RetryPolicy(retry_on={"my_error", "other_error"})
assert policy.should_retry("my_error", 0) is True
assert policy.should_retry("other_error", 0) is True
assert policy.should_retry("timeout", 0) is False
def test_get_delay_ms_exponential(self):
"""Test exponential backoff calculation."""
policy = RetryPolicy(
initial_delay_ms=1000,
backoff_factor=2.0,
max_delay_ms=30000,
jitter=False
)
assert policy.get_delay_ms(0) == 1000 # 1000 * 2^0
assert policy.get_delay_ms(1) == 2000 # 1000 * 2^1
assert policy.get_delay_ms(2) == 4000 # 1000 * 2^2
assert policy.get_delay_ms(3) == 8000 # 1000 * 2^3
def test_get_delay_ms_capped(self):
"""Test delay is capped at max_delay_ms."""
policy = RetryPolicy(
initial_delay_ms=1000,
backoff_factor=2.0,
max_delay_ms=5000,
jitter=False
)
assert policy.get_delay_ms(10) == 5000 # Would be 1024000, capped
def test_get_delay_ms_with_jitter(self):
"""Test jitter produces varying delays."""
policy = RetryPolicy(jitter=True, jitter_factor=0.25)
delays = [policy.get_delay_ms(0) for _ in range(20)]
# With 25% jitter, delays should vary
assert len(set(delays)) > 1
# All delays should be within expected range (±25%)
for delay in delays:
assert 750 <= delay <= 1250
def test_validation_max_attempts(self):
"""Test validation rejects invalid max_attempts."""
try:
RetryPolicy(max_attempts=0)
assert False, "Should have raised ValueError"
except ValueError as e:
assert "max_attempts" in str(e)
def test_validation_backoff_factor(self):
"""Test validation rejects backoff_factor < 1."""
try:
RetryPolicy(backoff_factor=0.5)
assert False, "Should have raised ValueError"
except ValueError as e:
assert "backoff_factor" in str(e)
def test_validation_initial_delay(self):
"""Test validation rejects negative initial_delay_ms."""
try:
RetryPolicy(initial_delay_ms=-1)
assert False, "Should have raised ValueError"
except ValueError as e:
assert "initial_delay_ms" in str(e)
def test_validation_max_less_than_initial(self):
"""Test validation rejects max_delay_ms < initial_delay_ms."""
try:
RetryPolicy(initial_delay_ms=1000, max_delay_ms=500)
assert False, "Should have raised ValueError"
except ValueError as e:
assert "max_delay_ms" in str(e)
def test_validation_jitter_factor(self):
"""Test validation rejects jitter_factor outside [0, 1]."""
try:
RetryPolicy(jitter_factor=1.5)
assert False, "Should have raised ValueError"
except ValueError as e:
assert "jitter_factor" in str(e)
try:
RetryPolicy(jitter_factor=-0.1)
assert False, "Should have raised ValueError"
except ValueError as e:
assert "jitter_factor" in str(e)
class TestFallbackChain:
"""Tests for FallbackChain dataclass."""
def test_default_values(self):
"""Test default configuration."""
chain = FallbackChain()
assert chain.tools == []
assert chain.stop_on_success is True
assert len(chain) == 0
def test_with_tools(self):
"""Test chain with tools."""
chain = FallbackChain(tools=["primary", "secondary", "fallback"])
assert len(chain) == 3
assert chain.tools == ["primary", "secondary", "fallback"]
def test_iteration(self):
"""Test chain is iterable."""
chain = FallbackChain(tools=["a", "b", "c"])
result = list(chain)
assert result == ["a", "b", "c"]
def test_stop_on_success_false(self):
"""Test stop_on_success can be disabled."""
chain = FallbackChain(tools=["a", "b"], stop_on_success=False)
assert chain.stop_on_success is False
class TestToolExecutionConfig:
"""Tests for ToolExecutionConfig dataclass."""
def test_default_values(self):
"""Test default configuration."""
config = ToolExecutionConfig()
assert config.retry_policy is None
assert config.fallback_chain is None
assert config.timeout_ms is None
def test_with_all_options(self):
"""Test configuration with all options."""
config = ToolExecutionConfig(
retry_policy=RetryPolicy(max_attempts=5),
fallback_chain=FallbackChain(tools=["a", "b"]),
timeout_ms=5000
)
assert config.retry_policy.max_attempts == 5
assert len(config.fallback_chain) == 2
assert config.timeout_ms == 5000
def test_default_factory(self):
"""Test default() factory method."""
config = ToolExecutionConfig.default()
assert config.retry_policy is not None
assert config.retry_policy.max_attempts == 3
assert config.fallback_chain is None
assert config.timeout_ms is None
@@ -0,0 +1,181 @@
"""Unit tests for tools/validators.py"""
import pytest
from praisonaiagents.tools.validators import (
ValidationResult,
ToolValidatorProtocol,
AsyncToolValidatorProtocol,
PassthroughValidator,
)
class TestValidationResult:
"""Tests for ValidationResult dataclass."""
def test_valid_result(self):
"""Test creating a valid result."""
result = ValidationResult(valid=True)
assert result.valid is True
assert bool(result) is True
assert result.errors == []
assert result.warnings == []
assert result.remediation is None
def test_invalid_result_with_errors(self):
"""Test creating an invalid result with errors."""
result = ValidationResult(
valid=False,
errors=["Error 1", "Error 2"],
warnings=["Warning 1"],
remediation="Fix by doing X"
)
assert result.valid is False
assert bool(result) is False
assert len(result.errors) == 2
assert len(result.warnings) == 1
assert result.remediation == "Fix by doing X"
def test_success_factory(self):
"""Test ValidationResult.success() factory method."""
result = ValidationResult.success()
assert result.valid is True
assert result.errors == []
def test_failure_factory(self):
"""Test ValidationResult.failure() factory method."""
result = ValidationResult.failure(
errors=["Something went wrong"],
remediation="Try again"
)
assert result.valid is False
assert result.errors == ["Something went wrong"]
assert result.remediation == "Try again"
def test_failure_factory_without_remediation(self):
"""Test failure factory without remediation."""
result = ValidationResult.failure(errors=["Error"])
assert result.valid is False
assert result.remediation is None
def test_add_error(self):
"""Test add_error method."""
result = ValidationResult(valid=True)
returned = result.add_error("New error")
assert result.valid is False
assert "New error" in result.errors
assert returned is result # Returns self for chaining
def test_add_error_chaining(self):
"""Test chaining add_error calls."""
result = ValidationResult(valid=True)
result.add_error("Error 1").add_error("Error 2")
assert result.valid is False
assert len(result.errors) == 2
def test_add_warning(self):
"""Test add_warning method."""
result = ValidationResult(valid=True)
returned = result.add_warning("Warning message")
assert result.valid is True # Warnings don't affect validity
assert "Warning message" in result.warnings
assert returned is result
def test_add_warning_preserves_validity(self):
"""Test that warnings don't change validity."""
result = ValidationResult(valid=True)
result.add_warning("W1").add_warning("W2")
assert result.valid is True
assert len(result.warnings) == 2
class TestToolValidatorProtocol:
"""Tests for ToolValidatorProtocol."""
def test_protocol_is_runtime_checkable(self):
"""Test that protocol is runtime checkable."""
validator = PassthroughValidator()
assert isinstance(validator, ToolValidatorProtocol)
def test_custom_validator_implements_protocol(self):
"""Test custom validator implements protocol."""
class CustomValidator:
def validate_args(self, tool_name, args, context=None):
return ValidationResult(valid=True)
def validate_result(self, tool_name, result, context=None):
return ValidationResult(valid=True)
validator = CustomValidator()
assert isinstance(validator, ToolValidatorProtocol)
def test_incomplete_validator_not_protocol(self):
"""Test incomplete validator doesn't match protocol."""
class IncompleteValidator:
def validate_args(self, tool_name, args, context=None):
return ValidationResult(valid=True)
# Missing validate_result
validator = IncompleteValidator()
assert not isinstance(validator, ToolValidatorProtocol)
class TestAsyncToolValidatorProtocol:
"""Tests for AsyncToolValidatorProtocol."""
def test_protocol_is_runtime_checkable(self):
"""Test that async protocol is runtime checkable."""
class AsyncValidator:
async def validate_args(self, tool_name, args, context=None):
return ValidationResult(valid=True)
async def validate_result(self, tool_name, result, context=None):
return ValidationResult(valid=True)
validator = AsyncValidator()
assert isinstance(validator, AsyncToolValidatorProtocol)
class TestPassthroughValidator:
"""Tests for PassthroughValidator."""
def test_validate_args_always_passes(self):
"""Test validate_args always returns valid."""
validator = PassthroughValidator()
result = validator.validate_args("any_tool", {"arg": "value"})
assert result.valid is True
def test_validate_result_always_passes(self):
"""Test validate_result always returns valid."""
validator = PassthroughValidator()
result = validator.validate_result("any_tool", {"output": "data"})
assert result.valid is True
def test_validate_args_with_context(self):
"""Test validate_args with context."""
validator = PassthroughValidator()
result = validator.validate_args(
"tool",
{"arg": "value"},
context={"user": "test"}
)
assert result.valid is True
def test_validate_result_with_context(self):
"""Test validate_result with context."""
validator = PassthroughValidator()
result = validator.validate_result(
"tool",
"result",
context={"session": "123"}
)
assert result.valid is True
def test_implements_protocol(self):
"""Test PassthroughValidator implements ToolValidatorProtocol."""
validator = PassthroughValidator()
assert isinstance(validator, ToolValidatorProtocol)
@@ -2,20 +2,23 @@
Tests for custom sink protocol and trace_context helper.
TDD tests to verify:
1. Custom sinks can implement ContextTraceSink protocol (duck typing)
1. Custom sinks can implement ContextTraceSinkProtocol protocol (duck typing)
2. trace_context() context manager properly sets/resets emitter
3. Exception in sink doesn't crash agent execution
4. Protocol compliance validation
Note: ContextTraceSink is a backward-compat alias for ContextTraceSinkProtocol
per AGENTS.md naming convention (XProtocol for interfaces).
"""
class TestCustomSinkProtocol:
"""Tests for custom sink implementing ContextTraceSink protocol."""
"""Tests for custom sink implementing ContextTraceSinkProtocol protocol."""
def test_custom_sink_duck_typing_works(self):
"""Test that a custom class implementing emit/flush/close works as sink."""
from praisonaiagents.trace.context_events import (
ContextTraceEmitter, ContextTraceSink
ContextTraceEmitter, ContextTraceSinkProtocol
)
# Custom sink - NO inheritance required
@@ -35,7 +38,7 @@ class TestCustomSinkProtocol:
sink = MyCustomSink()
# Should pass isinstance check (runtime_checkable)
assert isinstance(sink, ContextTraceSink)
assert isinstance(sink, ContextTraceSinkProtocol)
# Should work with emitter
emitter = ContextTraceEmitter(sink=sink, session_id="test", enabled=True)
@@ -0,0 +1,587 @@
"""
Unit tests for Nested Workflow Patterns and Conditional Branching (if:)
TDD tests for:
1. Nested loops (loop inside loop)
2. Nested parallel (parallel inside loop)
3. Nested route (route inside loop)
4. Conditional branching (if: pattern)
5. Depth limit enforcement
"""
import pytest
from praisonaiagents import Workflow, WorkflowContext, StepResult
from praisonaiagents.workflows import loop, Loop, parallel, route
# =============================================================================
# Test Fixtures
# =============================================================================
@pytest.fixture
def simple_processor():
"""A simple step that processes items."""
def processor(ctx: WorkflowContext) -> StepResult:
item = ctx.variables.get("item", "unknown")
return StepResult(output=f"Processed: {item}")
return processor
@pytest.fixture
def sub_processor():
"""A processor for nested items."""
def processor(ctx: WorkflowContext) -> StepResult:
item = ctx.variables.get("item", "unknown")
sub_item = ctx.variables.get("sub_item", "unknown")
return StepResult(output=f"Processed: {item}/{sub_item}")
return processor
# =============================================================================
# Test: Nested Loops
# =============================================================================
class TestNestedLoops:
"""Test loop inside loop execution."""
def test_nested_loop_basic(self):
"""Test basic nested loop execution."""
results = []
def outer_processor(ctx: WorkflowContext) -> StepResult:
category = ctx.variables.get("category", "unknown")
results.append(f"outer:{category}")
return StepResult(output=f"Category: {category}")
def inner_processor(ctx: WorkflowContext) -> StepResult:
category = ctx.variables.get("category", "unknown")
item = ctx.variables.get("item", "unknown")
results.append(f"inner:{category}/{item}")
return StepResult(output=f"Item: {category}/{item}")
# Nested loop: outer loops over categories, inner loops over items
workflow = Workflow(
steps=[
loop(
steps=[
outer_processor,
loop(inner_processor, over="items", var_name="item")
],
over="categories",
var_name="category"
)
],
variables={
"categories": ["A", "B"],
"items": ["1", "2"]
}
)
result = workflow.start("test")
# Should have processed all combinations
assert "outer:A" in results
assert "outer:B" in results
assert "inner:A/1" in results
assert "inner:A/2" in results
assert "inner:B/1" in results
assert "inner:B/2" in results
def test_nested_loop_with_dynamic_items(self):
"""Test nested loop where inner items come from outer item."""
results = []
def inner_processor(ctx: WorkflowContext) -> StepResult:
category = ctx.variables.get("category", {})
item = ctx.variables.get("item", "unknown")
cat_name = category.get("name", "unknown") if isinstance(category, dict) else str(category)
results.append(f"{cat_name}/{item}")
return StepResult(output=f"Processed: {cat_name}/{item}")
workflow = Workflow(
steps=[
loop(
steps=[
loop(inner_processor, over="category.items", var_name="item")
],
over="categories",
var_name="category"
)
],
variables={
"categories": [
{"name": "Fruits", "items": ["apple", "banana"]},
{"name": "Veggies", "items": ["carrot", "potato"]}
]
}
)
result = workflow.start("test")
# Should process items from each category
assert len(results) >= 4 # At least 4 items processed
def test_nested_loop_depth_limit(self):
"""Test that deeply nested loops are limited."""
def processor(ctx: WorkflowContext) -> StepResult:
return StepResult(output="processed")
# Create deeply nested loops (6 levels - should exceed limit of 5)
inner = loop(processor, over="items")
for _ in range(5): # 5 more levels = 6 total
inner = loop(steps=[inner], over="items")
workflow = Workflow(
steps=[inner],
variables={"items": ["a"]}
)
# Should raise error or handle gracefully
with pytest.raises((ValueError, RecursionError)):
workflow.start("test")
# =============================================================================
# Test: Nested Parallel
# =============================================================================
class TestNestedParallel:
"""Test parallel inside loop and loop inside parallel."""
def test_parallel_inside_loop(self):
"""Test parallel execution inside a loop."""
results = []
def task_a(ctx: WorkflowContext) -> StepResult:
item = ctx.variables.get("item", "unknown")
results.append(f"A:{item}")
return StepResult(output=f"A processed {item}")
def task_b(ctx: WorkflowContext) -> StepResult:
item = ctx.variables.get("item", "unknown")
results.append(f"B:{item}")
return StepResult(output=f"B processed {item}")
workflow = Workflow(
steps=[
loop(
steps=[parallel([task_a, task_b])],
over="items",
var_name="item"
)
],
variables={"items": ["X", "Y"]}
)
result = workflow.start("test")
# Both tasks should run for each item
assert "A:X" in results
assert "B:X" in results
assert "A:Y" in results
assert "B:Y" in results
def test_loop_inside_parallel(self):
"""Test loop execution inside parallel branches."""
results = []
def loop_processor(ctx: WorkflowContext) -> StepResult:
item = ctx.variables.get("item", "unknown")
results.append(f"loop:{item}")
return StepResult(output=f"Looped: {item}")
def simple_task(ctx: WorkflowContext) -> StepResult:
results.append("simple")
return StepResult(output="Simple done")
workflow = Workflow(
steps=[
parallel([
loop(loop_processor, over="items", var_name="item"),
simple_task
])
],
variables={"items": ["1", "2", "3"]}
)
result = workflow.start("test")
# Loop should process all items, simple task should run once
assert "loop:1" in results
assert "loop:2" in results
assert "loop:3" in results
assert "simple" in results
# =============================================================================
# Test: Nested Route
# =============================================================================
class TestNestedRoute:
"""Test route inside loop."""
def test_route_inside_loop(self):
"""Test routing decisions inside a loop."""
results = []
def classifier(ctx: WorkflowContext) -> StepResult:
item = ctx.variables.get("item", {})
item_type = item.get("type", "unknown") if isinstance(item, dict) else "unknown"
return StepResult(output=item_type)
def handle_a(ctx: WorkflowContext) -> StepResult:
item = ctx.variables.get("item", {})
results.append(f"handled_a:{item.get('name', 'unknown')}")
return StepResult(output="A handled")
def handle_b(ctx: WorkflowContext) -> StepResult:
item = ctx.variables.get("item", {})
results.append(f"handled_b:{item.get('name', 'unknown')}")
return StepResult(output="B handled")
workflow = Workflow(
steps=[
loop(
steps=[
classifier,
route({"a": [handle_a], "b": [handle_b]})
],
over="items",
var_name="item"
)
],
variables={
"items": [
{"name": "item1", "type": "a"},
{"name": "item2", "type": "b"},
{"name": "item3", "type": "a"}
]
}
)
result = workflow.start("test")
# Items should be routed correctly
assert "handled_a:item1" in results
assert "handled_b:item2" in results
assert "handled_a:item3" in results
# =============================================================================
# Test: Conditional Branching (if:)
# =============================================================================
class TestConditionalBranching:
"""Test if: conditional pattern."""
def test_if_pattern_creation(self):
"""Test If class can be created."""
from praisonaiagents.workflows import if_, If
def then_step(ctx): return StepResult(output="then")
def else_step(ctx): return StepResult(output="else")
cond = if_(
condition="{{score}} > 80",
then_steps=[then_step],
else_steps=[else_step]
)
assert isinstance(cond, If)
assert cond.condition == "{{score}} > 80"
assert len(cond.then_steps) == 1
assert len(cond.else_steps) == 1
def test_if_true_branch(self):
"""Test if: executes then branch when condition is true."""
from praisonaiagents.workflows import if_
results = []
def then_step(ctx: WorkflowContext) -> StepResult:
results.append("then_executed")
return StepResult(output="Then branch")
def else_step(ctx: WorkflowContext) -> StepResult:
results.append("else_executed")
return StepResult(output="Else branch")
workflow = Workflow(
steps=[
if_(
condition="{{score}} > 80",
then_steps=[then_step],
else_steps=[else_step]
)
],
variables={"score": 90} # 90 > 80 = True
)
result = workflow.start("test")
assert "then_executed" in results
assert "else_executed" not in results
assert "Then branch" in result["output"]
def test_if_false_branch(self):
"""Test if: executes else branch when condition is false."""
from praisonaiagents.workflows import if_
results = []
def then_step(ctx: WorkflowContext) -> StepResult:
results.append("then_executed")
return StepResult(output="Then branch")
def else_step(ctx: WorkflowContext) -> StepResult:
results.append("else_executed")
return StepResult(output="Else branch")
workflow = Workflow(
steps=[
if_(
condition="{{score}} > 80",
then_steps=[then_step],
else_steps=[else_step]
)
],
variables={"score": 50} # 50 > 80 = False
)
result = workflow.start("test")
assert "else_executed" in results
assert "then_executed" not in results
assert "Else branch" in result["output"]
def test_if_no_else_branch(self):
"""Test if: without else branch."""
from praisonaiagents.workflows import if_
results = []
def then_step(ctx: WorkflowContext) -> StepResult:
results.append("then_executed")
return StepResult(output="Then branch")
workflow = Workflow(
steps=[
if_(
condition="{{score}} > 80",
then_steps=[then_step]
# No else_steps
)
],
variables={"score": 50} # False, no else
)
result = workflow.start("test")
# Should not execute then, and should not error
assert "then_executed" not in results
def test_if_string_condition(self):
"""Test if: with string equality condition."""
from praisonaiagents.workflows import if_
results = []
def approved(ctx: WorkflowContext) -> StepResult:
results.append("approved")
return StepResult(output="Approved!")
def rejected(ctx: WorkflowContext) -> StepResult:
results.append("rejected")
return StepResult(output="Rejected!")
workflow = Workflow(
steps=[
if_(
condition="{{status}} == approved",
then_steps=[approved],
else_steps=[rejected]
)
],
variables={"status": "approved"}
)
result = workflow.start("test")
assert "approved" in results
assert "rejected" not in results
def test_if_contains_condition(self):
"""Test if: with 'contains' condition."""
from praisonaiagents.workflows import if_
results = []
def has_error(ctx: WorkflowContext) -> StepResult:
results.append("has_error")
return StepResult(output="Error found")
def no_error(ctx: WorkflowContext) -> StepResult:
results.append("no_error")
return StepResult(output="No error")
workflow = Workflow(
steps=[
if_(
condition="error in {{message}}",
then_steps=[has_error],
else_steps=[no_error]
)
],
variables={"message": "This has an error in it"}
)
result = workflow.start("test")
assert "has_error" in results
def test_if_inside_loop(self):
"""Test if: pattern inside a loop."""
from praisonaiagents.workflows import if_
results = []
def pass_handler(ctx: WorkflowContext) -> StepResult:
item = ctx.variables.get("item", {})
results.append(f"pass:{item.get('name', 'unknown')}")
return StepResult(output="Passed")
def fail_handler(ctx: WorkflowContext) -> StepResult:
item = ctx.variables.get("item", {})
results.append(f"fail:{item.get('name', 'unknown')}")
return StepResult(output="Failed")
workflow = Workflow(
steps=[
loop(
steps=[
if_(
condition="{{item.score}} >= 60",
then_steps=[pass_handler],
else_steps=[fail_handler]
)
],
over="students",
var_name="item"
)
],
variables={
"students": [
{"name": "Alice", "score": 85},
{"name": "Bob", "score": 45},
{"name": "Charlie", "score": 72}
]
}
)
result = workflow.start("test")
assert "pass:Alice" in results
assert "fail:Bob" in results
assert "pass:Charlie" in results
# =============================================================================
# Test: YAML Parsing for New Patterns
# =============================================================================
class TestYAMLParsing:
"""Test YAML parsing for nested patterns and if:."""
def test_parse_nested_loop_yaml(self):
"""Test parsing nested loop from YAML."""
from praisonaiagents.workflows.yaml_parser import YAMLWorkflowParser
import tempfile
import os
yaml_content = """
name: Nested Loop Test
variables:
categories:
- name: A
items: [1, 2]
- name: B
items: [3, 4]
agents:
processor:
role: Processor
goal: Process items
steps:
- loop:
over: categories
var_name: category
steps:
- loop:
over: category.items
var_name: item
agent: processor
action: "Process {{category.name}}/{{item}}"
"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
f.write(yaml_content)
yaml_path = f.name
try:
parser = YAMLWorkflowParser()
workflow = parser.parse_file(yaml_path)
# Should have parsed the nested loop structure
assert len(workflow.steps) >= 1
first_step = workflow.steps[0]
assert isinstance(first_step, Loop)
finally:
os.unlink(yaml_path)
def test_parse_if_yaml(self):
"""Test parsing if: pattern from YAML."""
from praisonaiagents.workflows.yaml_parser import YAMLWorkflowParser
from praisonaiagents.workflows import If
import tempfile
import os
yaml_content = """
name: Conditional Test
variables:
score: 85
agents:
approver:
role: Approver
goal: Approve items
rejector:
role: Rejector
goal: Reject items
steps:
- if:
condition: "{{score}} > 80"
then:
- agent: approver
action: "Approve with score {{score}}"
else:
- agent: rejector
action: "Reject with score {{score}}"
"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
f.write(yaml_content)
yaml_path = f.name
try:
parser = YAMLWorkflowParser()
workflow = parser.parse_file(yaml_path)
# Should have parsed the if pattern
assert len(workflow.steps) >= 1
first_step = workflow.steps[0]
assert isinstance(first_step, If)
assert first_step.condition == "{{score}} > 80"
finally:
os.unlink(yaml_path)
+1 -1
View File
@@ -2549,7 +2549,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.13.18"
version = "0.13.19"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.10.20.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.10.20.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.10.21.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.10.21.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+111 -14
View File
@@ -824,25 +824,122 @@ def docs_run_all(
@app.command("generate")
def docs_generate(
source: str = typer.Argument(".", help="Source directory"),
output: str = typer.Option("docs", "--output", "-o", help="Output directory"),
package: Optional[Path] = typer.Option(
None,
"--package", "-p",
help="Path to praisonaiagents package (default: auto-detect)",
),
output: Optional[Path] = typer.Option(
None,
"--output", "-o",
help="Output directory for MDX files (default: ~/PraisonAIDocs/docs/sdk/reference/praisonaiagents)",
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Preview without writing files",
),
force: bool = typer.Option(
False,
"--force",
help="Overwrite existing files",
),
module: Optional[str] = typer.Option(
None,
"--module", "-m",
help="Generate docs for specific module only",
),
update_nav: bool = typer.Option(
False,
"--update-nav",
help="Update docs.json navigation",
),
):
"""Generate documentation."""
from praisonai.cli.main import PraisonAI
import sys
"""
Generate SDK reference documentation from Python source code.
argv = ['docs', 'generate', source, '--output', output]
Parses praisonaiagents package using griffe and generates Mintlify-compatible
MDX documentation for all public symbols in _LAZY_IMPORTS.
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
Examples:
praisonai docs generate # Generate all reference docs
praisonai docs generate --dry-run # Preview without writing
praisonai docs generate --module agent # Generate only agent module
praisonai docs generate --update-nav # Also update docs.json
"""
import subprocess
import time
start_time = time.time()
# Default paths
default_package = Path("/Users/praison/praisonai-package/src/praisonai-agents/praisonaiagents")
default_output = Path("/Users/praison/PraisonAIDocs/docs/sdk/reference/praisonaiagents")
generator_script = Path("/Users/praison/PraisonAI-tools/scripts/generate-sdk-docs.py")
pkg_path = package or default_package
out_path = output or default_output
# Validate paths
if not pkg_path.exists():
typer.echo(f"❌ Package path not found: {pkg_path}")
raise typer.Exit(2)
if not generator_script.exists():
typer.echo(f"❌ Generator script not found: {generator_script}")
raise typer.Exit(2)
typer.echo("=" * 60)
typer.echo("PraisonAI SDK Documentation Generator")
typer.echo("=" * 60)
typer.echo(f"Package: {pkg_path}")
typer.echo(f"Output: {out_path}")
typer.echo(f"Dry run: {dry_run}")
if module:
typer.echo(f"Module filter: {module}")
typer.echo()
# Build command (use sys.executable to ensure same Python environment)
import sys as _sys
cmd = [
_sys.executable,
str(generator_script),
"--package", str(pkg_path),
"--output", str(out_path),
]
if dry_run:
cmd.append("--dry-run")
if force:
cmd.append("--force")
if module:
cmd.extend(["--module", module])
if update_nav:
cmd.append("--update-nav")
# Run generator
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
result = subprocess.run(
cmd,
capture_output=False,
text=True,
timeout=120,
)
elapsed = time.time() - start_time
typer.echo(f"\nTotal time: {elapsed:.2f}s")
if elapsed > 60:
typer.echo("⚠️ Warning: Generation took longer than 60s target")
raise typer.Exit(result.returncode)
except subprocess.TimeoutExpired:
typer.echo("❌ Generation timed out after 120s")
raise typer.Exit(1)
except FileNotFoundError:
typer.echo("❌ Python3 not found")
raise typer.Exit(1)
@app.command("serve")
@@ -9,6 +9,56 @@ import typer
app = typer.Typer(help="Recipe management")
@app.command("create")
def recipe_create(
goal: str = typer.Argument(..., help="Natural language goal for the recipe"),
output: str = typer.Option(".", "--output", "-o", help="Output directory"),
no_optimize: bool = typer.Option(False, "--no-optimize", help="Skip optimization loop"),
iterations: int = typer.Option(3, "--iterations", help="Max optimization iterations"),
threshold: float = typer.Option(8.0, "--threshold", help="Score threshold to stop"),
agents: str = typer.Option(None, "--agents", help="Custom agents (format: name:role=X,goal=Y;name2:...)"),
tools: str = typer.Option(None, "--tools", help="Custom tools per agent (format: agent:tool1,tool2;...)"),
agent_types: str = typer.Option(None, "--agent-types", help="Agent types (format: agent:image;agent2:audio;...)"),
):
"""Create a new recipe from a natural language goal.
Examples:
praisonai recipe create "Build a web scraper for news"
praisonai recipe create "Research AI trends" --no-optimize
praisonai recipe create "Research AI" --agents "researcher:role=AI Researcher,goal=Find papers"
praisonai recipe create "Research AI" --tools "researcher:internet_search,arxiv"
"""
from praisonai.cli.main import PraisonAI
import sys
argv = ['recipe', 'create', goal]
if output != ".":
argv.extend(['--output', output])
if no_optimize:
argv.append('--no-optimize')
if iterations != 3:
argv.extend(['--iterations', str(iterations)])
if threshold != 8.0:
argv.extend(['--threshold', str(threshold)])
if agents:
argv.extend(['--agents', agents])
if tools:
argv.extend(['--tools', tools])
if agent_types:
argv.extend(['--agent-types', agent_types])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("list")
def recipe_list():
"""List available recipes."""
@@ -202,6 +252,77 @@ def recipe_judge(
raise typer.Exit(1)
@app.command("optimize")
def recipe_optimize(
recipe_path: str = typer.Argument(..., help="Path to recipe folder"),
target: str = typer.Argument(None, help="Optional optimization target (e.g., 'improve error handling')"),
iterations: int = typer.Option(3, "--iterations", "-i", help="Maximum optimization iterations"),
threshold: float = typer.Option(8.0, "--threshold", "-t", help="Score threshold to stop (1-10)"),
input_data: str = typer.Option("", "--input", help="Input data for recipe runs"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Optimize a recipe using AI judge feedback.
Iteratively runs the recipe, judges output, and applies improvements.
Examples:
praisonai recipe optimize my-recipe
praisonai recipe optimize my-recipe "improve error handling"
praisonai recipe optimize my-recipe --iterations 5 --threshold 9.0
praisonai recipe optimize my-recipe --input '{"query": "test"}'
"""
from pathlib import Path
import logging
if verbose:
logging.basicConfig(level=logging.INFO)
recipe_path_obj = Path(recipe_path)
if not recipe_path_obj.exists():
print(f"❌ Recipe not found: {recipe_path}")
raise typer.Exit(1)
print(f"🔄 Optimizing recipe: {recipe_path}")
print(f" Max iterations: {iterations}")
print(f" Score threshold: {threshold}")
if target:
print(f" Target: {target}")
try:
from praisonai.cli.features.recipe_optimizer import RecipeOptimizer
optimizer = RecipeOptimizer(
max_iterations=iterations,
score_threshold=threshold,
)
final_report = optimizer.optimize(
recipe_path=recipe_path_obj,
input_data=input_data,
optimization_target=target,
)
if final_report:
score = getattr(final_report, 'overall_score', 0)
print(f"\n{'✅' if score >= threshold else '📊'} Final score: {score}/10")
if score >= threshold:
print(" Recipe optimization complete!")
else:
print(" Threshold not reached. Consider running more iterations.")
else:
print("❌ Optimization failed - no report generated")
raise typer.Exit(1)
except ImportError as e:
print(f"❌ Missing dependency: {e}")
print(" Install with: pip install litellm")
raise typer.Exit(1)
except Exception as e:
print(f"❌ Error: {e}")
raise typer.Exit(1)
@app.command("apply")
def recipe_apply(
plan_file: str = typer.Argument(..., help="Plan file to apply (from 'recipe judge')"),
+308 -45
View File
@@ -98,6 +98,8 @@ class RecipeHandler:
"policy": self.cmd_policy,
"judge": self.cmd_judge,
"apply": self.cmd_apply,
"create": self.cmd_create,
"optimize": self.cmd_optimize,
"help": lambda _: self._print_help() or self.EXIT_SUCCESS,
"--help": lambda _: self._print_help() or self.EXIT_SUCCESS,
"-h": lambda _: self._print_help() or self.EXIT_SUCCESS,
@@ -136,6 +138,8 @@ class RecipeHandler:
runs List/manage run history
judge <trace_id> Judge a trace with LLM (--memory, --knowledge, --context)
apply <plan> Apply fixes from a judge plan
create <goal> Create recipe from natural language goal (auto-optimize)
optimize <name> Optimize existing recipe with judge feedback
sbom <recipe> Generate SBOM (Software Bill of Materials)
audit <recipe> Audit dependencies for vulnerabilities
sign <bundle> Sign a recipe bundle
@@ -266,6 +270,88 @@ class RecipeHandler:
return result
def _parse_agents_spec(self, spec_str: str) -> Dict[str, Dict[str, Any]]:
"""Parse agents specification string.
Format: name:role=X,goal=Y;name2:role=Z,goal=W
Returns:
Dict of agent_name -> {role, goal, backstory}
"""
if not spec_str or not spec_str.strip():
return {}
agents = {}
for agent_spec in spec_str.split(";"):
agent_spec = agent_spec.strip()
if not agent_spec or ":" not in agent_spec:
continue
parts = agent_spec.split(":", 1)
agent_name = parts[0].strip()
agent_config = {}
if len(parts) > 1:
for prop in parts[1].split(","):
if "=" in prop:
key, value = prop.split("=", 1)
agent_config[key.strip()] = value.strip()
agents[agent_name] = agent_config
return agents
def _parse_tools_spec(self, spec_str: str) -> Dict[str, List[str]]:
"""Parse tools specification string.
Format: agent:tool1,tool2;agent2:tool3,tool4
Returns:
Dict of agent_name -> [tool1, tool2, ...]
"""
if not spec_str or not spec_str.strip():
return {}
tools = {}
for tool_spec in spec_str.split(";"):
tool_spec = tool_spec.strip()
if not tool_spec or ":" not in tool_spec:
continue
parts = tool_spec.split(":", 1)
agent_name = parts[0].strip()
if len(parts) > 1:
tool_list = [t.strip() for t in parts[1].split(",") if t.strip()]
tools[agent_name] = tool_list
return tools
def _parse_agent_types_spec(self, spec_str: str) -> Dict[str, str]:
"""Parse agent types specification string.
Format: agent:image;agent2:audio
Returns:
Dict of agent_name -> type
"""
if not spec_str or not spec_str.strip():
return {}
agent_types = {}
for type_spec in spec_str.split(";"):
type_spec = type_spec.strip()
if not type_spec or ":" not in type_spec:
continue
parts = type_spec.split(":", 1)
agent_name = parts[0].strip()
if len(parts) > 1:
agent_types[agent_name] = parts[1].strip()
return agent_types
def cmd_list(self, args: List[str]) -> int:
"""List available recipes."""
spec = {
@@ -833,56 +919,53 @@ class RecipeHandler:
output_dir = Path(parsed["output"]) / parsed["name"]
output_dir.mkdir(parents=True, exist_ok=True)
# Create TEMPLATE.yaml
template_yaml = f'''schema_version: "1.0"
name: {parsed["name"]}
version: "1.0.0"
description: |
Description of your recipe.
author: your-name
license: Apache-2.0
tags: [example]
# Create agents.yaml with metadata block (simplified 2-file structure)
agents_yaml = f'''# Recipe metadata (optional, for registry/sharing)
metadata:
name: {parsed["name"]}
version: "1.0.0"
description: |
Description of your recipe.
author: your-name
license: Apache-2.0
tags:
- example
requires:
env:
- OPENAI_API_KEY
requires:
env: [OPENAI_API_KEY]
packages: []
tools:
allow: []
deny: [shell.exec, file.write]
workflow: workflow.yaml
config:
input:
type: string
required: true
description: Input for the recipe
defaults:
input: ""
outputs:
- name: result
type: text
description: Recipe output
'''
(output_dir / "TEMPLATE.yaml").write_text(template_yaml)
# Create workflow.yaml
workflow_yaml = '''framework: praisonai
framework: praisonai
topic: ""
roles:
agents:
assistant:
role: AI Assistant
goal: Complete the task
backstory: You are a helpful AI assistant.
tasks:
main_task:
description: "Process the input: {{{{input}}}}"
expected_output: Processed result
tools:
- read_file
- write_file
steps:
- agent: assistant
action: "Process the input: {{{{input}}}}"
expected_output: Processed result
'''
(output_dir / "workflow.yaml").write_text(workflow_yaml)
(output_dir / "agents.yaml").write_text(agents_yaml)
# Create tools.py
tools_py = '''"""Custom tools for this recipe."""
# Define your custom tools here
# Example:
# def my_custom_tool(query: str) -> str:
# """A custom tool."""
# return f"Result: {query}"
# Dynamic variables can also be defined here
# CUSTOM_VAR = "value"
'''
(output_dir / "tools.py").write_text(tools_py)
# Create README.md
readme = f'''# {parsed["name"]}
@@ -897,7 +980,7 @@ praisonai recipe run {parsed["name"]} --input "your input"
## Configuration
See TEMPLATE.yaml for configuration options.
See agents.yaml for configuration options (metadata block for registry info).
'''
(output_dir / "README.md").write_text(readme)
@@ -910,7 +993,7 @@ OPENAI_API_KEY=your-api-key
self._print_success(f"Recipe '{parsed['name']}' initialized at {output_dir}")
print("\nNext steps:")
print(f" 1. cd {output_dir}")
print(" 2. Edit TEMPLATE.yaml and workflow.yaml")
print(" 2. Edit agents.yaml and tools.py")
print(f" 3. praisonai recipe validate {parsed['name']}")
print(f" 4. praisonai recipe run {parsed['name']} --input 'test'")
@@ -1914,6 +1997,186 @@ OPENAI_API_KEY=your-api-key
return self.EXIT_RUNTIME_ERROR
def cmd_create(self, args: List[str]) -> int:
"""Create a new recipe from a natural language goal.
Usage:
praisonai recipe create "GOAL" [options]
Options:
--output, -o <dir> Output directory (default: current dir)
--no-optimize Skip optimization loop
--iterations <n> Optimization iterations (default: 3)
--threshold <n> Score threshold to stop (default: 8.0)
--agents <spec> Custom agents (format: name:role=X,goal=Y;name2:...)
--tools <spec> Custom tools per agent (format: agent:tool1,tool2;...)
--agent-types <spec> Agent types (format: agent:image;agent2:audio;...)
"""
spec = {
"goal": {"positional": True},
"output": {"short": "-o", "default": "."},
"no_optimize": {"flag": True, "default": False},
"iterations": {"default": "3"},
"threshold": {"default": "8.0"},
"agents": {"default": ""},
"tools": {"default": ""},
"agent_types": {"default": ""},
}
parsed = self._parse_args(args, spec)
if not parsed.get("goal"):
self._print_error("Goal required")
print("\nUsage: praisonai recipe create \"GOAL\" [options]")
print("\nExamples:")
print(' praisonai recipe create "Build a web scraper for news"')
print(' praisonai recipe create "Research AI trends" --no-optimize')
print(' praisonai recipe create "Research AI" --agents "researcher:role=AI Researcher,goal=Find papers"')
print(' praisonai recipe create "Research AI" --tools "researcher:internet_search,arxiv"')
return self.EXIT_VALIDATION_ERROR
goal = parsed["goal"]
output_dir = Path(parsed["output"])
no_optimize = parsed.get("no_optimize", False)
iterations = int(parsed.get("iterations", 3))
threshold = float(parsed.get("threshold", 8.0))
# Parse customization options
agents = self._parse_agents_spec(parsed.get("agents", ""))
tools = self._parse_tools_spec(parsed.get("tools", ""))
agent_types = self._parse_agent_types_spec(parsed.get("agent_types", ""))
print(f"🚀 Creating recipe for: {goal}")
if agents:
print(f" 📋 Custom agents: {', '.join(agents.keys())}")
if tools:
print(f" 🔧 Custom tools: {sum(len(v) for v in tools.values())} tools")
if agent_types:
print(f" 🏷️ Agent types: {', '.join(f'{k}={v}' for k, v in agent_types.items())}")
try:
from .recipe_creator import RecipeCreator
creator = RecipeCreator()
recipe_path = creator.create(
goal,
output_dir=output_dir,
agents=agents if agents else None,
tools=tools if tools else None,
agent_types=agent_types if agent_types else None,
)
self._print_success(f"Created recipe: {recipe_path}")
print(" 📄 agents.yaml (agent definitions + metadata)")
print(" 📄 tools.py (custom functions)")
if not no_optimize:
print(f"\n🔄 Starting optimization loop ({iterations} iterations, threshold: {threshold})")
from .recipe_optimizer import RecipeOptimizer
optimizer = RecipeOptimizer(
max_iterations=iterations,
score_threshold=threshold,
)
final_report = optimizer.optimize(recipe_path)
if final_report:
score = getattr(final_report, 'overall_score', 0)
print(f"\n✅ Optimization complete! Final score: {score}/10")
print(f"\n📁 Recipe ready at: {recipe_path}")
print(f" Run with: praisonai recipe run {recipe_path.name}")
return self.EXIT_SUCCESS
except Exception as e:
self._print_error(str(e))
import traceback
traceback.print_exc()
return self.EXIT_RUNTIME_ERROR
def cmd_optimize(self, args: List[str]) -> int:
"""Optimize an existing recipe using judge feedback.
Usage:
praisonai recipe optimize <NAME> [WHAT] [options]
Options:
--iterations <n> Optimization iterations (default: 3)
--threshold <n> Score threshold to stop (default: 8.0)
--input <text> Input data for recipe runs
"""
spec = {
"name": {"positional": True},
"target": {"positional": True, "default": ""},
"iterations": {"default": "3"},
"threshold": {"default": "8.0"},
"input": {"short": "-i", "default": ""},
}
parsed = self._parse_args(args, spec)
if not parsed.get("name"):
self._print_error("Recipe name required")
print("\nUsage: praisonai recipe optimize <NAME> [WHAT] [options]")
print("\nExamples:")
print(' praisonai recipe optimize my-recipe')
print(' praisonai recipe optimize my-recipe "improve error handling"')
return self.EXIT_VALIDATION_ERROR
name = parsed["name"]
target = parsed.get("target", "")
iterations = int(parsed.get("iterations", 3))
threshold = float(parsed.get("threshold", 8.0))
input_data = parsed.get("input", "")
# Find recipe path
recipe_path = Path(name)
if not recipe_path.exists():
# Try as recipe name in current directory
recipe_path = Path.cwd() / name
if not recipe_path.exists():
# Try in recipes directory
try:
recipe_path = self.recipe.get_recipe_path(name)
except Exception:
pass
if not recipe_path.exists():
self._print_error(f"Recipe not found: {name}")
return self.EXIT_NOT_FOUND
print(f"🔄 Optimizing recipe: {recipe_path.name}")
if target:
print(f" Target: {target}")
try:
from .recipe_optimizer import RecipeOptimizer
optimizer = RecipeOptimizer(
max_iterations=iterations,
score_threshold=threshold,
)
final_report = optimizer.optimize(
recipe_path,
input_data=input_data,
optimization_target=target if target else None,
)
if final_report:
score = getattr(final_report, 'overall_score', 0)
self._print_success(f"Optimization complete! Final score: {score}/10")
return self.EXIT_SUCCESS
except Exception as e:
self._print_error(str(e))
import traceback
traceback.print_exc()
return self.EXIT_RUNTIME_ERROR
def handle_recipe_command(args: List[str]) -> int:
"""Entry point for recipe command."""
handler = RecipeHandler()
@@ -0,0 +1,571 @@
"""
Recipe Creator for PraisonAI.
Automatically generates recipe folders with agents.yaml, TEMPLATE.yaml, and tools.py
based on a natural language goal description.
DRY: Reuses AutoGenerator patterns and SDK knowledge prompt.
"""
import os
import re
import logging
from pathlib import Path
from typing import Dict, List, Optional, Any
from .sdk_knowledge import get_sdk_knowledge_prompt
logger = logging.getLogger(__name__)
class RecipeCreator:
"""
Creates optimized recipes from natural language goals.
Usage:
creator = RecipeCreator()
path = creator.create("Build a web scraper for news articles")
"""
# Tool categories for selection (from auto.py)
TOOL_CATEGORIES = {
'web_search': [
'internet_search', 'duckduckgo', 'tavily_search', 'exa_search',
'search_web', 'ydc_search', 'searxng_search'
],
'web_scraping': [
'scrape_page', 'extract_links', 'crawl', 'extract_text',
'crawl4ai', 'crawl4ai_extract', 'get_article'
],
'file_operations': [
'read_file', 'write_file', 'list_files', 'get_file_info',
'copy_file', 'move_file', 'delete_file'
],
'code_execution': [
'execute_command', 'execute_code', 'analyze_code', 'format_code'
],
'data_processing': [
'read_csv', 'write_csv', 'analyze_csv', 'read_json', 'write_json',
'read_excel', 'write_excel', 'read_yaml', 'write_yaml', 'read_xml'
],
'research': [
'search_arxiv', 'get_arxiv_paper', 'wiki_search', 'wiki_summary',
'get_news_sources', 'get_trending_topics'
],
'finance': [
'get_stock_price', 'get_stock_info', 'get_historical_data'
],
'math': [
'evaluate', 'solve_equation', 'convert_units', 'calculate_statistics'
],
'database': [
'query', 'create_table', 'load_data', 'find_documents', 'vector_search'
]
}
# Keywords that map to tool categories
TASK_KEYWORD_TO_TOOLS = {
'search': 'web_search',
'find': 'web_search',
'look up': 'web_search',
'google': 'web_search',
'scrape': 'web_scraping',
'crawl': 'web_scraping',
'extract': 'web_scraping',
'website': 'web_scraping',
'web page': 'web_scraping',
'file': 'file_operations',
'read': 'file_operations',
'write': 'file_operations',
'save': 'file_operations',
'code': 'code_execution',
'run': 'code_execution',
'execute': 'code_execution',
'script': 'code_execution',
'csv': 'data_processing',
'json': 'data_processing',
'excel': 'data_processing',
'data': 'data_processing',
'research': 'research',
'paper': 'research',
'arxiv': 'research',
'wikipedia': 'research',
'stock': 'finance',
'price': 'finance',
'market': 'finance',
'calculate': 'math',
'math': 'math',
'equation': 'math',
'database': 'database',
'sql': 'database',
'query': 'database',
}
def __init__(
self,
model: Optional[str] = None,
temperature: float = 0.7,
):
"""
Initialize the recipe creator.
Args:
model: LLM model for generation (default: gpt-4o-mini)
temperature: LLM temperature for creativity
"""
self.model = model or os.getenv("OPENAI_MODEL_NAME", "gpt-4o-mini")
self.temperature = temperature
def _get_litellm(self):
"""Lazy import litellm."""
try:
import litellm
return litellm
except ImportError:
raise ImportError(
"litellm is required for recipe creation. "
"Install with: pip install litellm"
)
def generate_folder_name(self, goal: str) -> str:
"""
Generate a kebab-case folder name from the goal.
Args:
goal: Natural language goal description
Returns:
Kebab-case folder name (max 50 chars)
"""
# Remove special characters
clean = re.sub(r'[^a-zA-Z0-9\s]', '', goal)
# Convert to lowercase and split into words
words = clean.lower().split()
# Take first few meaningful words
meaningful_words = []
skip_words = {'a', 'an', 'the', 'for', 'to', 'and', 'or', 'of', 'in', 'on', 'with'}
for word in words:
if word not in skip_words and len(word) > 1:
meaningful_words.append(word)
if len('-'.join(meaningful_words)) >= 40:
break
# Join with hyphens
name = '-'.join(meaningful_words[:6])
# Ensure max length
if len(name) > 50:
name = name[:50].rsplit('-', 1)[0]
return name or 'recipe'
def get_tools_for_task(self, goal: str) -> List[str]:
"""
Analyze goal and return appropriate tools.
Args:
goal: Natural language goal description
Returns:
List of tool names
"""
goal_lower = goal.lower()
matched_categories = set()
# Match keywords to categories
for keyword, category in self.TASK_KEYWORD_TO_TOOLS.items():
if keyword in goal_lower:
matched_categories.add(category)
# Collect tools from matched categories
tools = []
for category in matched_categories:
if category in self.TOOL_CATEGORIES:
# Add first 2-3 tools from each category
tools.extend(self.TOOL_CATEGORIES[category][:3])
# Always include core tools for flexibility
core_tools = ['read_file', 'write_file']
for tool in core_tools:
if tool not in tools:
tools.append(tool)
# Remove duplicates while preserving order
seen = set()
unique_tools = []
for tool in tools:
if tool not in seen:
seen.add(tool)
unique_tools.append(tool)
return unique_tools[:10] # Max 10 tools
def generate_agents_yaml(
self,
goal: str,
agents: Optional[Dict[str, Dict[str, Any]]] = None,
tools: Optional[Dict[str, List[str]]] = None,
agent_types: Optional[Dict[str, str]] = None,
) -> str:
"""
Generate agents.yaml content using LLM with SDK knowledge.
Args:
goal: Natural language goal description
agents: Optional custom agent definitions
tools: Optional custom tool assignments per agent
agent_types: Optional agent type specifications
Returns:
YAML content string
"""
# If custom agents are provided, generate YAML directly without LLM
if agents:
return self._generate_custom_agents_yaml(goal, agents, tools, agent_types)
litellm = self._get_litellm()
# Get recommended tools
task_tools = self.get_tools_for_task(goal)
tools_str = ', '.join(task_tools) if task_tools else 'read_file, write_file'
# Build prompt with SDK knowledge
sdk_knowledge = get_sdk_knowledge_prompt()
# Add customization hints to prompt
customization_hints = ""
if tools:
tools_hint = ", ".join([f"{k}: {','.join(v)}" for k, v in tools.items()])
customization_hints += f"\nUser-specified tools per agent: {tools_hint}"
if agent_types:
types_hint = ", ".join([f"{k}: {v}" for k, v in agent_types.items()])
customization_hints += f"\nUser-specified agent types: {types_hint}"
prompt = f"""{sdk_knowledge}
## Your Task
Create an agents.yaml file for this goal:
"{goal}"
Recommended tools based on the goal: {tools_str}
{customization_hints}
CRITICAL Requirements:
1. Use 1-3 agents depending on task complexity
2. Assign only relevant tools from the recommended list
3. Include clear backstory and expected_output for each task
4. Use sequential workflow unless parallel is clearly needed
5. Output ONLY valid YAML - no markdown, no explanations
CRITICAL - Agent Behavior:
6. Each action MUST specify which tool to use (e.g., "Use internet_search to find...")
7. Actions must be SPECIFIC and ACTIONABLE - agents should act immediately, not ask for input
8. Include concrete examples or default values in actions (e.g., specific URLs, topics)
9. Use {{{{previous_agent}}}}_output to reference previous agent's output in subsequent steps
10. expected_output must describe the exact format expected (list, report, JSON, etc.)
Example of GOOD action:
action: "Use internet_search to find the latest AI trends in 2024. Compile a list of top 5 trends with brief descriptions."
expected_output: "A numbered list of 5 AI trends, each with a 2-3 sentence description and source URL"
Example of BAD action (DO NOT DO THIS):
action: "Research AI trends" # Too vague, agent will ask for clarification
"""
try:
response = litellm.completion(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=self.temperature,
max_tokens=2000,
)
yaml_content = response.choices[0].message.content or ""
# Clean up any markdown code blocks
yaml_content = yaml_content.strip()
if yaml_content.startswith('```'):
lines = yaml_content.split('\n')
yaml_content = '\n'.join(lines[1:-1] if lines[-1].startswith('```') else lines[1:])
# Apply custom tools and types if provided
if tools or agent_types:
yaml_content = self._apply_customizations(yaml_content, tools, agent_types)
# Validate YAML
import yaml
yaml.safe_load(yaml_content)
return yaml_content
except Exception as e:
logger.warning(f"LLM generation failed: {e}, using fallback template")
return self._get_fallback_yaml(goal, task_tools)
def _generate_custom_agents_yaml(
self,
goal: str,
agents: Dict[str, Dict[str, Any]],
tools: Optional[Dict[str, List[str]]] = None,
agent_types: Optional[Dict[str, str]] = None,
) -> str:
"""Generate agents.yaml from custom agent specifications."""
import yaml
# Build the YAML structure
yaml_data = {
'framework': 'praisonai',
'topic': goal,
'agents': {},
'steps': []
}
# Get default tools based on goal if no tools specified
default_tools = self.get_tools_for_task(goal) if not tools else []
for agent_name, agent_config in agents.items():
agent_def = {
'role': agent_config.get('role', agent_name.replace('_', ' ').title()),
'goal': agent_config.get('goal', goal),
'backstory': agent_config.get('backstory', f'Expert {agent_name} specialized in completing tasks.'),
}
# Add tools if specified, otherwise assign default tools
if tools and agent_name in tools:
agent_def['tools'] = tools[agent_name]
elif default_tools:
# Assign default tools based on goal keywords
agent_def['tools'] = default_tools[:3] # Max 3 default tools per agent
# Add type if specified
if agent_types and agent_name in agent_types:
agent_def['type'] = agent_types[agent_name]
# Add LLM default
agent_def['llm'] = 'gpt-4o-mini'
yaml_data['agents'][agent_name] = agent_def
# Add step for this agent
yaml_data['steps'].append({
'agent': agent_name,
'action': agent_config.get('goal', goal),
'expected_output': f"Completed output from {agent_name}"
})
return yaml.dump(yaml_data, default_flow_style=False, sort_keys=False)
def _apply_customizations(
self,
yaml_content: str,
tools: Optional[Dict[str, List[str]]] = None,
agent_types: Optional[Dict[str, str]] = None,
) -> str:
"""Apply tool and type customizations to generated YAML."""
import yaml
try:
data = yaml.safe_load(yaml_content)
if 'agents' in data:
for agent_name, agent_config in data['agents'].items():
# Apply custom tools
if tools and agent_name in tools:
agent_config['tools'] = tools[agent_name]
# Apply custom types
if agent_types and agent_name in agent_types:
agent_config['type'] = agent_types[agent_name]
return yaml.dump(data, default_flow_style=False, sort_keys=False)
except Exception:
# If parsing fails, return original
return yaml_content
def _get_fallback_yaml(self, goal: str, tools: List[str]) -> str:
"""Generate fallback YAML if LLM fails."""
tools_yaml = '\n - '.join(tools[:5]) if tools else 'read_file'
return f'''framework: praisonai
topic: "{goal}"
agents:
assistant:
role: AI Assistant
goal: {goal}
backstory: |
You are a helpful AI assistant specialized in completing tasks efficiently.
You follow instructions carefully and produce high-quality output.
tools:
- {tools_yaml}
steps:
- agent: assistant
action: "{goal}"
expected_output: "Completed task output"
'''
def generate_tools_py(self, tools: List[str]) -> str:
"""
Generate tools.py content with required tool imports.
Args:
tools: List of tool names to include
Returns:
Python code string
"""
# Map tools to their import sources
tool_imports = []
tool_exports = []
for tool in tools:
if tool in ['tavily_search', 'tavily_extract']:
if 'tavily' not in str(tool_imports):
tool_imports.append("from praisonaiagents.tools import tavily_search")
tool_exports.append(tool)
elif tool in ['read_file', 'write_file', 'list_files']:
if 'file_tools' not in str(tool_imports):
tool_imports.append("from praisonaiagents.tools import read_file, write_file, list_files")
tool_exports.append(tool)
elif tool in ['execute_command', 'execute_code']:
if 'execute' not in str(tool_imports):
tool_imports.append("from praisonaiagents.tools import execute_command")
tool_exports.append(tool)
elif tool in ['internet_search', 'duckduckgo']:
if 'duckduckgo' not in str(tool_imports):
tool_imports.append("from praisonaiagents.tools import internet_search")
tool_exports.append('internet_search')
elif tool in ['crawl4ai', 'crawl4ai_extract']:
if 'crawl4ai' not in str(tool_imports):
tool_imports.append("from praisonaiagents.tools import crawl4ai")
tool_exports.append(tool)
# Build the tools.py content
imports_str = '\n'.join(sorted(set(tool_imports))) if tool_imports else '# No specific tool imports needed'
exports_str = ', '.join(sorted(set(tool_exports))) if tool_exports else ''
return f'''"""
Tools for this recipe.
Auto-generated by praisonai recipe create.
"""
{imports_str}
# Export tools for use in agents.yaml
TOOLS = [{exports_str}] if '{exports_str}' else []
def get_all_tools():
"""Get all tools defined in this recipe."""
return TOOLS
'''
def generate_template_yaml(self, name: str, goal: str, tools: List[str]) -> str:
"""
Generate TEMPLATE.yaml metadata file.
Args:
name: Recipe name
goal: Goal description
tools: List of tools used
Returns:
YAML content string
"""
tools_yaml = '\n - '.join(tools[:5]) if tools else 'llm_tool'
return f'''schema_version: "1.0"
name: {name}
version: "1.0.0"
description: |
{goal}
author: auto-generated
license: Apache-2.0
tags:
- auto-generated
requires:
env:
- OPENAI_API_KEY
tools:
- {tools_yaml}
cli:
command: praisonai recipe run {name}
examples:
- praisonai recipe run {name}
- praisonai recipe run {name} --input "custom input"
safety:
dry_run_default: false
overwrites_files: false
'''
def create(
self,
goal: str,
output_dir: Optional[Path] = None,
agents: Optional[Dict[str, Dict[str, Any]]] = None,
tools: Optional[Dict[str, List[str]]] = None,
agent_types: Optional[Dict[str, str]] = None,
) -> Path:
"""
Create a complete recipe folder.
Args:
goal: Natural language goal description
output_dir: Parent directory for recipe (default: current dir)
agents: Optional dict of agent_name -> {role, goal, backstory}
tools: Optional dict of agent_name -> [tool1, tool2, ...]
agent_types: Optional dict of agent_name -> type (image, audio, video, etc.)
Returns:
Path to created recipe folder
"""
output_dir = output_dir or Path.cwd()
# Generate folder name
folder_name = self.generate_folder_name(goal)
recipe_path = output_dir / folder_name
# Create folder
recipe_path.mkdir(parents=True, exist_ok=True)
# Get tools for this goal (use custom tools if provided)
if tools:
# Flatten all tools from custom specification
all_tools = []
for agent_tools in tools.values():
all_tools.extend(agent_tools)
task_tools = list(set(all_tools))
else:
task_tools = self.get_tools_for_task(goal)
# Generate and write agents.yaml
agents_yaml = self.generate_agents_yaml(
goal,
agents=agents,
tools=tools,
agent_types=agent_types,
)
(recipe_path / "agents.yaml").write_text(agents_yaml)
# Generate and write tools.py
tools_py = self.generate_tools_py(task_tools)
(recipe_path / "tools.py").write_text(tools_py)
# Note: TEMPLATE.yaml is no longer generated (simplified 2-file structure)
# Metadata is now embedded in agents.yaml via the 'metadata' block
logger.info(f"Created recipe at: {recipe_path}")
return recipe_path
__all__ = ['RecipeCreator']
@@ -0,0 +1,340 @@
"""
Recipe Optimizer for PraisonAI.
Iteratively optimizes recipes using LLM-as-judge feedback.
Runs the recipe, judges the output, proposes improvements, and applies them.
DRY: Reuses ContextEffectivenessJudge from replay module.
"""
import os
import logging
from pathlib import Path
from typing import Optional, List, Tuple, Any
logger = logging.getLogger(__name__)
class RecipeOptimizer:
"""
Iteratively optimizes recipes using judge feedback.
Usage:
optimizer = RecipeOptimizer(max_iterations=3, score_threshold=8.0)
final_report = optimizer.optimize(recipe_path)
"""
def __init__(
self,
max_iterations: int = 3,
score_threshold: float = 8.0,
model: Optional[str] = None,
):
"""
Initialize the optimizer.
Args:
max_iterations: Maximum optimization iterations
score_threshold: Score threshold to stop optimization (1-10)
model: LLM model for judging and optimization
"""
self.max_iterations = max_iterations
self.score_threshold = score_threshold
self.model = model or os.getenv("OPENAI_MODEL_NAME", "gpt-4o-mini")
def _get_litellm(self):
"""Lazy import litellm."""
try:
import litellm
return litellm
except ImportError:
raise ImportError(
"litellm is required for recipe optimization. "
"Install with: pip install litellm"
)
def should_continue(self, report: Any, iteration: int) -> bool:
"""
Determine if optimization should continue.
Args:
report: JudgeReport from last iteration
iteration: Current iteration number (1-indexed)
Returns:
True if should continue, False if should stop
"""
# Stop if max iterations reached
if iteration >= self.max_iterations:
return False
# Stop if score threshold reached
if hasattr(report, 'overall_score') and report.overall_score >= self.score_threshold:
return False
return True
def run_iteration(
self,
recipe_path: Path,
input_data: str = "",
iteration: int = 1,
) -> Tuple[Any, str]:
"""
Run one optimization iteration: execute recipe and judge output.
Args:
recipe_path: Path to recipe folder
input_data: Input data for recipe
iteration: Current iteration number
Returns:
Tuple of (JudgeReport, trace_id)
"""
from praisonai.recipe import RecipeManager
from praisonai.replay import ContextTraceReader, ContextEffectivenessJudge
# Generate unique trace name for this iteration
trace_name = f"{recipe_path.name}-opt-{iteration}"
# Run the recipe with trace saving
manager = RecipeManager()
result = manager.run(
str(recipe_path),
input={"input": input_data} if input_data else {},
options={
"save_replay": True,
"trace_name": trace_name,
}
)
# Judge the trace
reader = ContextTraceReader(trace_name)
events = reader.get_all()
if not events:
logger.warning(f"No events found for trace: {trace_name}")
# Return a minimal report
from praisonai.replay.judge import JudgeReport
return JudgeReport(
session_id=trace_name,
timestamp="",
total_agents=0,
overall_score=5.0,
agent_scores=[],
summary="No events to judge",
recommendations=["Ensure recipe runs correctly"],
), trace_name
# Run judge
yaml_file = str(recipe_path / "agents.yaml")
judge = ContextEffectivenessJudge(model=self.model)
report = judge.judge_trace(events, session_id=trace_name, yaml_file=yaml_file)
return report, trace_name
def propose_improvements(
self,
report: Any,
recipe_path: Path,
optimization_target: Optional[str] = None,
) -> List[str]:
"""
Use LLM to propose specific YAML improvements based on judge feedback.
Args:
report: JudgeReport with scores and suggestions
recipe_path: Path to recipe folder
optimization_target: Optional specific aspect to optimize
Returns:
List of improvement suggestions
"""
litellm = self._get_litellm()
# Read current agents.yaml
agents_yaml = (recipe_path / "agents.yaml").read_text()
# Build prompt
suggestions = []
if hasattr(report, 'agent_scores'):
for score in report.agent_scores:
if hasattr(score, 'suggestions'):
suggestions.extend(score.suggestions)
recommendations = getattr(report, 'recommendations', [])
overall_score = getattr(report, 'overall_score', 5.0)
prompt = f"""You are an expert at optimizing PraisonAI agent recipes.
Current agents.yaml:
```yaml
{agents_yaml[:3000]}
```
Judge Report:
- Overall Score: {overall_score}/10
- Recommendations: {recommendations}
- Agent Suggestions: {suggestions}
{f"Optimization Target: {optimization_target}" if optimization_target else ""}
Propose 2-3 specific improvements to the agents.yaml file.
For each improvement, explain:
1. What to change
2. Why it will help
3. The exact YAML modification
Be specific and actionable. Focus on:
- Clearer agent instructions
- Better tool selection
- Improved expected_output specifications
- Better workflow structure
"""
try:
response = litellm.completion(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=0.3,
max_tokens=1500,
)
improvements_text = response.choices[0].message.content or ""
# Parse improvements into list
improvements = []
for line in improvements_text.split('\n'):
line = line.strip()
if line.startswith(('1.', '2.', '3.', '-', '*')):
improvements.append(line.lstrip('123.-* '))
return improvements if improvements else [improvements_text]
except Exception as e:
logger.warning(f"Failed to propose improvements: {e}")
return recommendations if recommendations else ["Review agent instructions"]
def apply_improvements(
self,
recipe_path: Path,
improvements: List[str],
) -> bool:
"""
Apply improvements to agents.yaml using LLM.
Args:
recipe_path: Path to recipe folder
improvements: List of improvement suggestions
Returns:
True if improvements were applied
"""
litellm = self._get_litellm()
# Read current agents.yaml
agents_yaml_path = recipe_path / "agents.yaml"
current_yaml = agents_yaml_path.read_text()
prompt = f"""You are an expert at modifying PraisonAI agent YAML files.
Current agents.yaml:
```yaml
{current_yaml}
```
Apply these improvements:
{chr(10).join(f"- {imp}" for imp in improvements)}
Output ONLY the complete, updated agents.yaml file.
Do not include markdown code blocks or explanations.
Ensure the YAML is valid and parseable.
"""
try:
response = litellm.completion(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=0.2,
max_tokens=3000,
)
new_yaml = response.choices[0].message.content or ""
# Clean up any markdown
new_yaml = new_yaml.strip()
if new_yaml.startswith('```'):
lines = new_yaml.split('\n')
new_yaml = '\n'.join(lines[1:-1] if lines[-1].startswith('```') else lines[1:])
# Validate YAML
import yaml
yaml.safe_load(new_yaml)
# Write updated YAML
agents_yaml_path.write_text(new_yaml)
logger.info(f"Applied improvements to {agents_yaml_path}")
return True
except Exception as e:
logger.warning(f"Failed to apply improvements: {e}")
return False
def optimize(
self,
recipe_path: Path,
input_data: str = "",
optimization_target: Optional[str] = None,
) -> Any:
"""
Run the full optimization loop.
Args:
recipe_path: Path to recipe folder
input_data: Input data for recipe runs
optimization_target: Optional specific aspect to optimize
Returns:
Final JudgeReport after optimization
"""
recipe_path = Path(recipe_path)
if not recipe_path.exists():
raise ValueError(f"Recipe path does not exist: {recipe_path}")
final_report = None
for iteration in range(1, self.max_iterations + 1):
logger.info(f"Optimization iteration {iteration}/{self.max_iterations}")
# Run and judge
report, trace_id = self.run_iteration(recipe_path, input_data, iteration)
final_report = report
score = getattr(report, 'overall_score', 0)
logger.info(f" Score: {score}/10")
# Check if we should continue
if not self.should_continue(report, iteration):
if score >= self.score_threshold:
logger.info(f" ✅ Score threshold reached!")
else:
logger.info(f" Max iterations reached")
break
# Propose and apply improvements
improvements = self.propose_improvements(report, recipe_path, optimization_target)
logger.info(f" Proposed {len(improvements)} improvements")
if improvements:
applied = self.apply_improvements(recipe_path, improvements)
if applied:
logger.info(f" ✏️ Applied improvements")
else:
logger.warning(f" Failed to apply improvements")
return final_report
__all__ = ['RecipeOptimizer']
@@ -0,0 +1,376 @@
"""
SDK Knowledge Prompt for Recipe Creation.
Contains comprehensive documentation of PraisonAI Agents SDK features
for LLM-based recipe generation.
DRY: Single source of truth for SDK knowledge used in recipe creation.
"""
SDK_KNOWLEDGE_PROMPT = '''
# PraisonAI Agents SDK Reference
You are an expert at creating PraisonAI agent recipes. Generate a complete agents.yaml file based on the user's goal.
## YAML Structure
```yaml
# Optional: Metadata block for recipe registry/sharing
metadata:
name: recipe-name
version: "1.0.0"
description: What this recipe does
author: your-name
license: Apache-2.0
tags:
- category1
- category2
requires:
env:
- OPENAI_API_KEY
framework: praisonai
topic: "{{topic}}"
# Optional: Define variables for dynamic content
variables:
key: value
agents:
agent_name:
role: Role Title
goal: What the agent aims to achieve
backstory: |
Background and expertise of the agent.
Can be multi-line.
tools:
- tool_name_1
- tool_name_2
# Optional: LLM configuration
llm: gpt-4o-mini
# Optional: Memory configuration
memory: true
# Optional: Knowledge sources
knowledge:
- path/to/file.pdf
- https://example.com/docs
# Workflow steps (optional - defaults to sequential)
steps:
- agent: agent_name
action: "Task description with {{variable}} substitution"
expected_output: "What the agent should produce"
```
## Available Tools by Category
### Web Search
- `internet_search` - DuckDuckGo search
- `duckduckgo` - DuckDuckGo search (alias)
- `tavily_search` - Tavily AI search (requires TAVILY_API_KEY)
- `exa_search` - Exa semantic search (requires EXA_API_KEY)
- `search_web` - Unified search with auto-fallback
### Web Scraping
- `scrape_page` - Extract content from URL
- `crawl4ai` - Async web crawling
- `crawl4ai_extract` - Extract structured data
- `extract_links` - Get all links from page
- `extract_text` - Get text content
### File Operations
- `read_file` - Read file contents
- `write_file` - Write to file
- `list_files` - List directory contents
- `get_file_info` - Get file metadata
- `copy_file` - Copy file
- `move_file` - Move file
- `delete_file` - Delete file
### Code Execution
- `execute_command` - Run shell command
- `execute_code` - Run Python code
- `analyze_code` - Analyze code structure
- `format_code` - Format Python code
### Data Processing
- `read_csv` - Read CSV file
- `write_csv` - Write CSV file
- `read_json` - Read JSON file
- `write_json` - Write JSON file
- `read_excel` - Read Excel file
- `read_yaml` - Read YAML file
### Research
- `search_arxiv` - Search arXiv papers
- `get_arxiv_paper` - Get paper details
- `wiki_search` - Search Wikipedia
- `wiki_summary` - Get Wikipedia summary
### Finance
- `get_stock_price` - Current stock price
- `get_stock_info` - Stock information
- `get_historical_data` - Historical prices
### Math
- `evaluate` - Evaluate math expression
- `solve_equation` - Solve equations
- `convert_units` - Unit conversion
- `calculate_statistics` - Statistical calculations
### Database
- `query` - SQL query
- `vector_search` - Vector similarity search
- `find_documents` - Find documents
## Workflow Patterns
### Sequential (default)
Agents work one after another, passing output to the next.
### Parallel
Multiple agents work concurrently on independent subtasks.
```yaml
steps:
- name: parallel_research
parallel:
- agent: researcher1
action: "Research topic A"
- agent: researcher2
action: "Research topic B"
- agent: aggregator
action: "Combine findings"
```
### Routing
A classifier routes requests to specialized agents.
```yaml
steps:
- agent: classifier
action: "Classify: {{input}}"
- name: routing
route:
technical: [tech_agent]
creative: [creative_agent]
default: [general_agent]
```
### Loop
Iterative refinement until condition is met.
```yaml
steps:
- agent: generator
action: "Generate content"
- name: evaluation_loop
loop:
agent: evaluator
action: "Evaluate content"
condition: "output does not contain 'APPROVED'"
max_iterations: 3
feedback_to: generator
```
## Best Practices
1. **Start Simple**: Use 1-2 agents for simple tasks, 3-4 for complex ones
2. **Clear Roles**: Each agent should have a distinct, non-overlapping role
3. **Specific Tools**: Only assign tools the agent actually needs
4. **Descriptive Backstory**: Include expertise and approach
5. **Expected Output**: Always specify what format/content is expected
6. **Error Handling**: Include instructions for handling edge cases
7. **Proactive Agents**: Agents should USE their tools immediately, not ask for input
8. **Context Flow**: Use {{previous_agent_output}} to pass data between agents
## CRITICAL: Agent Behavior Rules
**Agents MUST be proactive and use their tools immediately.**
BAD (asks for input):
```yaml
steps:
- agent: scraper
action: "Scrape headlines from the website" # Too vague!
```
GOOD (specific and actionable):
```yaml
steps:
- agent: scraper
action: "Use scrape_page tool to extract all news headlines from https://news.ycombinator.com. Return a numbered list of headlines."
expected_output: "A numbered list of 10-20 news headlines extracted from the page"
```
**Context Flow Between Agents:**
```yaml
steps:
- agent: researcher
action: "Use internet_search to find information about {{topic}}. Compile findings into a structured summary."
expected_output: "A structured summary with key findings, sources, and insights"
- agent: writer
action: "Using the research findings from the previous step: {{researcher_output}}, write a comprehensive report."
expected_output: "A well-formatted report with introduction, findings, and conclusion"
```
## Anti-Patterns to Avoid
1. **Too Many Agents**: Don't create agents for simple tasks
2. **Overlapping Roles**: Avoid agents with similar responsibilities
3. **Generic Tools**: Don't assign all tools to every agent
4. **Missing Expected Output**: Always specify what success looks like
5. **Vague Instructions**: Be specific about what the agent should do
6. **Passive Agents**: Never create agents that ask for input - they should act immediately
7. **Missing Tool Usage**: Always specify WHICH tool to use in the action
## Memory Configuration
```yaml
agents:
agent_name:
memory: true # Enable all memory types
# Or configure specific types:
memory:
short_term: true
long_term: true
entity: true
```
## Knowledge Configuration
```yaml
agents:
agent_name:
knowledge:
- ./docs/manual.pdf
- https://example.com/api-docs
- ./data/
```
## Specialized Agent Types
PraisonAI provides specialized agent classes for different use cases:
### Agent (Base)
Standard text-based agent for general tasks.
```yaml
agents:
assistant:
role: AI Assistant
goal: Complete tasks
llm: gpt-4o-mini
```
### ImageAgent
Generates images using AI models (DALL-E, Stable Diffusion).
```yaml
agents:
artist:
type: image # Use ImageAgent
role: Image Generator
goal: Create images from descriptions
llm: dall-e-3 # or openai/dall-e-3
style: natural # natural, vivid
```
### AudioAgent
Text-to-Speech (TTS) and Speech-to-Text (STT) capabilities.
```yaml
agents:
narrator:
type: audio # Use AudioAgent
role: Audio Narrator
goal: Convert text to speech
llm: tts-1 # or tts-1-hd
voice: alloy # alloy, echo, fable, onyx, nova, shimmer
```
### VideoAgent
Generates videos using AI models (Sora, Runway).
```yaml
agents:
video_creator:
type: video # Use VideoAgent
role: Video Generator
goal: Create videos from descriptions
llm: openai/sora-2
```
### DeepResearchAgent
Automated deep research using specialized APIs.
```yaml
agents:
researcher:
type: deep_research # Use DeepResearchAgent
role: Deep Researcher
goal: Conduct comprehensive research
llm: o3-deep-research # or deep-research-pro for Gemini
```
### OCRAgent
Extract text from documents and images.
```yaml
agents:
document_reader:
type: ocr # Use OCRAgent
role: Document Reader
goal: Extract text from images and PDFs
llm: gpt-4o-mini
```
### RouterAgent
Dynamically routes to different models based on task.
```yaml
agents:
smart_router:
type: router # Use RouterAgent
role: Smart Router
goal: Route tasks to optimal models
models:
- gpt-4o-mini
- gpt-4o
routing_strategy: cost-optimized # auto, cost-optimized, performance-optimized
```
## When to Use Specialized Agents
| Goal Keywords | Agent Type | Example |
|---------------|------------|---------|
| image, picture, photo, visual, art | ImageAgent | "Generate product images" |
| audio, speech, voice, narrate, podcast | AudioAgent | "Create podcast narration" |
| video, animation, clip, movie | VideoAgent | "Create promotional video" |
| research, analyze, investigate, study | DeepResearchAgent | "Research market trends" |
| ocr, extract text, document, scan | OCRAgent | "Extract text from receipts" |
| route, optimize, multi-model | RouterAgent | "Smart task routing" |
## Output Format
Generate ONLY valid YAML. Do not include markdown code blocks or explanations.
The YAML must be parseable by Python's yaml.safe_load().
'''
TOOL_SELECTION_PROMPT = '''
Based on the goal, select the most appropriate tools from these categories:
{tool_categories}
Return a comma-separated list of tool names that would be useful for this goal.
Only include tools that are directly relevant to the task.
'''
def get_sdk_knowledge_prompt() -> str:
"""Get the full SDK knowledge prompt."""
return SDK_KNOWLEDGE_PROMPT
def get_tool_selection_prompt(tool_categories: str) -> str:
"""Get the tool selection prompt with categories."""
return TOOL_SELECTION_PROMPT.format(tool_categories=tool_categories)
__all__ = [
'SDK_KNOWLEDGE_PROMPT',
'TOOL_SELECTION_PROMPT',
'get_sdk_knowledge_prompt',
'get_tool_selection_prompt',
]
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==3.10.20 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==3.10.21 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+6 -2
View File
@@ -870,8 +870,12 @@ def _execute_recipe(
template_path = Path(recipe_config.path) if recipe_config.path else None
# Determine workflow file - check for agents.yaml if workflow.yaml not specified
workflow_file = recipe_config.raw.get("workflow", "workflow.yaml")
agents_file = recipe_config.raw.get("agents", "agents.yaml")
workflow_raw = recipe_config.raw.get("workflow", "workflow.yaml")
agents_raw = recipe_config.raw.get("agents", "agents.yaml")
# Handle case where "workflow" or "agents" is a dict (inline definition) vs string (file path)
workflow_file = workflow_raw if isinstance(workflow_raw, str) else "workflow.yaml"
agents_file = "agents.yaml" # Always use agents.yaml as the file path
# If workflow.yaml doesn't exist but agents.yaml does, use agents.yaml as workflow
if template_path:
+87 -2
View File
@@ -136,13 +136,18 @@ class TemplateLoader:
return template_config
def _parse_template_file(self, template_dir: Path) -> TemplateConfig:
"""Parse TEMPLATE.yaml file."""
"""Parse TEMPLATE.yaml file, or extract metadata from agents.yaml if absent."""
import yaml
template_file = template_dir / self.TEMPLATE_FILE
if not template_file.exists():
# Try to infer from directory name
# Try to extract metadata from agents.yaml (simplified 2-file structure)
agents_file = template_dir / "agents.yaml"
if agents_file.exists():
return self._parse_agents_yaml_metadata(template_dir, agents_file)
# Fallback to directory name
return TemplateConfig(
name=template_dir.name,
path=template_dir
@@ -190,6 +195,86 @@ class TemplateLoader:
path=template_dir
)
def _parse_agents_yaml_metadata(
self,
template_dir: Path,
agents_file: Path
) -> TemplateConfig:
"""
Extract metadata from agents.yaml for simplified 2-file recipe structure.
Supports optional 'metadata' block in agents.yaml:
```yaml
metadata:
name: my-recipe
version: "1.0.0"
description: What this recipe does
author: author-name
license: MIT
tags: [tag1, tag2]
requires:
env: [API_KEY]
framework: praisonai
agents:
...
```
Args:
template_dir: Path to the template directory
agents_file: Path to agents.yaml file
Returns:
TemplateConfig with metadata extracted from agents.yaml
"""
import yaml
with open(agents_file) as f:
raw = yaml.safe_load(f) or {}
# Extract metadata block if present
metadata = raw.get("metadata", {})
# Extract requires from metadata
requires = metadata.get("requires", {})
if isinstance(requires, list):
requires = {"tools": requires}
# Use agents.yaml as the workflow file (it contains agents and steps)
workflow_file = "agents.yaml"
agents_file_ref = "agents.yaml"
# Build raw config for compatibility
raw_config = {
**raw,
"name": metadata.get("name", template_dir.name),
"version": metadata.get("version", "1.0.0"),
"description": metadata.get("description", ""),
"author": metadata.get("author"),
"license": metadata.get("license"),
"tags": metadata.get("tags", []),
"requires": requires,
}
return TemplateConfig(
name=metadata.get("name", template_dir.name),
description=metadata.get("description", ""),
version=metadata.get("version", "1.0.0"),
author=metadata.get("author"),
license=metadata.get("license"),
tags=metadata.get("tags", []),
requires=requires,
workflow_file=workflow_file,
agents_file=agents_file_ref,
config_schema=metadata.get("config", {}),
defaults=metadata.get("defaults", {}),
skills=raw.get("skills", []),
cli=metadata.get("cli", {}),
runtime=None,
raw=raw_config,
path=template_dir
)
def _merge_config(
self,
template: TemplateConfig,
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.10.20"
__version__ = "3.10.21"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=0.13.18",
"praisonaiagents>=0.13.19",
"python-dotenv>=0.19.0",
"litellm>=1.81.0",
"PyYAML>=6.0",
@@ -0,0 +1,434 @@
"""
Tests for recipe create and optimize commands.
TDD: These tests are written FIRST before implementation.
"""
from pathlib import Path
from unittest.mock import MagicMock, patch
import tempfile
class TestRecipeCreatorFolderName:
"""Tests for folder name generation."""
def test_generate_folder_name_kebab_case(self):
"""Test that folder names are kebab-case."""
from praisonai.cli.features.recipe_creator import RecipeCreator
creator = RecipeCreator()
name = creator.generate_folder_name("Build a Web Scraper for News")
assert "-" in name or name.islower()
assert " " not in name
assert name.islower()
def test_generate_folder_name_max_length(self):
"""Test that folder names are max 50 chars."""
from praisonai.cli.features.recipe_creator import RecipeCreator
creator = RecipeCreator()
long_goal = "Build a comprehensive web scraper that extracts news articles from multiple sources and summarizes them"
name = creator.generate_folder_name(long_goal)
assert len(name) <= 50
def test_generate_folder_name_removes_special_chars(self):
"""Test that special characters are removed."""
from praisonai.cli.features.recipe_creator import RecipeCreator
creator = RecipeCreator()
name = creator.generate_folder_name("Build a scraper! @#$%")
assert "@" not in name
assert "#" not in name
assert "!" not in name
class TestRecipeCreatorAgentsYaml:
"""Tests for agents.yaml generation."""
@patch('praisonai.cli.features.recipe_creator.RecipeCreator._get_litellm')
def test_generate_agents_yaml_calls_llm(self, mock_get_litellm):
"""Test that LLM is called for agents.yaml generation."""
from praisonai.cli.features.recipe_creator import RecipeCreator
mock_litellm = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = """framework: praisonai
topic: "Web Scraper"
agents:
scraper:
role: Web Scraper
goal: Scrape web pages
backstory: Expert at web scraping
"""
mock_litellm.completion.return_value = mock_response
mock_get_litellm.return_value = mock_litellm
creator = RecipeCreator()
yaml_content = creator.generate_agents_yaml("Build a web scraper")
mock_litellm.completion.assert_called_once()
assert "framework: praisonai" in yaml_content or "agents:" in yaml_content
@patch('praisonai.cli.features.recipe_creator.RecipeCreator._get_litellm')
def test_generate_agents_yaml_includes_sdk_knowledge(self, mock_get_litellm):
"""Test that SDK knowledge prompt is included."""
from praisonai.cli.features.recipe_creator import RecipeCreator
mock_litellm = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "framework: praisonai\nagents: {}"
mock_litellm.completion.return_value = mock_response
mock_get_litellm.return_value = mock_litellm
creator = RecipeCreator()
creator.generate_agents_yaml("Build a web scraper")
# Check that the prompt contains SDK knowledge
call_args = mock_litellm.completion.call_args
messages = call_args.kwargs.get('messages', call_args[1].get('messages', []))
prompt = str(messages)
# Should mention key SDK concepts
assert "agent" in prompt.lower() or "tool" in prompt.lower()
class TestRecipeCreatorToolsPy:
"""Tests for tools.py generation."""
def test_generate_tools_py_imports_correct_tools(self):
"""Test that tools.py imports the right tools."""
from praisonai.cli.features.recipe_creator import RecipeCreator
creator = RecipeCreator()
tools_content = creator.generate_tools_py(["tavily_search", "read_file", "write_file"])
assert "tavily" in tools_content.lower() or "search" in tools_content.lower()
assert "def " in tools_content or "import " in tools_content
def test_generate_tools_py_valid_python(self):
"""Test that generated tools.py is valid Python."""
from praisonai.cli.features.recipe_creator import RecipeCreator
creator = RecipeCreator()
tools_content = creator.generate_tools_py(["read_file"])
# Should be valid Python (no syntax errors)
compile(tools_content, '<string>', 'exec')
class TestRecipeCreatorCreate:
"""Tests for the main create method."""
@patch('praisonai.cli.features.recipe_creator.RecipeCreator._get_litellm')
def test_create_returns_path(self, mock_get_litellm):
"""Test that create() returns the recipe path."""
from praisonai.cli.features.recipe_creator import RecipeCreator
mock_litellm = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = """framework: praisonai
topic: "Test"
agents:
test_agent:
role: Tester
goal: Test things
backstory: Expert tester
"""
mock_litellm.completion.return_value = mock_response
mock_get_litellm.return_value = mock_litellm
with tempfile.TemporaryDirectory() as tmpdir:
creator = RecipeCreator()
path = creator.create("Build a test recipe", output_dir=Path(tmpdir))
assert path.exists()
assert path.is_dir()
@patch('praisonai.cli.features.recipe_creator.RecipeCreator._get_litellm')
def test_create_creates_all_files(self, mock_get_litellm):
"""Test that create() creates all required files."""
from praisonai.cli.features.recipe_creator import RecipeCreator
mock_litellm = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = """framework: praisonai
topic: "Test"
agents:
test_agent:
role: Tester
goal: Test things
backstory: Expert tester
"""
mock_litellm.completion.return_value = mock_response
mock_get_litellm.return_value = mock_litellm
with tempfile.TemporaryDirectory() as tmpdir:
creator = RecipeCreator()
path = creator.create("Build a test recipe", output_dir=Path(tmpdir))
assert (path / "agents.yaml").exists()
assert (path / "TEMPLATE.yaml").exists()
assert (path / "tools.py").exists()
class TestRecipeOptimizer:
"""Tests for RecipeOptimizer class."""
def test_should_continue_respects_threshold(self):
"""Test that optimization stops when score threshold is reached."""
from praisonai.cli.features.recipe_optimizer import RecipeOptimizer
optimizer = RecipeOptimizer(score_threshold=8.0, max_iterations=3)
# Mock report with high score
mock_report = MagicMock()
mock_report.overall_score = 9.0
assert optimizer.should_continue(mock_report, iteration=1) is False
def test_should_continue_respects_max_iterations(self):
"""Test that optimization stops at max iterations."""
from praisonai.cli.features.recipe_optimizer import RecipeOptimizer
optimizer = RecipeOptimizer(score_threshold=8.0, max_iterations=3)
# Mock report with low score
mock_report = MagicMock()
mock_report.overall_score = 5.0
assert optimizer.should_continue(mock_report, iteration=3) is False
assert optimizer.should_continue(mock_report, iteration=2) is True
class TestCmdCreate:
"""Tests for cmd_create CLI command."""
@patch('praisonai.cli.features.recipe_creator.RecipeCreator')
def test_cmd_create_parses_goal(self, mock_creator_class):
"""Test that cmd_create parses the goal argument."""
from praisonai.cli.features.recipe import RecipeHandler
mock_creator = MagicMock()
mock_creator.create.return_value = Path("/tmp/test-recipe")
mock_creator_class.return_value = mock_creator
handler = RecipeHandler()
with patch.object(handler, '_print_success'):
handler.cmd_create(["Build a web scraper", "--no-optimize"])
mock_creator.create.assert_called_once()
call_args = mock_creator.create.call_args
assert "Build a web scraper" in str(call_args)
class TestRecipeCreatorCustomization:
"""Tests for recipe creation customization options."""
@patch('praisonai.cli.features.recipe_creator.RecipeCreator._get_litellm')
def test_create_with_agents_parameter(self, mock_get_litellm):
"""Test that agents parameter overrides LLM generation."""
from praisonai.cli.features.recipe_creator import RecipeCreator
mock_litellm = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = """framework: praisonai
topic: "Test"
agents:
researcher:
role: AI Researcher
goal: Research topics
backstory: Expert researcher
"""
mock_litellm.completion.return_value = mock_response
mock_get_litellm.return_value = mock_litellm
with tempfile.TemporaryDirectory() as tmpdir:
creator = RecipeCreator()
# Specify custom agents
custom_agents = {
"my_agent": {
"role": "Custom Role",
"goal": "Custom Goal",
"backstory": "Custom backstory"
}
}
path = creator.create(
"Test goal",
output_dir=Path(tmpdir),
agents=custom_agents
)
# Read the generated agents.yaml
agents_yaml = (path / "agents.yaml").read_text()
# Should contain custom agent
assert "my_agent" in agents_yaml or "Custom Role" in agents_yaml
@patch('praisonai.cli.features.recipe_creator.RecipeCreator._get_litellm')
def test_create_with_tools_parameter(self, mock_get_litellm):
"""Test that tools parameter assigns tools to agents."""
from praisonai.cli.features.recipe_creator import RecipeCreator
mock_litellm = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = """framework: praisonai
topic: "Test"
agents:
researcher:
role: Researcher
goal: Research
backstory: Expert
tools:
- internet_search
"""
mock_litellm.completion.return_value = mock_response
mock_get_litellm.return_value = mock_litellm
with tempfile.TemporaryDirectory() as tmpdir:
creator = RecipeCreator()
# Specify custom tools per agent
custom_tools = {
"researcher": ["tavily_search", "arxiv_search"]
}
path = creator.create(
"Research AI",
output_dir=Path(tmpdir),
tools=custom_tools
)
# Read the generated agents.yaml
agents_yaml = (path / "agents.yaml").read_text()
# Should contain specified tools
assert "tavily_search" in agents_yaml or "arxiv_search" in agents_yaml
@patch('praisonai.cli.features.recipe_creator.RecipeCreator._get_litellm')
def test_create_with_agent_types_parameter(self, mock_get_litellm):
"""Test that agent_types parameter sets agent type."""
from praisonai.cli.features.recipe_creator import RecipeCreator
mock_litellm = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = """framework: praisonai
topic: "Test"
agents:
artist:
role: Artist
goal: Create images
backstory: Expert artist
type: image
"""
mock_litellm.completion.return_value = mock_response
mock_get_litellm.return_value = mock_litellm
with tempfile.TemporaryDirectory() as tmpdir:
creator = RecipeCreator()
# Specify agent types
custom_types = {
"artist": "image"
}
path = creator.create(
"Generate images",
output_dir=Path(tmpdir),
agent_types=custom_types
)
# Read the generated agents.yaml
agents_yaml = (path / "agents.yaml").read_text()
# Should contain type specification
assert "type:" in agents_yaml or "image" in agents_yaml
class TestCmdCreateCustomization:
"""Tests for cmd_create CLI customization options."""
@patch('praisonai.cli.features.recipe_creator.RecipeCreator')
def test_cmd_create_with_agents_option(self, mock_creator_class):
"""Test that --agents CLI option is parsed correctly."""
from praisonai.cli.features.recipe import RecipeHandler
mock_creator = MagicMock()
mock_creator.create.return_value = Path("/tmp/test-recipe")
mock_creator_class.return_value = mock_creator
handler = RecipeHandler()
with patch.object(handler, '_print_success'):
handler.cmd_create([
"Build a research tool",
"--agents", "researcher:role=AI Researcher,goal=Find papers",
"--no-optimize"
])
mock_creator.create.assert_called_once()
call_kwargs = mock_creator.create.call_args.kwargs
assert 'agents' in call_kwargs or 'agents' in str(mock_creator.create.call_args)
@patch('praisonai.cli.features.recipe_creator.RecipeCreator')
def test_cmd_create_with_tools_option(self, mock_creator_class):
"""Test that --tools CLI option is parsed correctly."""
from praisonai.cli.features.recipe import RecipeHandler
mock_creator = MagicMock()
mock_creator.create.return_value = Path("/tmp/test-recipe")
mock_creator_class.return_value = mock_creator
handler = RecipeHandler()
with patch.object(handler, '_print_success'):
handler.cmd_create([
"Build a research tool",
"--tools", "researcher:internet_search,arxiv",
"--no-optimize"
])
mock_creator.create.assert_called_once()
call_kwargs = mock_creator.create.call_args.kwargs
assert 'tools' in call_kwargs or 'tools' in str(mock_creator.create.call_args)
class TestCmdOptimize:
"""Tests for cmd_optimize CLI command."""
@patch('praisonai.cli.features.recipe_optimizer.RecipeOptimizer')
def test_cmd_optimize_parses_args(self, mock_optimizer_class):
"""Test that cmd_optimize parses name and target."""
from praisonai.cli.features.recipe import RecipeHandler
mock_optimizer = MagicMock()
mock_report = MagicMock()
mock_report.overall_score = 8.5
mock_optimizer.optimize.return_value = mock_report
mock_optimizer_class.return_value = mock_optimizer
handler = RecipeHandler()
# Create a temp directory to simulate recipe path
with tempfile.TemporaryDirectory() as tmpdir:
recipe_path = Path(tmpdir) / "test-recipe"
recipe_path.mkdir()
(recipe_path / "agents.yaml").write_text("framework: praisonai")
with patch.object(handler, '_print_success'):
with patch('pathlib.Path.cwd', return_value=Path(tmpdir)):
handler.cmd_optimize(["test-recipe", "improve error handling"])
mock_optimizer.optimize.assert_called_once()
+175
View File
@@ -1556,6 +1556,181 @@ agents:
assert "env" in output.lower() or "Env" in output or "Environment" in output
class TestSimplifiedRecipeStructure:
"""Tests for simplified 2-file recipe structure (agents.yaml + tools.py)."""
@pytest.fixture
def temp_simplified_recipe(self):
"""Create a temporary recipe with only agents.yaml + tools.py (no TEMPLATE.yaml)."""
temp_dir = tempfile.mkdtemp()
recipe_dir = Path(temp_dir) / "my-recipe"
recipe_dir.mkdir()
# Create agents.yaml with metadata block
(recipe_dir / "agents.yaml").write_text("""
metadata:
name: my-recipe
version: "2.0.0"
description: A simplified recipe
author: test-author
license: MIT
tags:
- test
- simplified
requires:
env:
- TEST_API_KEY
framework: praisonai
topic: "Test topic"
agents:
assistant:
role: AI Assistant
goal: Help with tasks
tools:
- my_custom_tool
steps:
- agent: assistant
action: "Do the task"
expected_output: "Task result"
""")
# Create tools.py
(recipe_dir / "tools.py").write_text('''
"""Custom tools for this recipe."""
def my_custom_tool(query: str) -> str:
"""A custom tool."""
return f"Result: {query}"
# Dynamic variable
CUSTOM_VAR = "test_value"
''')
yield recipe_dir
shutil.rmtree(temp_dir)
@pytest.fixture
def temp_legacy_recipe(self):
"""Create a legacy recipe with TEMPLATE.yaml (for backward compat testing)."""
temp_dir = tempfile.mkdtemp()
recipe_dir = Path(temp_dir) / "legacy-recipe"
recipe_dir.mkdir()
# Create TEMPLATE.yaml (legacy)
(recipe_dir / "TEMPLATE.yaml").write_text("""
name: legacy-recipe
version: "1.0.0"
description: A legacy recipe with TEMPLATE.yaml
author: legacy-author
requires:
env:
- LEGACY_API_KEY
""")
# Create agents.yaml (without metadata)
(recipe_dir / "agents.yaml").write_text("""
framework: praisonai
topic: "Legacy topic"
agents:
worker:
role: Worker
goal: Do work
tools:
- read_file
steps:
- agent: worker
action: "Work"
""")
# Create tools.py
(recipe_dir / "tools.py").write_text('# Empty tools\n')
yield recipe_dir
shutil.rmtree(temp_dir)
def test_load_simplified_recipe_metadata(self, temp_simplified_recipe):
"""Test that metadata is extracted from agents.yaml when TEMPLATE.yaml is absent."""
from praisonai.templates.loader import TemplateLoader
loader = TemplateLoader()
template = loader.load(str(temp_simplified_recipe))
# Metadata should be extracted from agents.yaml
assert template.name == "my-recipe"
assert template.version == "2.0.0"
assert template.description == "A simplified recipe"
assert template.author == "test-author"
assert template.license == "MIT"
assert "test" in template.tags
assert "simplified" in template.tags
assert "TEST_API_KEY" in template.requires.get("env", [])
def test_legacy_recipe_still_works(self, temp_legacy_recipe):
"""Test that legacy recipes with TEMPLATE.yaml still work."""
from praisonai.templates.loader import TemplateLoader
loader = TemplateLoader()
template = loader.load(str(temp_legacy_recipe))
# Should use TEMPLATE.yaml metadata
assert template.name == "legacy-recipe"
assert template.version == "1.0.0"
assert template.author == "legacy-author"
def test_template_yaml_takes_precedence(self, temp_simplified_recipe):
"""Test that TEMPLATE.yaml takes precedence over agents.yaml metadata."""
# Add TEMPLATE.yaml to the simplified recipe
(temp_simplified_recipe / "TEMPLATE.yaml").write_text("""
name: override-name
version: "3.0.0"
author: override-author
""")
from praisonai.templates.loader import TemplateLoader
loader = TemplateLoader()
template = loader.load(str(temp_simplified_recipe))
# TEMPLATE.yaml should take precedence
assert template.name == "override-name"
assert template.version == "3.0.0"
assert template.author == "override-author"
def test_tools_py_loaded(self, temp_simplified_recipe):
"""Test that tools.py is loaded and custom tools are available."""
from praisonai.templates.tool_override import create_tool_registry_with_overrides
registry = create_tool_registry_with_overrides(
template_dir=str(temp_simplified_recipe)
)
# Custom tool should be in registry
assert "my_custom_tool" in registry
# Test the tool works
result = registry["my_custom_tool"]("test")
assert "Result: test" == result
def test_workflow_config_loads_from_agents_yaml(self, temp_simplified_recipe):
"""Test that workflow config is loaded from agents.yaml."""
from praisonai.templates.loader import TemplateLoader
loader = TemplateLoader()
template = loader.load(str(temp_simplified_recipe))
# agents.yaml should be used as workflow file
workflow_config = loader.load_workflow_config(template)
assert "agents" in workflow_config
assert "assistant" in workflow_config["agents"]
assert workflow_config["agents"]["assistant"]["role"] == "AI Assistant"
class TestCLIToolsDoctor:
"""CLI tests for tools doctor command."""
+8 -8
View File
@@ -3899,10 +3899,10 @@ requires-dist = [
{ name = "autogen-agentchat", marker = "extra == 'autogen-v4'", specifier = ">=0.4.0" },
{ name = "autogen-core", marker = "extra == 'autogen-v4'", specifier = ">=0.4.0" },
{ name = "autogen-ext", extras = ["openai"], marker = "extra == 'autogen-v4'", specifier = ">=0.4.0" },
{ name = "chainlit", marker = "extra == 'chat'", specifier = ">=2.8.5,<2.9.0" },
{ name = "chainlit", marker = "extra == 'code'", specifier = ">=2.8.5,<2.9.0" },
{ name = "chainlit", marker = "extra == 'realtime'", specifier = ">=2.8.5,<2.9.0" },
{ name = "chainlit", marker = "extra == 'ui'", specifier = ">=2.8.5,<2.9.0" },
{ name = "chainlit", marker = "extra == 'chat'", specifier = ">=2.8.5,<=2.9.4" },
{ name = "chainlit", marker = "extra == 'code'", specifier = ">=2.8.5,<=2.9.4" },
{ name = "chainlit", marker = "extra == 'realtime'", specifier = ">=2.8.5,<=2.9.4" },
{ name = "chainlit", marker = "extra == 'ui'", specifier = ">=2.8.5,<=2.9.4" },
{ name = "crawl4ai", marker = "extra == 'chat'", specifier = ">=0.7.0" },
{ name = "crawl4ai", marker = "extra == 'code'", specifier = ">=0.7.0" },
{ name = "crawl4ai", marker = "extra == 'realtime'", specifier = ">=0.7.0" },
@@ -3928,7 +3928,7 @@ requires-dist = [
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
{ name = "praisonaiagents", specifier = ">=0.13.18" },
{ name = "praisonaiagents", specifier = ">=0.13.19" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
{ name = "pyngrok", marker = "extra == 'call'", specifier = ">=1.4.0" },
{ name = "pyparsing", specifier = ">=3.0.0" },
@@ -3973,7 +3973,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.13.18"
version = "0.13.19"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -3982,9 +3982,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cf/e1/52093a988240b43dd1704c36c67f125b55b2c4299e835a1a17de0f12804f/praisonaiagents-0.13.18.tar.gz", hash = "sha256:ccc8ce6faf6ba21a2311cfbd0e447d289ec81c8d577446c978c413e2752aad26", size = 1094127, upload-time = "2026-01-25T00:33:29.15Z" }
sdist = { url = "https://files.pythonhosted.org/packages/dd/fb/d0a25938d9232c62d99b80199d51e80b9330c075c8222e541ba425e495a9/praisonaiagents-0.13.19.tar.gz", hash = "sha256:3bf484fc0d4aafa51e0ea30d2860487f0ae2ebd00580ba2c7b86eb4af6c8a35e", size = 1101799, upload-time = "2026-01-26T08:06:18.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/d1/7794eee8a0760f458ec2fb1cf1509ba716949adaa600d1594b3f6e13282c/praisonaiagents-0.13.18-py3-none-any.whl", hash = "sha256:acc5e06b337d095430be07d794892ac7935420c3902abe29aa77c75bb871aacb", size = 1068404, upload-time = "2026-01-25T00:33:27.04Z" },
{ url = "https://files.pythonhosted.org/packages/2f/fa/98b85ab9fed45acaf2b184eab25fe0425001371142439b8a0fe7820e182e/praisonaiagents-0.13.19-py3-none-any.whl", hash = "sha256:ead5562726d5349a63fcc571a12132f205f625046294da82e692ea4285e00654", size = 1077267, upload-time = "2026-01-26T08:06:15.391Z" },
]
[[package]]