mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 12:47:55 -10:00
Release v3.10.21
This commit is contained in:
1 parent
2e1ac193a5
commit
fd600d81d1
65 files changed
+7130
-194
No files matched your search
@@ -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]"
|
||||
|
||||
|
||||
@@ -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]" \
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
File renamed without changes.
@@ -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)
|
||||
File renamed without changes.
@@ -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)
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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)
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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__":
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
Generated
+1
-1
@@ -2549,7 +2549,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.13.18"
|
||||
version = "0.13.19"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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')"),
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 @@
|
||||
__version__ = "3.10.20"
|
||||
__version__ = "3.10.21"
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Generated
+8
-8
@@ -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]]
|
||||
|
||||
Reference in new issue
Block a user