mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
fix(lint): resolve all ruff + black errors across all packages
Fix 35 ruff errors (E722, F841, B904, B905, B007, E741, S202, S310, S306, S110, TID251, UP042, F401) and run black on 526 files. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
CONTENT_TRUNCATED_NOTICE = "<response clipped><NOTE>Due to the max output limit, only part of the full response has been shown to you.</NOTE>"
|
||||
|
||||
FILE_CONTENT_TRUNCATED_NOTICE: str = "<response clipped><NOTE>Due to the max output limit, only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>"
|
||||
FILE_CONTENT_TRUNCATED_NOTICE: str = (
|
||||
"<response clipped><NOTE>Due to the max output limit, only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>"
|
||||
)
|
||||
|
||||
DIRECTORY_CONTENT_TRUNCATED_NOTICE: str = "<response clipped><NOTE>Due to the max output limit, only part of this directory has been shown to you. You should use `ls -la` instead to view large directories incrementally.</NOTE>"
|
||||
DIRECTORY_CONTENT_TRUNCATED_NOTICE: str = (
|
||||
"<response clipped><NOTE>Due to the max output limit, only part of this directory has been shown to you. You should use `ls -la` instead to view large directories incrementally.</NOTE>"
|
||||
)
|
||||
|
||||
@@ -32,9 +32,9 @@ def test_file_editor_happy_path(temp_file):
|
||||
result,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, (
|
||||
"Output does not contain the expected <oh_aci_output_> tags in the correct format."
|
||||
)
|
||||
assert (
|
||||
match
|
||||
), "Output does not contain the expected <oh_aci_output_> tags in the correct format."
|
||||
result_dict = json.loads(match.group(1))
|
||||
|
||||
# Validate the formatted output in the result dictionary
|
||||
@@ -89,16 +89,14 @@ match = re.search(
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
assert match, (
|
||||
"Output does not contain the expected <oh_aci_output_> tags in the correct format."
|
||||
)
|
||||
assert (
|
||||
match
|
||||
), "Output does not contain the expected <oh_aci_output_> tags in the correct format."
|
||||
result_dict = json.loads(match.group(1))
|
||||
|
||||
# Validate the formatted output in the result dictionary
|
||||
formatted_output = result_dict["formatted_output_and_error"]
|
||||
assert (
|
||||
formatted_output
|
||||
== f"""Here's the result of running `cat -n` on {temp_file}:
|
||||
assert formatted_output == f"""Here's the result of running `cat -n` on {temp_file}:
|
||||
1\tThis is a file with XML tags parsing logic...
|
||||
2\tmatch = re.search(
|
||||
3\t r'<oh_aci_output_[0-9a-f]{{32}}>(.*?)</oh_aci_output_[0-9a-f]{{32}}>',
|
||||
@@ -108,7 +106,6 @@ match = re.search(
|
||||
7\t...More text here.
|
||||
8\t
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_successful_operations(temp_file):
|
||||
|
||||
@@ -256,9 +256,9 @@ def test_large_history_insert():
|
||||
|
||||
# Check if we can still retrieve the last entry
|
||||
last_content = manager.pop_last_history(Path(f"test_file_{num_files - 1}.txt"))
|
||||
assert last_content == large_content, (
|
||||
"Failed to retrieve the last inserted content"
|
||||
)
|
||||
assert (
|
||||
last_content == large_content
|
||||
), "Failed to retrieve the last inserted content"
|
||||
|
||||
# Check if the number of cache entries is correct
|
||||
cache_entries = list(manager.cache)
|
||||
@@ -266,6 +266,4 @@ def test_large_history_insert():
|
||||
len(cache_entries)
|
||||
== num_files * 2
|
||||
- 1 # The cache entry for file content was removed, only metadata remains
|
||||
), (
|
||||
f"Expected {num_files * 2 - 1} cache entries ({num_files - 1} content + {num_files} metadata), but found {len(cache_entries)}"
|
||||
)
|
||||
), f"Expected {num_files * 2 - 1} cache entries ({num_files - 1} content + {num_files} metadata), but found {len(cache_entries)}"
|
||||
|
||||
@@ -8,13 +8,10 @@ def foo():
|
||||
foo()
|
||||
"""
|
||||
|
||||
NEW_CONTENT_V1 = (
|
||||
OLD_CONTENT
|
||||
+ """
|
||||
NEW_CONTENT_V1 = OLD_CONTENT + """
|
||||
def new_function_that_causes_error():
|
||||
y = ANOTHER_UNDEFINED_VARIABLE
|
||||
"""
|
||||
)
|
||||
|
||||
NEW_CONTENT_V2 = """
|
||||
def foo():
|
||||
@@ -28,9 +25,7 @@ foo()
|
||||
def test_get_and_parse_diff(tmp_path):
|
||||
diff = get_diff(OLD_CONTENT, NEW_CONTENT_V1, "test.py")
|
||||
print(diff)
|
||||
assert (
|
||||
diff
|
||||
== """
|
||||
assert diff == """
|
||||
--- test.py
|
||||
+++ test.py
|
||||
@@ -6,0 +7,3 @@
|
||||
@@ -38,7 +33,6 @@ def test_get_and_parse_diff(tmp_path):
|
||||
+ y = ANOTHER_UNDEFINED_VARIABLE
|
||||
+
|
||||
""".strip()
|
||||
)
|
||||
|
||||
print(
|
||||
"\n".join(
|
||||
|
||||
@@ -66,7 +66,9 @@ async def main():
|
||||
print("Outline is not a scifi story, so we stop here.")
|
||||
exit(0)
|
||||
|
||||
print("Outline is good quality and a scifi story, so we continue to write the story.")
|
||||
print(
|
||||
"Outline is good quality and a scifi story, so we continue to write the story."
|
||||
)
|
||||
|
||||
# 4. Write the story
|
||||
story_result = await Runner.run(
|
||||
|
||||
@@ -43,7 +43,9 @@ guardrail_agent = Agent(
|
||||
|
||||
@input_guardrail
|
||||
async def math_guardrail(
|
||||
context: RunContextWrapper[None], agent: Agent, input: str | list[TResponseInputItem]
|
||||
context: RunContextWrapper[None],
|
||||
agent: Agent,
|
||||
input: str | list[TResponseInputItem],
|
||||
) -> GuardrailFunctionOutput:
|
||||
"""This is an input guardrail function, which happens to call an agent to check if the input
|
||||
is a math homework question.
|
||||
|
||||
@@ -53,7 +53,9 @@ async def main() -> None:
|
||||
)
|
||||
|
||||
input_items = story_outline_result.to_input_list()
|
||||
latest_outline = ItemHelpers.text_message_outputs(story_outline_result.new_items)
|
||||
latest_outline = ItemHelpers.text_message_outputs(
|
||||
story_outline_result.new_items
|
||||
)
|
||||
print("Story outline generated")
|
||||
|
||||
evaluator_result = await Runner.run(evaluator, input_items)
|
||||
@@ -67,7 +69,9 @@ async def main() -> None:
|
||||
|
||||
print("Re-running with feedback")
|
||||
|
||||
input_items.append({"content": f"Feedback: {result.feedback}", "role": "user"})
|
||||
input_items.append(
|
||||
{"content": f"Feedback: {result.feedback}", "role": "user"}
|
||||
)
|
||||
|
||||
print(f"Final story outline: {latest_outline}")
|
||||
|
||||
|
||||
@@ -29,9 +29,13 @@ a phone number.
|
||||
|
||||
# The agent's output type
|
||||
class MessageOutput(BaseModel):
|
||||
reasoning: str = Field(description="Thoughts on how to respond to the user's message")
|
||||
reasoning: str = Field(
|
||||
description="Thoughts on how to respond to the user's message"
|
||||
)
|
||||
response: str = Field(description="The response to the user's message")
|
||||
user_name: str | None = Field(description="The name of the user who sent the message, if known")
|
||||
user_name: str | None = Field(
|
||||
description="The name of the user who sent the message, if known"
|
||||
)
|
||||
|
||||
|
||||
@output_guardrail
|
||||
|
||||
@@ -29,11 +29,12 @@ model_provider = HanzoModelProvider(HANZO_ROUTER_URL, HANZO_API_KEY)
|
||||
|
||||
# ==================== Tools ====================
|
||||
|
||||
|
||||
@function_tool
|
||||
async def read_file(file_path: str) -> str:
|
||||
"""Read a file from the filesystem."""
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
with open(file_path, "r") as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
return f"Error reading file: {e}"
|
||||
@@ -46,12 +47,12 @@ async def write_file(file_path: str, content: str) -> str:
|
||||
# Create backup first
|
||||
if os.path.exists(file_path):
|
||||
backup_path = f"{file_path}.backup"
|
||||
with open(file_path, 'r') as f:
|
||||
with open(file_path, "r") as f:
|
||||
backup_content = f.read()
|
||||
with open(backup_path, 'w') as f:
|
||||
with open(backup_path, "w") as f:
|
||||
f.write(backup_content)
|
||||
|
||||
with open(file_path, 'w') as f:
|
||||
|
||||
with open(file_path, "w") as f:
|
||||
f.write(content)
|
||||
return f"File written successfully to {file_path}"
|
||||
except Exception as e:
|
||||
@@ -62,97 +63,102 @@ async def write_file(file_path: str, content: str) -> str:
|
||||
async def run_tests(test_command: str = "pytest") -> Dict[str, Any]:
|
||||
"""Run tests and return results."""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
test_command.split(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
test_command.split(), capture_output=True, text=True, timeout=30
|
||||
)
|
||||
return {
|
||||
"success": result.returncode == 0,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"return_code": result.returncode
|
||||
"return_code": result.returncode,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@function_tool
|
||||
async def analyze_stack_trace(error_text: str) -> Dict[str, Any]:
|
||||
"""Analyze a stack trace to identify the error location and type."""
|
||||
lines = error_text.split('\n')
|
||||
|
||||
lines = error_text.split("\n")
|
||||
|
||||
# Simple parser - in production use proper parsing
|
||||
error_info = {
|
||||
"error_type": None,
|
||||
"error_message": None,
|
||||
"file_path": None,
|
||||
"line_number": None,
|
||||
"function_name": None
|
||||
"function_name": None,
|
||||
}
|
||||
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if "File " in line and "line " in line:
|
||||
# Extract file path and line number
|
||||
parts = line.split('"')
|
||||
if len(parts) >= 2:
|
||||
error_info["file_path"] = parts[1]
|
||||
|
||||
|
||||
if "line " in line:
|
||||
line_parts = line.split("line ")
|
||||
if len(line_parts) >= 2:
|
||||
error_info["line_number"] = line_parts[1].split(",")[0].strip()
|
||||
|
||||
if i < len(lines) - 1 and not lines[i+1].startswith(" "):
|
||||
|
||||
if i < len(lines) - 1 and not lines[i + 1].startswith(" "):
|
||||
# This might be the error type and message
|
||||
if "Error" in line or "Exception" in line:
|
||||
parts = line.split(":", 1)
|
||||
if len(parts) >= 2:
|
||||
error_info["error_type"] = parts[0].strip()
|
||||
error_info["error_message"] = parts[1].strip()
|
||||
|
||||
|
||||
return error_info
|
||||
|
||||
|
||||
@function_tool
|
||||
async def search_codebase(pattern: str, file_types: List[str] = None) -> List[Dict[str, Any]]:
|
||||
async def search_codebase(
|
||||
pattern: str, file_types: List[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search codebase for specific patterns."""
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
if file_types is None:
|
||||
file_types = ['.py', '.js', '.ts', '.java', '.go']
|
||||
|
||||
file_types = [".py", ".js", ".ts", ".java", ".go"]
|
||||
|
||||
results = []
|
||||
|
||||
for root, dirs, files in os.walk('.'):
|
||||
|
||||
for root, dirs, files in os.walk("."):
|
||||
# Skip hidden directories and common ignore patterns
|
||||
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ['node_modules', '__pycache__']]
|
||||
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
if not d.startswith(".") and d not in ["node_modules", "__pycache__"]
|
||||
]
|
||||
|
||||
for file in files:
|
||||
if any(file.endswith(ft) for ft in file_types):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
with open(file_path, "r") as f:
|
||||
content = f.read()
|
||||
matches = list(re.finditer(pattern, content, re.MULTILINE | re.IGNORECASE))
|
||||
matches = list(
|
||||
re.finditer(pattern, content, re.MULTILINE | re.IGNORECASE)
|
||||
)
|
||||
if matches:
|
||||
for match in matches:
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
results.append({
|
||||
"file": file_path,
|
||||
"line": line_num,
|
||||
"match": match.group(),
|
||||
"context": content.split('\n')[line_num-1]
|
||||
})
|
||||
line_num = content[: match.start()].count("\n") + 1
|
||||
results.append(
|
||||
{
|
||||
"file": file_path,
|
||||
"line": line_num,
|
||||
"match": match.group(),
|
||||
"context": content.split("\n")[line_num - 1],
|
||||
}
|
||||
)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
pass # Skip unreadable files
|
||||
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -160,7 +166,7 @@ async def search_codebase(pattern: str, file_types: List[str] = None) -> List[Di
|
||||
fix_and_test = create_composite_tool(
|
||||
name="fix_and_test",
|
||||
tools=[write_file, run_tests],
|
||||
description="Apply a fix and immediately run tests"
|
||||
description="Apply a fix and immediately run tests",
|
||||
)
|
||||
|
||||
|
||||
@@ -178,7 +184,7 @@ bug_analyzer = Agent(
|
||||
|
||||
Be thorough and systematic in your analysis.""",
|
||||
tools=[analyze_stack_trace, read_file, search_codebase],
|
||||
model="gpt-4"
|
||||
model="gpt-4",
|
||||
)
|
||||
|
||||
# Code Reader Agent
|
||||
@@ -193,7 +199,7 @@ code_reader = Agent(
|
||||
|
||||
Focus on understanding the code's intent and implementation.""",
|
||||
tools=[read_file, search_codebase],
|
||||
model="gpt-3.5-turbo" # Faster for simple reading tasks
|
||||
model="gpt-3.5-turbo", # Faster for simple reading tasks
|
||||
)
|
||||
|
||||
# Solution Designer Agent
|
||||
@@ -208,7 +214,7 @@ solution_designer = Agent(
|
||||
|
||||
Think about edge cases, performance, and maintainability.""",
|
||||
tools=[read_file],
|
||||
model="gpt-4"
|
||||
model="gpt-4",
|
||||
)
|
||||
|
||||
# Code Fixer Agent
|
||||
@@ -223,7 +229,7 @@ code_fixer = Agent(
|
||||
|
||||
Always test your fixes and handle edge cases.""",
|
||||
tools=[read_file, write_file, fix_and_test],
|
||||
model="gpt-4"
|
||||
model="gpt-4",
|
||||
)
|
||||
|
||||
# Test Writer Agent
|
||||
@@ -238,7 +244,7 @@ test_writer = Agent(
|
||||
|
||||
Write clear, maintainable tests with good coverage.""",
|
||||
tools=[read_file, write_file, run_tests],
|
||||
model="gpt-3.5-turbo"
|
||||
model="gpt-3.5-turbo",
|
||||
)
|
||||
|
||||
# Quality Reviewer Agent
|
||||
@@ -253,7 +259,7 @@ quality_reviewer = Agent(
|
||||
|
||||
Be constructive but thorough in your review.""",
|
||||
tools=[read_file, run_tests],
|
||||
model="gpt-4"
|
||||
model="gpt-4",
|
||||
)
|
||||
|
||||
|
||||
@@ -274,10 +280,7 @@ rule_router.add_rule(r".*test.*|.*coverage.*", "TestWriter")
|
||||
rule_router.add_rule(r".*review.*|.*quality.*|.*check.*", "QualityReviewer")
|
||||
|
||||
# Composite router with both strategies
|
||||
main_router = RoutingStrategy([
|
||||
(rule_router, 0.7),
|
||||
(semantic_router, 0.3)
|
||||
])
|
||||
main_router = RoutingStrategy([(rule_router, 0.7), (semantic_router, 0.3)])
|
||||
|
||||
# Create the network
|
||||
bug_solver_network = create_network(
|
||||
@@ -287,13 +290,13 @@ bug_solver_network = create_network(
|
||||
solution_designer,
|
||||
code_fixer,
|
||||
test_writer,
|
||||
quality_reviewer
|
||||
quality_reviewer,
|
||||
],
|
||||
router=main_router,
|
||||
state_store=InMemoryStateStore(),
|
||||
model_provider=model_provider,
|
||||
memory_manager=MemoryManager(store=memory_store),
|
||||
default_model="gpt-4"
|
||||
default_model="gpt-4",
|
||||
)
|
||||
|
||||
|
||||
@@ -306,67 +309,75 @@ bug_solving_workflow = create_workflow(
|
||||
steps=[
|
||||
# 1. Initial Analysis
|
||||
Step.agent("BugAnalyzer", "Analyze the error: {error_message}"),
|
||||
|
||||
# 2. Parallel investigation
|
||||
Step.parallel([
|
||||
Step.agent("CodeReader", "Read and understand {error_info.file_path}"),
|
||||
Step.agent("CodeReader", "Search for related code using {error_info.function_name}")
|
||||
]),
|
||||
|
||||
Step.parallel(
|
||||
[
|
||||
Step.agent("CodeReader", "Read and understand {error_info.file_path}"),
|
||||
Step.agent(
|
||||
"CodeReader",
|
||||
"Search for related code using {error_info.function_name}",
|
||||
),
|
||||
]
|
||||
),
|
||||
# 3. Design solution
|
||||
Step.agent("SolutionDesigner", "Design a fix based on the analysis"),
|
||||
|
||||
# 4. Implementation
|
||||
Step.agent("CodeFixer", "Implement the proposed solution"),
|
||||
|
||||
# 5. Testing
|
||||
Step.parallel([
|
||||
Step.agent("TestWriter", "Write tests for the fix"),
|
||||
Step.agent("CodeFixer", "Run existing tests to verify the fix")
|
||||
]),
|
||||
|
||||
Step.parallel(
|
||||
[
|
||||
Step.agent("TestWriter", "Write tests for the fix"),
|
||||
Step.agent("CodeFixer", "Run existing tests to verify the fix"),
|
||||
]
|
||||
),
|
||||
# 6. Review
|
||||
Step.agent("QualityReviewer", "Review the complete fix and tests"),
|
||||
|
||||
# 7. Conditional refinement
|
||||
Step.conditional(
|
||||
condition=lambda state: state.get("review_result", {}).get("needs_revision", False),
|
||||
condition=lambda state: state.get("review_result", {}).get(
|
||||
"needs_revision", False
|
||||
),
|
||||
true_step=Step.loop(
|
||||
steps=[
|
||||
Step.agent("CodeFixer", "Address review feedback: {review_feedback}"),
|
||||
Step.agent("QualityReviewer", "Re-review the changes")
|
||||
Step.agent(
|
||||
"CodeFixer", "Address review feedback: {review_feedback}"
|
||||
),
|
||||
Step.agent("QualityReviewer", "Re-review the changes"),
|
||||
],
|
||||
max_iterations=3,
|
||||
break_condition=lambda state: not state.get("review_result", {}).get("needs_revision", True)
|
||||
break_condition=lambda state: not state.get("review_result", {}).get(
|
||||
"needs_revision", True
|
||||
),
|
||||
),
|
||||
false_step=Step.transform(lambda x: {"status": "completed", "result": x})
|
||||
)
|
||||
false_step=Step.transform(lambda x: {"status": "completed", "result": x}),
|
||||
),
|
||||
],
|
||||
enable_streaming=True
|
||||
enable_streaming=True,
|
||||
)
|
||||
|
||||
|
||||
# ==================== Main Example ====================
|
||||
|
||||
|
||||
async def solve_bug(error_message: str, context: Dict[str, Any] = None):
|
||||
"""Autonomously solve a bug given an error message."""
|
||||
|
||||
|
||||
print(f"🐛 Autonomous Bug Solver Started")
|
||||
print(f"{'='*60}")
|
||||
print(f"Error: {error_message}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
|
||||
# Initialize context
|
||||
if context is None:
|
||||
context = {}
|
||||
|
||||
|
||||
context["error_message"] = error_message
|
||||
|
||||
|
||||
# Stream handler for real-time updates
|
||||
async def stream_handler(event):
|
||||
agent_name = event.data.get("agent", "System")
|
||||
message = event.data.get("message", "")
|
||||
|
||||
|
||||
if event.type == "agent_start":
|
||||
print(f"\n🤖 {agent_name}: Starting...")
|
||||
elif event.type == "agent_complete":
|
||||
@@ -376,22 +387,21 @@ async def solve_bug(error_message: str, context: Dict[str, Any] = None):
|
||||
print(f" 🔧 Using tool: {tool_name}")
|
||||
elif event.type == "message":
|
||||
print(f" 💬 {message}")
|
||||
|
||||
|
||||
# Run the workflow
|
||||
result = await bug_solving_workflow.run(
|
||||
context,
|
||||
stream_callback=stream_handler
|
||||
)
|
||||
|
||||
result = await bug_solving_workflow.run(context, stream_callback=stream_handler)
|
||||
|
||||
# Summary
|
||||
print(f"\n{'='*60}")
|
||||
print(f"🎉 Bug Solving Complete!")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
if result.get("status") == "completed":
|
||||
print(f"✅ Status: Success")
|
||||
print(f"📝 Summary: {result.get('result', {}).get('summary', 'Bug fixed successfully')}")
|
||||
|
||||
print(
|
||||
f"📝 Summary: {result.get('result', {}).get('summary', 'Bug fixed successfully')}"
|
||||
)
|
||||
|
||||
# Show memory insights
|
||||
memories = await bug_solver_network.memory_manager.search("bug fix", limit=3)
|
||||
if memories:
|
||||
@@ -401,20 +411,21 @@ async def solve_bug(error_message: str, context: Dict[str, Any] = None):
|
||||
else:
|
||||
print(f"❌ Status: Failed")
|
||||
print(f"📝 Error: {result.get('error', 'Unknown error')}")
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ==================== Example Usage ====================
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run example bug-solving scenarios."""
|
||||
|
||||
|
||||
# Example 1: Simple syntax error
|
||||
print("\n" + "="*80)
|
||||
print("\n" + "=" * 80)
|
||||
print("Example 1: Solving a Simple Syntax Error")
|
||||
print("="*80)
|
||||
|
||||
print("=" * 80)
|
||||
|
||||
error1 = """
|
||||
Traceback (most recent call last):
|
||||
File "app.py", line 42, in process_data
|
||||
@@ -423,14 +434,14 @@ async def main():
|
||||
total += item.price * item.quantty
|
||||
AttributeError: 'Item' object has no attribute 'quantty'
|
||||
"""
|
||||
|
||||
|
||||
await solve_bug(error1)
|
||||
|
||||
|
||||
# Example 2: Complex logic error
|
||||
print("\n" + "="*80)
|
||||
print("\n" + "=" * 80)
|
||||
print("Example 2: Solving a Complex Logic Error")
|
||||
print("="*80)
|
||||
|
||||
print("=" * 80)
|
||||
|
||||
error2 = """
|
||||
Test test_payment_processing failed:
|
||||
AssertionError: Payment total mismatch
|
||||
@@ -441,17 +452,20 @@ async def main():
|
||||
The discount calculation in checkout.py might not be handling
|
||||
overlapping discounts correctly.
|
||||
"""
|
||||
|
||||
await solve_bug(error2, context={
|
||||
"test_file": "tests/test_checkout.py",
|
||||
"suspected_files": ["checkout.py", "models/discount.py"]
|
||||
})
|
||||
|
||||
|
||||
await solve_bug(
|
||||
error2,
|
||||
context={
|
||||
"test_file": "tests/test_checkout.py",
|
||||
"suspected_files": ["checkout.py", "models/discount.py"],
|
||||
},
|
||||
)
|
||||
|
||||
# Show network statistics
|
||||
print("\n" + "="*80)
|
||||
print("\n" + "=" * 80)
|
||||
print("Network Statistics")
|
||||
print("="*80)
|
||||
|
||||
print("=" * 80)
|
||||
|
||||
stats = bug_solver_network.get_statistics()
|
||||
for agent_name, agent_stats in stats.items():
|
||||
print(f"\n{agent_name}:")
|
||||
@@ -461,4 +475,4 @@ async def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -14,21 +14,29 @@ class CustomAgentHooks(AgentHooks):
|
||||
|
||||
async def on_start(self, context: RunContextWrapper, agent: Agent) -> None:
|
||||
self.event_counter += 1
|
||||
print(f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} started")
|
||||
print(
|
||||
f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} started"
|
||||
)
|
||||
|
||||
async def on_end(self, context: RunContextWrapper, agent: Agent, output: Any) -> None:
|
||||
async def on_end(
|
||||
self, context: RunContextWrapper, agent: Agent, output: Any
|
||||
) -> None:
|
||||
self.event_counter += 1
|
||||
print(
|
||||
f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} ended with output {output}"
|
||||
)
|
||||
|
||||
async def on_handoff(self, context: RunContextWrapper, agent: Agent, source: Agent) -> None:
|
||||
async def on_handoff(
|
||||
self, context: RunContextWrapper, agent: Agent, source: Agent
|
||||
) -> None:
|
||||
self.event_counter += 1
|
||||
print(
|
||||
f"### ({self.display_name}) {self.event_counter}: Agent {source.name} handed off to {agent.name}"
|
||||
)
|
||||
|
||||
async def on_tool_start(self, context: RunContextWrapper, agent: Agent, tool: Tool) -> None:
|
||||
async def on_tool_start(
|
||||
self, context: RunContextWrapper, agent: Agent, tool: Tool
|
||||
) -> None:
|
||||
self.event_counter += 1
|
||||
print(
|
||||
f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} started tool {tool.name}"
|
||||
|
||||
@@ -29,7 +29,9 @@ agent = Agent(
|
||||
|
||||
|
||||
async def main():
|
||||
choice: Literal["haiku", "pirate", "robot"] = random.choice(["haiku", "pirate", "robot"])
|
||||
choice: Literal["haiku", "pirate", "robot"] = random.choice(
|
||||
["haiku", "pirate", "robot"]
|
||||
)
|
||||
context = CustomContext(style=choice)
|
||||
print(f"Using style: {choice}\n")
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from agents import Agent, Runner
|
||||
agent = Agent(name="Assistant", instructions="You are a helpful assistant")
|
||||
|
||||
# Intended for Jupyter notebooks where there's an existing event loop
|
||||
result = await Runner.run(agent, "Write a haiku about recursion in programming.") # type: ignore[top-level-await] # noqa: F704
|
||||
result = await Runner.run(agent, "Write a haiku about recursion in programming.") # type: ignore[top-level-await] # noqa: F704
|
||||
print(result.final_output)
|
||||
|
||||
# Code within code loops,
|
||||
|
||||
@@ -4,7 +4,15 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agents import Agent, RunContextWrapper, RunHooks, Runner, Tool, Usage, function_tool
|
||||
from agents import (
|
||||
Agent,
|
||||
RunContextWrapper,
|
||||
RunHooks,
|
||||
Runner,
|
||||
Tool,
|
||||
Usage,
|
||||
function_tool,
|
||||
)
|
||||
|
||||
|
||||
class ExampleHooks(RunHooks):
|
||||
@@ -20,13 +28,17 @@ class ExampleHooks(RunHooks):
|
||||
f"### {self.event_counter}: Agent {agent.name} started. Usage: {self._usage_to_str(context.usage)}"
|
||||
)
|
||||
|
||||
async def on_agent_end(self, context: RunContextWrapper, agent: Agent, output: Any) -> None:
|
||||
async def on_agent_end(
|
||||
self, context: RunContextWrapper, agent: Agent, output: Any
|
||||
) -> None:
|
||||
self.event_counter += 1
|
||||
print(
|
||||
f"### {self.event_counter}: Agent {agent.name} ended with output {output}. Usage: {self._usage_to_str(context.usage)}"
|
||||
)
|
||||
|
||||
async def on_tool_start(self, context: RunContextWrapper, agent: Agent, tool: Tool) -> None:
|
||||
async def on_tool_start(
|
||||
self, context: RunContextWrapper, agent: Agent, tool: Tool
|
||||
) -> None:
|
||||
self.event_counter += 1
|
||||
print(
|
||||
f"### {self.event_counter}: Tool {tool.name} started. Usage: {self._usage_to_str(context.usage)}"
|
||||
|
||||
@@ -34,7 +34,9 @@ async def main():
|
||||
elif event.item.type == "tool_call_output_item":
|
||||
print(f"-- Tool output: {event.item.output}")
|
||||
elif event.item.type == "message_output_item":
|
||||
print(f"-- Message output:\n {ItemHelpers.text_message_output(event.item)}")
|
||||
print(
|
||||
f"-- Message output:\n {ItemHelpers.text_message_output(event.item)}"
|
||||
)
|
||||
else:
|
||||
pass # Ignore other event types
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ async def main():
|
||||
|
||||
result = Runner.run_streamed(agent, input="Please tell me 5 jokes.")
|
||||
async for event in result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
|
||||
if event.type == "raw_response_event" and isinstance(
|
||||
event.data, ResponseTextDeltaEvent
|
||||
):
|
||||
print(event.data.delta, end="", flush=True)
|
||||
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ class AirlineAgentContext(BaseModel):
|
||||
|
||||
|
||||
@function_tool(
|
||||
name_override="faq_lookup_tool", description_override="Lookup frequently asked questions."
|
||||
name_override="faq_lookup_tool",
|
||||
description_override="Lookup frequently asked questions.",
|
||||
)
|
||||
async def faq_lookup_tool(question: str) -> str:
|
||||
if "bag" in question or "baggage" in question:
|
||||
@@ -58,7 +59,9 @@ async def faq_lookup_tool(question: str) -> str:
|
||||
|
||||
@function_tool
|
||||
async def update_seat(
|
||||
context: RunContextWrapper[AirlineAgentContext], confirmation_number: str, new_seat: str
|
||||
context: RunContextWrapper[AirlineAgentContext],
|
||||
confirmation_number: str,
|
||||
new_seat: str,
|
||||
) -> str:
|
||||
"""
|
||||
Update the seat for a given confirmation number.
|
||||
@@ -78,7 +81,9 @@ async def update_seat(
|
||||
### HOOKS
|
||||
|
||||
|
||||
async def on_seat_booking_handoff(context: RunContextWrapper[AirlineAgentContext]) -> None:
|
||||
async def on_seat_booking_handoff(
|
||||
context: RunContextWrapper[AirlineAgentContext],
|
||||
) -> None:
|
||||
flight_number = f"FLT-{random.randint(100, 999)}"
|
||||
context.context.flight_number = flight_number
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ def random_number_tool(max: int) -> int:
|
||||
return random.randint(0, max)
|
||||
|
||||
|
||||
def spanish_handoff_message_filter(handoff_message_data: HandoffInputData) -> HandoffInputData:
|
||||
def spanish_handoff_message_filter(
|
||||
handoff_message_data: HandoffInputData,
|
||||
) -> HandoffInputData:
|
||||
# First, we'll remove any tool-related messages from the message history
|
||||
handoff_message_data = handoff_filters.remove_all_tools(handoff_message_data)
|
||||
|
||||
@@ -64,7 +66,12 @@ async def main():
|
||||
result = await Runner.run(
|
||||
second_agent,
|
||||
input=result.to_input_list()
|
||||
+ [{"content": "Can you generate a random number between 0 and 100?", "role": "user"}],
|
||||
+ [
|
||||
{
|
||||
"content": "Can you generate a random number between 0 and 100?",
|
||||
"role": "user",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
print("Step 2 done")
|
||||
|
||||
@@ -13,7 +13,9 @@ def random_number_tool(max: int) -> int:
|
||||
return random.randint(0, max)
|
||||
|
||||
|
||||
def spanish_handoff_message_filter(handoff_message_data: HandoffInputData) -> HandoffInputData:
|
||||
def spanish_handoff_message_filter(
|
||||
handoff_message_data: HandoffInputData,
|
||||
) -> HandoffInputData:
|
||||
# First, we'll remove any tool-related messages from the message history
|
||||
handoff_message_data = handoff_filters.remove_all_tools(handoff_message_data)
|
||||
|
||||
@@ -64,7 +66,12 @@ async def main():
|
||||
result = await Runner.run(
|
||||
second_agent,
|
||||
input=result.to_input_list()
|
||||
+ [{"content": "Can you generate a random number between 0 and 100?", "role": "user"}],
|
||||
+ [
|
||||
{
|
||||
"content": "Can you generate a random number between 0 and 100?",
|
||||
"role": "user",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
print("Step 2 done")
|
||||
|
||||
@@ -29,29 +29,28 @@ HANZO_API_KEY = os.getenv("HANZO_API_KEY", "sk-1234") # Get from Router dashboa
|
||||
class HanzoModelProvider(ModelProvider):
|
||||
"""
|
||||
Custom model provider that routes requests through Hanzo Router.
|
||||
|
||||
|
||||
The Router provides:
|
||||
- Unified access to 100+ LLM providers
|
||||
- Cost tracking and rate limiting
|
||||
- Model fallbacks and load balancing
|
||||
- Observability with Cloud dashboard
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, base_url: str = HANZO_ROUTER_URL, api_key: str = HANZO_API_KEY):
|
||||
self.client = AsyncOpenAI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
)
|
||||
print(f"Connected to Hanzo Router at: {base_url}")
|
||||
|
||||
|
||||
def get_model(self, model_name: str | None) -> Model:
|
||||
# The Router supports many models, including:
|
||||
# - OpenAI: gpt-4, gpt-3.5-turbo, etc.
|
||||
# - Anthropic: claude-3-opus, claude-3-sonnet, etc.
|
||||
# - Open models: llama-3, mixtral, etc.
|
||||
return OpenAIChatCompletionsModel(
|
||||
model=model_name or "gpt-3.5-turbo",
|
||||
openai_client=self.client
|
||||
model=model_name or "gpt-3.5-turbo", openai_client=self.client
|
||||
)
|
||||
|
||||
|
||||
@@ -63,18 +62,18 @@ hanzo_provider = HanzoModelProvider()
|
||||
async def basic_example():
|
||||
"""Simple example using Hanzo backend."""
|
||||
print("\n=== Basic Agent Example ===")
|
||||
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
instructions="You are a helpful AI assistant powered by Hanzo infrastructure.",
|
||||
)
|
||||
|
||||
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
"Tell me about the benefits of using a unified LLM gateway.",
|
||||
run_config=RunConfig(model_provider=hanzo_provider)
|
||||
run_config=RunConfig(model_provider=hanzo_provider),
|
||||
)
|
||||
|
||||
|
||||
print(f"Response: {result.final_output}")
|
||||
|
||||
|
||||
@@ -88,7 +87,9 @@ def search_knowledge_base(query: str) -> str:
|
||||
|
||||
|
||||
@function_tool
|
||||
def create_support_ticket(title: str, description: str, priority: str = "medium") -> str:
|
||||
def create_support_ticket(
|
||||
title: str, description: str, priority: str = "medium"
|
||||
) -> str:
|
||||
"""Create a support ticket in the system."""
|
||||
# This would integrate with your ticketing system
|
||||
return f"Created ticket: {title} (Priority: {priority})"
|
||||
@@ -97,21 +98,21 @@ def create_support_ticket(title: str, description: str, priority: str = "medium"
|
||||
async def tools_example():
|
||||
"""Example with custom tools."""
|
||||
print("\n=== Agent with Tools Example ===")
|
||||
|
||||
|
||||
agent = Agent(
|
||||
name="SupportAgent",
|
||||
instructions="""You are a customer support agent.
|
||||
Use the search_knowledge_base tool to find information.
|
||||
Create support tickets when customers report issues.""",
|
||||
tools=[search_knowledge_base, create_support_ticket]
|
||||
tools=[search_knowledge_base, create_support_ticket],
|
||||
)
|
||||
|
||||
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
"I'm having trouble connecting to the API. It returns 401 errors.",
|
||||
run_config=RunConfig(model_provider=hanzo_provider)
|
||||
run_config=RunConfig(model_provider=hanzo_provider),
|
||||
)
|
||||
|
||||
|
||||
print(f"Response: {result.final_output}")
|
||||
|
||||
|
||||
@@ -119,36 +120,35 @@ async def tools_example():
|
||||
async def multi_model_example():
|
||||
"""Example using different models for different tasks."""
|
||||
print("\n=== Multi-Model Example ===")
|
||||
|
||||
|
||||
# Use a fast model for simple tasks
|
||||
fast_agent = Agent(
|
||||
name="FastResponder",
|
||||
instructions="You provide quick, concise responses.",
|
||||
)
|
||||
|
||||
|
||||
result = await Runner.run(
|
||||
fast_agent,
|
||||
"What's the current time in UTC?",
|
||||
run_config=RunConfig(
|
||||
model_provider=hanzo_provider,
|
||||
model="gpt-3.5-turbo" # Fast, cost-effective
|
||||
)
|
||||
model_provider=hanzo_provider, model="gpt-3.5-turbo" # Fast, cost-effective
|
||||
),
|
||||
)
|
||||
print(f"Fast response: {result.final_output}")
|
||||
|
||||
|
||||
# Use a powerful model for complex tasks
|
||||
analyst_agent = Agent(
|
||||
name="DataAnalyst",
|
||||
instructions="You are an expert data analyst. Provide detailed analysis.",
|
||||
)
|
||||
|
||||
|
||||
result = await Runner.run(
|
||||
analyst_agent,
|
||||
"Analyze the pros and cons of microservices vs monolithic architecture.",
|
||||
run_config=RunConfig(
|
||||
model_provider=hanzo_provider,
|
||||
model="gpt-4" # More capable for complex analysis
|
||||
)
|
||||
model="gpt-4", # More capable for complex analysis
|
||||
),
|
||||
)
|
||||
print(f"Detailed analysis: {result.final_output[:200]}...")
|
||||
|
||||
@@ -156,17 +156,17 @@ async def multi_model_example():
|
||||
# Example 4: Production Configuration
|
||||
class ProductionHanzoProvider(HanzoModelProvider):
|
||||
"""Production-ready provider with additional configuration."""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
# In production, load from secure configuration
|
||||
base_url = os.getenv("HANZO_ROUTER_URL", "https://router.hanzo.ai/v1")
|
||||
api_key = os.getenv("HANZO_API_KEY")
|
||||
|
||||
|
||||
if not api_key:
|
||||
raise ValueError("HANZO_API_KEY environment variable is required")
|
||||
|
||||
|
||||
super().__init__(base_url=base_url, api_key=api_key)
|
||||
|
||||
|
||||
# Additional configuration
|
||||
self.client.timeout = 30.0 # 30 second timeout
|
||||
self.client.max_retries = 3
|
||||
@@ -174,31 +174,33 @@ class ProductionHanzoProvider(HanzoModelProvider):
|
||||
|
||||
async def main():
|
||||
"""Run all examples."""
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
print("Hanzo Agent SDK - Backend Integration Examples")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
print(f"\nUsing Hanzo Router at: {HANZO_ROUTER_URL}")
|
||||
print("Make sure the Router is running locally or update HANZO_ROUTER_URL")
|
||||
|
||||
|
||||
try:
|
||||
await basic_example()
|
||||
await tools_example()
|
||||
await multi_model_example()
|
||||
|
||||
print("\n" + "="*60)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✓ All examples completed successfully!")
|
||||
print("\nTo use in your own code:")
|
||||
print("1. Set HANZO_ROUTER_URL to your Router endpoint")
|
||||
print("2. Set HANZO_API_KEY to your Router API key")
|
||||
print("3. Use HanzoModelProvider with RunConfig")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error: {e}")
|
||||
print("\nTroubleshooting:")
|
||||
print("1. Ensure Hanzo Router is running: cd /path/to/services && make start-router")
|
||||
print(
|
||||
"1. Ensure Hanzo Router is running: cd /path/to/services && make start-router"
|
||||
)
|
||||
print("2. Check your HANZO_API_KEY is valid")
|
||||
print("3. Verify network connectivity to the Router")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -19,7 +19,6 @@ from agents import (
|
||||
Runner,
|
||||
)
|
||||
|
||||
|
||||
# Define specialized agents
|
||||
research_agent = Agent(
|
||||
name="research_agent",
|
||||
@@ -75,7 +74,7 @@ coordinator_agent = Agent(
|
||||
async def save_to_memory(ctx, key: str, value: str) -> str:
|
||||
"""Save information to shared memory for other agents to access."""
|
||||
# Access network context
|
||||
if hasattr(ctx, 'network'):
|
||||
if hasattr(ctx, "network"):
|
||||
await ctx.set_state(key, value, namespace="shared")
|
||||
return f"Saved '{key}' to shared memory"
|
||||
return "Memory not available"
|
||||
@@ -84,7 +83,7 @@ async def save_to_memory(ctx, key: str, value: str) -> str:
|
||||
@function_tool
|
||||
async def recall_from_memory(ctx, key: str) -> str:
|
||||
"""Recall information from shared memory."""
|
||||
if hasattr(ctx, 'network'):
|
||||
if hasattr(ctx, "network"):
|
||||
value = await ctx.get_state(key, namespace="shared")
|
||||
if value:
|
||||
return f"Retrieved from memory: {value}"
|
||||
@@ -95,7 +94,7 @@ async def recall_from_memory(ctx, key: str) -> str:
|
||||
@function_tool
|
||||
async def list_memories(ctx) -> str:
|
||||
"""List all keys in shared memory."""
|
||||
if hasattr(ctx, 'network'):
|
||||
if hasattr(ctx, "network"):
|
||||
keys = await ctx.state_store.keys(namespace="shared")
|
||||
if keys:
|
||||
return f"Memory keys: {', '.join(keys)}"
|
||||
@@ -113,13 +112,13 @@ coordinator_agent.tools = [save_to_memory, recall_from_memory, list_memories]
|
||||
async def basic_network_example():
|
||||
"""Basic example of agent network with semantic routing."""
|
||||
print("=== Basic Network Example ===\n")
|
||||
|
||||
|
||||
# Create network with semantic router
|
||||
network = AgentNetwork(
|
||||
config=NetworkConfig(name="Research Network"),
|
||||
router=SemanticRouter(),
|
||||
)
|
||||
|
||||
|
||||
# Add agents with capabilities
|
||||
network.add_agent(
|
||||
research_agent,
|
||||
@@ -133,14 +132,14 @@ async def basic_network_example():
|
||||
coder_agent,
|
||||
capabilities=["coding", "programming", "debugging", "implementation"],
|
||||
)
|
||||
|
||||
|
||||
# Test routing
|
||||
queries = [
|
||||
"Research the latest trends in AI",
|
||||
"Write a blog post about climate change",
|
||||
"Debug this Python function that's not working",
|
||||
]
|
||||
|
||||
|
||||
for query in queries:
|
||||
print(f"Query: {query}")
|
||||
result = await network.run(input=query, max_turns=1)
|
||||
@@ -150,13 +149,13 @@ async def basic_network_example():
|
||||
async def advanced_network_with_rules():
|
||||
"""Advanced example with rule-based routing and state sharing."""
|
||||
print("\n=== Advanced Network with Rules ===\n")
|
||||
|
||||
|
||||
# Create router with rules
|
||||
router = RuleBasedRouter()
|
||||
router.add_rule(r"research|analyze|find", "research_agent", priority=10)
|
||||
router.add_rule(r"write|draft|compose", "writer_agent", priority=10)
|
||||
router.add_rule(r"code|program|debug|implement", "coder_agent", priority=10)
|
||||
|
||||
|
||||
# Create network with state store
|
||||
state_store = InMemoryStateStore()
|
||||
network = AgentNetwork(
|
||||
@@ -166,22 +165,22 @@ async def advanced_network_with_rules():
|
||||
),
|
||||
router=router,
|
||||
)
|
||||
|
||||
|
||||
# Add agents
|
||||
network.add_agent(research_agent)
|
||||
network.add_agent(writer_agent)
|
||||
network.add_agent(coder_agent)
|
||||
|
||||
|
||||
# Complex task that requires multiple agents
|
||||
print("Task: Research AI trends and write a technical blog post\n")
|
||||
|
||||
|
||||
# Step 1: Research
|
||||
result1 = await network.run(
|
||||
input="Research the top 3 AI trends in 2024 and save them to memory",
|
||||
starting_agent="research_agent",
|
||||
)
|
||||
print(f"Research complete: {result1.final_output}\n")
|
||||
|
||||
|
||||
# Step 2: Write based on research
|
||||
result2 = await network.run(
|
||||
input="Recall the AI trends from memory and write a technical blog post about them",
|
||||
@@ -193,7 +192,7 @@ async def advanced_network_with_rules():
|
||||
async def orchestrated_workflow_example():
|
||||
"""Example using the orchestrator for complex workflows."""
|
||||
print("\n=== Orchestrated Workflow Example ===\n")
|
||||
|
||||
|
||||
# Create orchestrator
|
||||
orchestrator = Orchestrator(
|
||||
config=OrchestrationConfig(
|
||||
@@ -201,22 +200,22 @@ async def orchestrated_workflow_example():
|
||||
enable_ui_streaming=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Register agents
|
||||
orchestrator.register_agent(coordinator_agent, capabilities=["coordination"])
|
||||
orchestrator.register_agent(research_agent, capabilities=["research"])
|
||||
orchestrator.register_agent(writer_agent, capabilities=["writing"])
|
||||
orchestrator.register_agent(coder_agent, capabilities=["coding"])
|
||||
|
||||
|
||||
# Create a workflow
|
||||
workflow = orchestrator.create_workflow_from_agents(
|
||||
name="AI Article Workflow",
|
||||
agents=["coordinator_agent"],
|
||||
)
|
||||
|
||||
|
||||
# Register and execute workflow
|
||||
orchestrator.register_workflow(workflow)
|
||||
|
||||
|
||||
result = await orchestrator.execute_workflow(
|
||||
workflow_id=workflow.id,
|
||||
input="""Create a comprehensive guide about implementing
|
||||
@@ -226,7 +225,7 @@ async def orchestrated_workflow_example():
|
||||
3. Well-written explanation
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
print(f"Workflow completed: {result.success}")
|
||||
print(f"Duration: {result.duration:.2f}s")
|
||||
print(f"Output: {result.output}")
|
||||
@@ -235,10 +234,10 @@ async def orchestrated_workflow_example():
|
||||
async def memory_system_example():
|
||||
"""Example demonstrating the memory system."""
|
||||
print("\n=== Memory System Example ===\n")
|
||||
|
||||
|
||||
# Create memory system
|
||||
memory = Memory(max_entries=100)
|
||||
|
||||
|
||||
# Create memory-enabled agent
|
||||
memory_agent = Agent(
|
||||
name="memory_agent",
|
||||
@@ -246,31 +245,31 @@ async def memory_system_example():
|
||||
You can remember facts, conversations, and learn from experience.
|
||||
Always check your memory before responding.""",
|
||||
)
|
||||
|
||||
|
||||
# Wrap with memory capabilities
|
||||
memory_enabled = memory.create_agent_wrapper(memory_agent)
|
||||
|
||||
|
||||
# Store some memories
|
||||
await memory.remember(
|
||||
"The user's favorite color is blue",
|
||||
type=MemoryType.FACT,
|
||||
agent_name="memory_agent",
|
||||
)
|
||||
|
||||
|
||||
await memory.remember(
|
||||
"The user is interested in machine learning",
|
||||
type=MemoryType.FACT,
|
||||
agent_name="memory_agent",
|
||||
)
|
||||
|
||||
|
||||
# Test memory recall
|
||||
result = await Runner.run(
|
||||
starting_agent=memory_enabled,
|
||||
input="What do you remember about me?",
|
||||
)
|
||||
|
||||
|
||||
print(f"Memory recall: {result.final_output}")
|
||||
|
||||
|
||||
# Generate reflection
|
||||
reflection = await memory.reflect(memory_agent)
|
||||
print(f"\nReflection: {reflection.content}")
|
||||
@@ -279,7 +278,7 @@ async def memory_system_example():
|
||||
async def parallel_network_example():
|
||||
"""Example of parallel task execution in a network."""
|
||||
print("\n=== Parallel Network Execution ===\n")
|
||||
|
||||
|
||||
# Create network
|
||||
network = AgentNetwork(
|
||||
config=NetworkConfig(
|
||||
@@ -288,28 +287,28 @@ async def parallel_network_example():
|
||||
max_parallel_agents=3,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Add agents
|
||||
network.add_agent(research_agent)
|
||||
network.add_agent(writer_agent)
|
||||
network.add_agent(coder_agent)
|
||||
|
||||
|
||||
# Define parallel tasks
|
||||
tasks = [
|
||||
{"input": "Research quantum computing basics", "agent": "research_agent"},
|
||||
{"input": "Write a haiku about technology", "agent": "writer_agent"},
|
||||
{"input": "Implement a fibonacci function", "agent": "coder_agent"},
|
||||
]
|
||||
|
||||
|
||||
print("Running 3 tasks in parallel...\n")
|
||||
|
||||
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
results = await network.run_parallel(tasks)
|
||||
duration = asyncio.get_event_loop().time() - start_time
|
||||
|
||||
|
||||
for i, result in enumerate(results):
|
||||
print(f"Task {i+1} result: {result.final_output[:100]}...")
|
||||
|
||||
|
||||
print(f"\nTotal execution time: {duration:.2f}s")
|
||||
|
||||
|
||||
@@ -323,4 +322,4 @@ async def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -66,7 +66,9 @@ class ResearchManager:
|
||||
with custom_span("Search the web"):
|
||||
self.printer.update_item("searching", "Searching...")
|
||||
num_completed = 0
|
||||
tasks = [asyncio.create_task(self._search(item)) for item in search_plan.searches]
|
||||
tasks = [
|
||||
asyncio.create_task(self._search(item)) for item in search_plan.searches
|
||||
]
|
||||
results = []
|
||||
for task in asyncio.as_completed(tasks):
|
||||
result = await task
|
||||
|
||||
@@ -19,7 +19,11 @@ class Printer:
|
||||
self.hide_done_ids.add(item_id)
|
||||
|
||||
def update_item(
|
||||
self, item_id: str, content: str, is_done: bool = False, hide_checkmark: bool = False
|
||||
self,
|
||||
item_id: str,
|
||||
content: str,
|
||||
is_done: bool = False,
|
||||
hide_checkmark: bool = False,
|
||||
) -> None:
|
||||
self.items[item_id] = (content, is_done)
|
||||
if hide_checkmark:
|
||||
|
||||
@@ -76,7 +76,9 @@ class LocalPlaywrightComputer(AsyncComputer):
|
||||
async def _get_browser_and_page(self) -> tuple[Browser, Page]:
|
||||
width, height = self.dimensions
|
||||
launch_args = [f"--window-size={width},{height}"]
|
||||
browser = await self.playwright.chromium.launch(headless=False, args=launch_args)
|
||||
browser = await self.playwright.chromium.launch(
|
||||
headless=False, args=launch_args
|
||||
)
|
||||
page = await browser.new_page()
|
||||
await page.set_viewport_size({"width": width, "height": height})
|
||||
await page.goto("https://www.bing.com")
|
||||
|
||||
@@ -7,7 +7,9 @@ async def main():
|
||||
agent = Agent(
|
||||
name="Web searcher",
|
||||
instructions="You are a helpful agent.",
|
||||
tools=[WebSearchTool(user_location={"type": "approximate", "city": "New York"})],
|
||||
tools=[
|
||||
WebSearchTool(user_location={"type": "approximate", "city": "New York"})
|
||||
],
|
||||
)
|
||||
|
||||
with trace("Web search example"):
|
||||
|
||||
@@ -162,7 +162,9 @@ def set_default_openai_key(key: str, use_for_tracing: bool = True) -> None:
|
||||
_config.set_default_openai_key(key, use_for_tracing)
|
||||
|
||||
|
||||
def set_default_openai_client(client: AsyncOpenAI, use_for_tracing: bool = True) -> None:
|
||||
def set_default_openai_client(
|
||||
client: AsyncOpenAI, use_for_tracing: bool = True
|
||||
) -> None:
|
||||
"""Set the default OpenAI client to use for LLM requests and/or tracing. If provided, this
|
||||
client will be used instead of the default OpenAI client.
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import os
|
||||
|
||||
def _debug_flag_enabled(flag: str) -> bool:
|
||||
flag_value = os.getenv(flag)
|
||||
return flag_value is not None and (flag_value == "1" or flag_value.lower() == "true")
|
||||
return flag_value is not None and (
|
||||
flag_value == "1" or flag_value.lower() == "true"
|
||||
)
|
||||
|
||||
|
||||
DONT_LOG_MODEL_DATA = _debug_flag_enabled("OPENAI_AGENTS_DONT_LOG_MODEL_DATA")
|
||||
|
||||
@@ -30,7 +30,12 @@ from .agent import Agent
|
||||
from .agent_output import AgentOutputSchema
|
||||
from .computer import AsyncComputer, Computer
|
||||
from .exceptions import AgentsException, ModelBehaviorError, UserError
|
||||
from .guardrail import InputGuardrail, InputGuardrailResult, OutputGuardrail, OutputGuardrailResult
|
||||
from .guardrail import (
|
||||
InputGuardrail,
|
||||
InputGuardrailResult,
|
||||
OutputGuardrail,
|
||||
OutputGuardrailResult,
|
||||
)
|
||||
from .handoffs import Handoff, HandoffInputData
|
||||
from .items import (
|
||||
HandoffCallItem,
|
||||
@@ -217,17 +222,25 @@ class RunImpl:
|
||||
)
|
||||
|
||||
# Now we can check if the model also produced a final output
|
||||
message_items = [item for item in new_step_items if isinstance(item, MessageOutputItem)]
|
||||
message_items = [
|
||||
item for item in new_step_items if isinstance(item, MessageOutputItem)
|
||||
]
|
||||
|
||||
# We'll use the last content output as the final output
|
||||
potential_final_output_text = (
|
||||
ItemHelpers.extract_last_text(message_items[-1].raw_item) if message_items else None
|
||||
ItemHelpers.extract_last_text(message_items[-1].raw_item)
|
||||
if message_items
|
||||
else None
|
||||
)
|
||||
|
||||
# There are two possibilities that lead to a final output:
|
||||
# 1. Structured output schema => always leads to a final output
|
||||
# 2. Plain text output schema => only leads to a final output if there are no tool calls
|
||||
if output_schema and not output_schema.is_plain_text() and potential_final_output_text:
|
||||
if (
|
||||
output_schema
|
||||
and not output_schema.is_plain_text()
|
||||
and potential_final_output_text
|
||||
):
|
||||
final_output = output_schema.validate_json(potential_final_output_text)
|
||||
return await cls.execute_final_output(
|
||||
agent=agent,
|
||||
@@ -278,8 +291,12 @@ class RunImpl:
|
||||
computer_actions = []
|
||||
|
||||
handoff_map = {handoff.tool_name: handoff for handoff in handoffs}
|
||||
function_map = {tool.name: tool for tool in agent.tools if isinstance(tool, FunctionTool)}
|
||||
computer_tool = next((tool for tool in agent.tools if isinstance(tool, ComputerTool)), None)
|
||||
function_map = {
|
||||
tool.name: tool for tool in agent.tools if isinstance(tool, FunctionTool)
|
||||
}
|
||||
computer_tool = next(
|
||||
(tool for tool in agent.tools if isinstance(tool, ComputerTool)), None
|
||||
)
|
||||
|
||||
for output in response.output:
|
||||
if isinstance(output, ResponseOutputMessage):
|
||||
@@ -330,7 +347,9 @@ class RunImpl:
|
||||
data={"tool_name": output.name},
|
||||
)
|
||||
)
|
||||
raise ModelBehaviorError(f"Tool {output.name} not found in agent {agent.name}")
|
||||
raise ModelBehaviorError(
|
||||
f"Tool {output.name} not found in agent {agent.name}"
|
||||
)
|
||||
items.append(ToolCallItem(raw_item=output, agent=agent))
|
||||
functions.append(
|
||||
ToolRunFunction(
|
||||
@@ -376,7 +395,9 @@ class RunImpl:
|
||||
await asyncio.gather(
|
||||
hooks.on_tool_end(context_wrapper, agent, func_tool, result),
|
||||
(
|
||||
agent.hooks.on_tool_end(context_wrapper, agent, func_tool, result)
|
||||
agent.hooks.on_tool_end(
|
||||
context_wrapper, agent, func_tool, result
|
||||
)
|
||||
if agent.hooks
|
||||
else _utils.noop_coroutine()
|
||||
),
|
||||
@@ -406,7 +427,9 @@ class RunImpl:
|
||||
return [
|
||||
ToolCallOutputItem(
|
||||
output=str(result),
|
||||
raw_item=ItemHelpers.tool_call_output_item(tool_run.tool_call, str(result)),
|
||||
raw_item=ItemHelpers.tool_call_output_item(
|
||||
tool_run.tool_call, str(result)
|
||||
),
|
||||
agent=agent,
|
||||
)
|
||||
for tool_run, result in zip(tool_runs, results)
|
||||
@@ -513,9 +536,11 @@ class RunImpl:
|
||||
if input_filter:
|
||||
logger.debug("Filtering inputs for handoff")
|
||||
handoff_input_data = HandoffInputData(
|
||||
input_history=tuple(original_input)
|
||||
if isinstance(original_input, list)
|
||||
else original_input,
|
||||
input_history=(
|
||||
tuple(original_input)
|
||||
if isinstance(original_input, list)
|
||||
else original_input
|
||||
),
|
||||
pre_handoff_items=tuple(pre_step_items),
|
||||
new_items=tuple(new_step_items),
|
||||
)
|
||||
@@ -589,9 +614,11 @@ class RunImpl:
|
||||
):
|
||||
await asyncio.gather(
|
||||
hooks.on_agent_end(context_wrapper, agent, final_output),
|
||||
agent.hooks.on_end(context_wrapper, agent, final_output)
|
||||
if agent.hooks
|
||||
else _utils.noop_coroutine(),
|
||||
(
|
||||
agent.hooks.on_end(context_wrapper, agent, final_output)
|
||||
if agent.hooks
|
||||
else _utils.noop_coroutine()
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -616,7 +643,9 @@ class RunImpl:
|
||||
context: RunContextWrapper[TContext],
|
||||
) -> OutputGuardrailResult:
|
||||
with guardrail_span(guardrail.get_name()) as span_guardrail:
|
||||
result = await guardrail.run(agent=agent, agent_output=agent_output, context=context)
|
||||
result = await guardrail.run(
|
||||
agent=agent, agent_output=agent_output, context=context
|
||||
)
|
||||
span_guardrail.span_data.triggered = result.output.tripwire_triggered
|
||||
return result
|
||||
|
||||
@@ -698,7 +727,9 @@ class ComputerAction:
|
||||
output_func = (
|
||||
cls._get_screenshot_async(action.computer_tool.computer, action.tool_call)
|
||||
if isinstance(action.computer_tool.computer, AsyncComputer)
|
||||
else cls._get_screenshot_sync(action.computer_tool.computer, action.tool_call)
|
||||
else cls._get_screenshot_sync(
|
||||
action.computer_tool.computer, action.tool_call
|
||||
)
|
||||
)
|
||||
|
||||
_, _, output = await asyncio.gather(
|
||||
@@ -714,7 +745,9 @@ class ComputerAction:
|
||||
await asyncio.gather(
|
||||
hooks.on_tool_end(context_wrapper, agent, action.computer_tool, output),
|
||||
(
|
||||
agent.hooks.on_tool_end(context_wrapper, agent, action.computer_tool, output)
|
||||
agent.hooks.on_tool_end(
|
||||
context_wrapper, agent, action.computer_tool, output
|
||||
)
|
||||
if agent.hooks
|
||||
else _utils.noop_coroutine()
|
||||
),
|
||||
|
||||
@@ -31,7 +31,9 @@ def validate_json(json_str: str, type_adapter: TypeAdapter[T], partial: bool) ->
|
||||
"trailing-strings" if partial else False
|
||||
)
|
||||
try:
|
||||
validated = type_adapter.validate_json(json_str, experimental_allow_partial=partial_setting)
|
||||
validated = type_adapter.validate_json(
|
||||
json_str, experimental_allow_partial=partial_setting
|
||||
)
|
||||
return validated
|
||||
except ValidationError as e:
|
||||
attach_error_to_current_span(
|
||||
|
||||
@@ -135,7 +135,8 @@ class Agent(Generic[TContext]):
|
||||
"""
|
||||
|
||||
@function_tool(
|
||||
name_override=tool_name or _utils.transform_string_function_style(self.name),
|
||||
name_override=tool_name
|
||||
or _utils.transform_string_function_style(self.name),
|
||||
description_override=tool_description or "",
|
||||
)
|
||||
async def run_agent(context: RunContextWrapper, input: str) -> str:
|
||||
@@ -153,7 +154,9 @@ class Agent(Generic[TContext]):
|
||||
|
||||
return run_agent
|
||||
|
||||
async def get_system_prompt(self, run_context: RunContextWrapper[TContext]) -> str | None:
|
||||
async def get_system_prompt(
|
||||
self, run_context: RunContextWrapper[TContext]
|
||||
) -> str | None:
|
||||
"""Get the system prompt for the agent."""
|
||||
if isinstance(self.instructions, str):
|
||||
return self.instructions
|
||||
@@ -163,6 +166,8 @@ class Agent(Generic[TContext]):
|
||||
else:
|
||||
return cast(str, self.instructions(run_context, self))
|
||||
elif self.instructions is not None:
|
||||
logger.error(f"Instructions must be a string or a function, got {self.instructions}")
|
||||
logger.error(
|
||||
f"Instructions must be a string or a function, got {self.instructions}"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@@ -104,7 +104,9 @@ class AgentOutputSchema:
|
||||
_utils.attach_error_to_current_span(
|
||||
SpanError(
|
||||
message="Invalid JSON",
|
||||
data={"details": f"Could not find key {_WRAPPER_DICT_KEY} in JSON"},
|
||||
data={
|
||||
"details": f"Could not find key {_WRAPPER_DICT_KEY} in JSON"
|
||||
},
|
||||
)
|
||||
)
|
||||
raise ModelBehaviorError(
|
||||
|
||||
@@ -20,9 +20,13 @@ def remove_all_tools(handoff_input_data: HandoffInputData) -> HandoffInputData:
|
||||
new_items = handoff_input_data.new_items
|
||||
|
||||
filtered_history = (
|
||||
_remove_tool_types_from_input(history) if isinstance(history, tuple) else history
|
||||
_remove_tool_types_from_input(history)
|
||||
if isinstance(history, tuple)
|
||||
else history
|
||||
)
|
||||
filtered_pre_handoff_items = _remove_tools_from_items(
|
||||
handoff_input_data.pre_handoff_items
|
||||
)
|
||||
filtered_pre_handoff_items = _remove_tools_from_items(handoff_input_data.pre_handoff_items)
|
||||
filtered_new_items = _remove_tools_from_items(new_items)
|
||||
|
||||
return HandoffInputData(
|
||||
|
||||
@@ -63,6 +63,7 @@ class AttestationReport:
|
||||
# SGX attestation uses EPID or DCAP
|
||||
# Signature should be base64-encoded quote
|
||||
import base64
|
||||
|
||||
try:
|
||||
quote = base64.b64decode(self.signature)
|
||||
# SGX quote header is 48 bytes minimum
|
||||
@@ -72,6 +73,7 @@ class AttestationReport:
|
||||
elif self.provider == TEEProvider.SEV:
|
||||
# AMD SEV uses attestation report
|
||||
import base64
|
||||
|
||||
try:
|
||||
report = base64.b64decode(self.signature)
|
||||
# SEV report is 0x4A0 bytes
|
||||
|
||||
@@ -159,11 +159,18 @@ def generate_func_documentation(
|
||||
return FuncDocumentation(name=name, description=None, param_descriptions=None)
|
||||
|
||||
with _suppress_griffe_logging():
|
||||
docstring = Docstring(doc, lineno=1, parser=style or _detect_docstring_style(doc))
|
||||
docstring = Docstring(
|
||||
doc, lineno=1, parser=style or _detect_docstring_style(doc)
|
||||
)
|
||||
parsed = docstring.parse()
|
||||
|
||||
description: str | None = next(
|
||||
(section.value for section in parsed if section.kind == DocstringSectionKind.text), None
|
||||
(
|
||||
section.value
|
||||
for section in parsed
|
||||
if section.kind == DocstringSectionKind.text
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
param_descriptions: dict[str, str] = {
|
||||
|
||||
@@ -109,7 +109,9 @@ class InputGuardrail(Generic[TContext]):
|
||||
context: RunContextWrapper[TContext],
|
||||
) -> InputGuardrailResult:
|
||||
if not callable(self.guardrail_function):
|
||||
raise UserError(f"Guardrail function must be callable, got {self.guardrail_function}")
|
||||
raise UserError(
|
||||
f"Guardrail function must be callable, got {self.guardrail_function}"
|
||||
)
|
||||
|
||||
output = self.guardrail_function(context, agent, input)
|
||||
if inspect.isawaitable(output):
|
||||
@@ -160,7 +162,9 @@ class OutputGuardrail(Generic[TContext]):
|
||||
self, context: RunContextWrapper[TContext], agent: Agent[Any], agent_output: Any
|
||||
) -> OutputGuardrailResult:
|
||||
if not callable(self.guardrail_function):
|
||||
raise UserError(f"Guardrail function must be callable, got {self.guardrail_function}")
|
||||
raise UserError(
|
||||
f"Guardrail function must be callable, got {self.guardrail_function}"
|
||||
)
|
||||
|
||||
output = self.guardrail_function(context, agent, agent_output)
|
||||
if inspect.isawaitable(output):
|
||||
@@ -183,11 +187,19 @@ TContext_co = TypeVar("TContext_co", bound=Any, covariant=True)
|
||||
|
||||
# For InputGuardrail
|
||||
_InputGuardrailFuncSync = Callable[
|
||||
[RunContextWrapper[TContext_co], "Agent[Any]", Union[str, list[TResponseInputItem]]],
|
||||
[
|
||||
RunContextWrapper[TContext_co],
|
||||
"Agent[Any]",
|
||||
Union[str, list[TResponseInputItem]],
|
||||
],
|
||||
GuardrailFunctionOutput,
|
||||
]
|
||||
_InputGuardrailFuncAsync = Callable[
|
||||
[RunContextWrapper[TContext_co], "Agent[Any]", Union[str, list[TResponseInputItem]]],
|
||||
[
|
||||
RunContextWrapper[TContext_co],
|
||||
"Agent[Any]",
|
||||
Union[str, list[TResponseInputItem]],
|
||||
],
|
||||
Awaitable[GuardrailFunctionOutput],
|
||||
]
|
||||
|
||||
@@ -215,9 +227,11 @@ def input_guardrail(
|
||||
|
||||
|
||||
def input_guardrail(
|
||||
func: _InputGuardrailFuncSync[TContext_co]
|
||||
| _InputGuardrailFuncAsync[TContext_co]
|
||||
| None = None,
|
||||
func: (
|
||||
_InputGuardrailFuncSync[TContext_co]
|
||||
| _InputGuardrailFuncAsync[TContext_co]
|
||||
| None
|
||||
) = None,
|
||||
*,
|
||||
name: str | None = None,
|
||||
) -> (
|
||||
@@ -284,15 +298,20 @@ def output_guardrail(
|
||||
|
||||
|
||||
def output_guardrail(
|
||||
func: _OutputGuardrailFuncSync[TContext_co]
|
||||
| _OutputGuardrailFuncAsync[TContext_co]
|
||||
| None = None,
|
||||
func: (
|
||||
_OutputGuardrailFuncSync[TContext_co]
|
||||
| _OutputGuardrailFuncAsync[TContext_co]
|
||||
| None
|
||||
) = None,
|
||||
*,
|
||||
name: str | None = None,
|
||||
) -> (
|
||||
OutputGuardrail[TContext_co]
|
||||
| Callable[
|
||||
[_OutputGuardrailFuncSync[TContext_co] | _OutputGuardrailFuncAsync[TContext_co]],
|
||||
[
|
||||
_OutputGuardrailFuncSync[TContext_co]
|
||||
| _OutputGuardrailFuncAsync[TContext_co]
|
||||
],
|
||||
OutputGuardrail[TContext_co],
|
||||
]
|
||||
):
|
||||
@@ -308,7 +327,10 @@ def output_guardrail(
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
f: _OutputGuardrailFuncSync[TContext_co] | _OutputGuardrailFuncAsync[TContext_co],
|
||||
f: (
|
||||
_OutputGuardrailFuncSync[TContext_co]
|
||||
| _OutputGuardrailFuncAsync[TContext_co]
|
||||
),
|
||||
) -> OutputGuardrail[TContext_co]:
|
||||
return OutputGuardrail(guardrail_function=f, name=name)
|
||||
|
||||
|
||||
@@ -67,7 +67,9 @@ class Handoff(Generic[TContext]):
|
||||
"""The JSON schema for the handoff input. Can be empty if the handoff does not take an input.
|
||||
"""
|
||||
|
||||
on_invoke_handoff: Callable[[RunContextWrapper[Any], str], Awaitable[Agent[TContext]]]
|
||||
on_invoke_handoff: Callable[
|
||||
[RunContextWrapper[Any], str], Awaitable[Agent[TContext]]
|
||||
]
|
||||
"""The function that invokes the handoff. The parameters passed are:
|
||||
1. The handoff run context
|
||||
2. The arguments from the LLM, as a JSON string. Empty string if input_json_schema is empty.
|
||||
@@ -167,9 +169,9 @@ def handoff(
|
||||
against this type. Only relevant if you pass a function that takes an input.
|
||||
input_filter: a function that filters the inputs that are passed to the next agent.
|
||||
"""
|
||||
assert (on_handoff and input_type) or not (on_handoff and input_type), (
|
||||
"You must provide either both on_input and input_type, or neither"
|
||||
)
|
||||
assert (on_handoff and input_type) or not (
|
||||
on_handoff and input_type
|
||||
), "You must provide either both on_input and input_type, or neither"
|
||||
type_adapter: TypeAdapter[Any] | None
|
||||
if input_type is not None:
|
||||
assert callable(on_handoff), "on_handoff must be callable"
|
||||
@@ -198,7 +200,9 @@ def handoff(
|
||||
data={"details": "input_json is None"},
|
||||
)
|
||||
)
|
||||
raise ModelBehaviorError("Handoff function expected non-null input, but got None")
|
||||
raise ModelBehaviorError(
|
||||
"Handoff function expected non-null input, but got None"
|
||||
)
|
||||
|
||||
validated_input = _utils.validate_json(
|
||||
json_str=input_json,
|
||||
@@ -220,7 +224,9 @@ def handoff(
|
||||
return agent
|
||||
|
||||
tool_name = tool_name_override or Handoff.default_tool_name(agent)
|
||||
tool_description = tool_description_override or Handoff.default_tool_description(agent)
|
||||
tool_description = tool_description_override or Handoff.default_tool_description(
|
||||
agent
|
||||
)
|
||||
|
||||
# Always ensure the input JSON schema is in strict mode
|
||||
# If there is a need, we can make this configurable in the future
|
||||
|
||||
@@ -18,7 +18,10 @@ from openai.types.responses import (
|
||||
ResponseOutputText,
|
||||
ResponseStreamEvent,
|
||||
)
|
||||
from openai.types.responses.response_input_item_param import ComputerCallOutput, FunctionCallOutput
|
||||
from openai.types.responses.response_input_item_param import (
|
||||
ComputerCallOutput,
|
||||
FunctionCallOutput,
|
||||
)
|
||||
from openai.types.responses.response_reasoning_item import ResponseReasoningItem
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
@@ -61,7 +61,9 @@ class AgentHooks(Generic[TContext]):
|
||||
Subclass and override the methods you need.
|
||||
"""
|
||||
|
||||
async def on_start(self, context: RunContextWrapper[TContext], agent: Agent[TContext]) -> None:
|
||||
async def on_start(
|
||||
self, context: RunContextWrapper[TContext], agent: Agent[TContext]
|
||||
) -> None:
|
||||
"""Called before the agent is invoked. Called each time the running agent is changed to this
|
||||
agent."""
|
||||
pass
|
||||
|
||||
@@ -7,7 +7,12 @@ remember past interactions, learn from experience, and maintain context.
|
||||
from .types import MemoryEntry, MemoryType
|
||||
from .memory import Memory
|
||||
from .store import MemoryStore, InMemoryMemoryStore, VectorMemoryStore
|
||||
from .retriever import MemoryRetriever, SemanticRetriever, RecencyRetriever, HybridRetriever
|
||||
from .retriever import (
|
||||
MemoryRetriever,
|
||||
SemanticRetriever,
|
||||
RecencyRetriever,
|
||||
HybridRetriever,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Memory",
|
||||
@@ -20,4 +25,4 @@ __all__ = [
|
||||
"SemanticRetriever",
|
||||
"RecencyRetriever",
|
||||
"HybridRetriever",
|
||||
]
|
||||
]
|
||||
|
||||
@@ -14,11 +14,9 @@ from .store import MemoryStore, InMemoryMemoryStore
|
||||
from .retriever import MemoryRetriever, SemanticRetriever
|
||||
|
||||
|
||||
|
||||
|
||||
class Memory:
|
||||
"""Memory system for agents."""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: MemoryStore | None = None,
|
||||
@@ -28,7 +26,7 @@ class Memory:
|
||||
compress_threshold: int = 100,
|
||||
):
|
||||
"""Initialize memory system.
|
||||
|
||||
|
||||
Args:
|
||||
store: Memory storage backend
|
||||
retriever: Memory retrieval strategy
|
||||
@@ -41,7 +39,7 @@ class Memory:
|
||||
self.max_entries = max_entries
|
||||
self.auto_compress = auto_compress
|
||||
self.compress_threshold = compress_threshold
|
||||
|
||||
|
||||
# Backwards-compatible aliases for tests and existing code
|
||||
async def add(
|
||||
self,
|
||||
@@ -77,7 +75,7 @@ class Memory:
|
||||
|
||||
async def get_all(self) -> List[MemoryEntry]:
|
||||
return await self.store.list()
|
||||
|
||||
|
||||
async def remember(
|
||||
self,
|
||||
content: str,
|
||||
@@ -87,14 +85,14 @@ class Memory:
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> MemoryEntry:
|
||||
"""Store a new memory.
|
||||
|
||||
|
||||
Args:
|
||||
content: The content to remember
|
||||
type: Type of memory
|
||||
agent_name: Name of the agent creating the memory
|
||||
importance: Importance score
|
||||
metadata: Additional metadata
|
||||
|
||||
|
||||
Returns:
|
||||
The created memory entry
|
||||
"""
|
||||
@@ -103,7 +101,7 @@ class Memory:
|
||||
count = await self.store.count()
|
||||
if count >= self.compress_threshold:
|
||||
await self._compress_memories()
|
||||
|
||||
|
||||
# Create memory entry
|
||||
entry = await self.store.add(
|
||||
content=content,
|
||||
@@ -112,17 +110,17 @@ class Memory:
|
||||
importance=importance,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
|
||||
# Ensure we don't exceed the max_entries after adding
|
||||
if self.auto_compress:
|
||||
count_after = await self.store.count()
|
||||
if count_after > self.max_entries:
|
||||
await self._compress_memories()
|
||||
|
||||
|
||||
logger.debug(f"Stored memory: {entry.id} ({type.value})")
|
||||
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
async def recall(
|
||||
self,
|
||||
query: str | None = None,
|
||||
@@ -132,14 +130,14 @@ class Memory:
|
||||
min_importance: float = 0.0,
|
||||
) -> List[MemoryEntry]:
|
||||
"""Retrieve memories.
|
||||
|
||||
|
||||
Args:
|
||||
query: Query for semantic search
|
||||
type: Filter by memory type
|
||||
agent_name: Filter by agent name
|
||||
limit: Maximum number of results
|
||||
min_importance: Minimum importance threshold
|
||||
|
||||
|
||||
Returns:
|
||||
List of matching memories
|
||||
"""
|
||||
@@ -149,7 +147,7 @@ class Memory:
|
||||
agent_name=agent_name,
|
||||
min_importance=min_importance,
|
||||
)
|
||||
|
||||
|
||||
# Use retriever to find best matches
|
||||
if query and self.retriever:
|
||||
memories = await self.retriever.retrieve(
|
||||
@@ -160,42 +158,42 @@ class Memory:
|
||||
else:
|
||||
# Just return most recent
|
||||
memories = sorted(memories, key=lambda m: m.timestamp, reverse=True)[:limit]
|
||||
|
||||
|
||||
# Update access counts
|
||||
for memory in memories:
|
||||
memory.access_count += 1
|
||||
memory.last_accessed = time.time()
|
||||
await self.store.update(memory)
|
||||
|
||||
|
||||
return memories
|
||||
|
||||
|
||||
async def forget(self, memory_id: str) -> None:
|
||||
"""Remove a specific memory."""
|
||||
await self.store.delete(memory_id)
|
||||
logger.debug(f"Deleted memory: {memory_id}")
|
||||
|
||||
|
||||
async def clear(
|
||||
self,
|
||||
type: MemoryType | None = None,
|
||||
agent_name: str | None = None,
|
||||
) -> int:
|
||||
"""Clear memories.
|
||||
|
||||
|
||||
Args:
|
||||
type: Clear only memories of this type
|
||||
agent_name: Clear only memories from this agent
|
||||
|
||||
|
||||
Returns:
|
||||
Number of memories cleared
|
||||
"""
|
||||
memories = await self.store.list(type=type, agent_name=agent_name)
|
||||
|
||||
|
||||
for memory in memories:
|
||||
await self.store.delete(memory.id)
|
||||
|
||||
|
||||
logger.debug(f"Cleared {len(memories)} memories")
|
||||
return len(memories)
|
||||
|
||||
|
||||
async def reflect(
|
||||
self,
|
||||
agent: Agent[TContext],
|
||||
@@ -203,14 +201,14 @@ class Memory:
|
||||
recent_limit: int = 20,
|
||||
) -> MemoryEntry:
|
||||
"""Generate a reflection based on recent memories.
|
||||
|
||||
|
||||
This allows the agent to synthesize and learn from recent experiences.
|
||||
|
||||
|
||||
Args:
|
||||
agent: The agent doing the reflection
|
||||
context: Optional context
|
||||
recent_limit: Number of recent memories to consider
|
||||
|
||||
|
||||
Returns:
|
||||
The reflection memory entry
|
||||
"""
|
||||
@@ -219,35 +217,32 @@ class Memory:
|
||||
agent_name=agent.name,
|
||||
limit=recent_limit,
|
||||
)
|
||||
|
||||
|
||||
if not recent:
|
||||
content = "No recent memories to reflect on."
|
||||
else:
|
||||
# Build reflection prompt
|
||||
memory_text = "\n".join([
|
||||
f"- [{m.type.value}] {m.content}"
|
||||
for m in recent
|
||||
])
|
||||
|
||||
memory_text = "\n".join([f"- [{m.type.value}] {m.content}" for m in recent])
|
||||
|
||||
# Use agent to generate reflection
|
||||
from ..run import Runner
|
||||
|
||||
|
||||
reflection_agent = agent.clone(
|
||||
instructions=(
|
||||
"You are reflecting on recent memories and experiences. "
|
||||
"Synthesize key insights, patterns, and learnings."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
result = await Runner.run(
|
||||
starting_agent=reflection_agent,
|
||||
input=f"Reflect on these recent memories:\n\n{memory_text}",
|
||||
context=context.context if context else None,
|
||||
max_turns=1,
|
||||
)
|
||||
|
||||
|
||||
content = ItemHelpers.text_message_outputs(result.new_items)
|
||||
|
||||
|
||||
# Store reflection
|
||||
reflection = await self.remember(
|
||||
content=content,
|
||||
@@ -256,56 +251,54 @@ class Memory:
|
||||
importance=0.8,
|
||||
metadata={"recent_memory_count": len(recent)},
|
||||
)
|
||||
|
||||
|
||||
return reflection
|
||||
|
||||
|
||||
async def _compress_memories(self) -> None:
|
||||
"""Compress old memories to save space."""
|
||||
# Get all memories sorted by importance and recency
|
||||
memories = await self.store.list()
|
||||
|
||||
|
||||
# Score memories
|
||||
now = time.time()
|
||||
scored = []
|
||||
|
||||
|
||||
for memory in memories:
|
||||
# Calculate score based on importance, recency, and access
|
||||
recency_score = 1.0 / (1.0 + (now - memory.timestamp) / 86400) # Days
|
||||
access_score = min(1.0, memory.access_count / 10)
|
||||
|
||||
score = (
|
||||
memory.importance * 0.5 +
|
||||
recency_score * 0.3 +
|
||||
access_score * 0.2
|
||||
)
|
||||
|
||||
|
||||
score = memory.importance * 0.5 + recency_score * 0.3 + access_score * 0.2
|
||||
|
||||
scored.append((score, memory))
|
||||
|
||||
|
||||
# Sort by score
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
|
||||
# Keep top memories
|
||||
to_keep = min(self.max_entries, len(scored))
|
||||
to_delete = len(scored) - to_keep
|
||||
|
||||
|
||||
if to_delete > 0:
|
||||
# Delete lowest scored memories
|
||||
for _, memory in scored[to_keep:]:
|
||||
await self.store.delete(memory.id)
|
||||
|
||||
|
||||
logger.debug(f"Compressed memories: deleted {to_delete} entries")
|
||||
|
||||
|
||||
def create_agent_wrapper(self, agent: Agent[TContext]) -> Agent[TContext]:
|
||||
"""Create an agent wrapper with memory capabilities.
|
||||
|
||||
|
||||
This returns a new agent that automatically stores and retrieves memories.
|
||||
"""
|
||||
memory = self
|
||||
|
||||
async def memory_instructions(ctx: RunContextWrapper[TContext], agent: Agent[TContext]) -> str:
|
||||
|
||||
async def memory_instructions(
|
||||
ctx: RunContextWrapper[TContext], agent: Agent[TContext]
|
||||
) -> str:
|
||||
# Get base instructions
|
||||
base = await agent.get_system_prompt(ctx)
|
||||
|
||||
|
||||
# Retrieve relevant memories
|
||||
if ctx and hasattr(ctx, "last_message"):
|
||||
memories = await memory.recall(
|
||||
@@ -313,17 +306,14 @@ class Memory:
|
||||
agent_name=agent.name,
|
||||
limit=5,
|
||||
)
|
||||
|
||||
|
||||
if memories:
|
||||
memory_text = "\n".join([
|
||||
f"- {m.content}"
|
||||
for m in memories
|
||||
])
|
||||
|
||||
memory_text = "\n".join([f"- {m.content}" for m in memories])
|
||||
|
||||
return f"{base}\n\nRelevant memories:\n{memory_text}"
|
||||
|
||||
|
||||
return base
|
||||
|
||||
|
||||
# Create memory-enabled agent
|
||||
return agent.clone(
|
||||
instructions=memory_instructions,
|
||||
@@ -333,12 +323,14 @@ class Memory:
|
||||
|
||||
class MemoryAgentHooks:
|
||||
"""Agent hooks for automatic memory management."""
|
||||
|
||||
|
||||
def __init__(self, memory: Memory, agent_name: str):
|
||||
self.memory = memory
|
||||
self.agent_name = agent_name
|
||||
|
||||
async def on_start(self, context: RunContextWrapper[Any], agent: Agent[Any]) -> None:
|
||||
|
||||
async def on_start(
|
||||
self, context: RunContextWrapper[Any], agent: Agent[Any]
|
||||
) -> None:
|
||||
"""Store conversation start."""
|
||||
await self.memory.remember(
|
||||
content="Conversation started",
|
||||
@@ -346,7 +338,7 @@ class MemoryAgentHooks:
|
||||
agent_name=self.agent_name,
|
||||
importance=0.3,
|
||||
)
|
||||
|
||||
|
||||
async def on_end(self, context: RunContextWrapper[Any], agent: Agent[Any]) -> None:
|
||||
"""Store conversation end."""
|
||||
await self.memory.remember(
|
||||
|
||||
@@ -12,7 +12,7 @@ from ..logger import logger
|
||||
|
||||
class MemoryRetriever(ABC):
|
||||
"""Abstract base class for memory retrieval strategies."""
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def retrieve(
|
||||
self,
|
||||
@@ -21,12 +21,12 @@ class MemoryRetriever(ABC):
|
||||
limit: int = 10,
|
||||
) -> List[MemoryEntry]:
|
||||
"""Retrieve relevant memories.
|
||||
|
||||
|
||||
Args:
|
||||
query: The query to match against
|
||||
memories: Pool of memories to search
|
||||
limit: Maximum number of results
|
||||
|
||||
|
||||
Returns:
|
||||
List of relevant memories
|
||||
"""
|
||||
@@ -35,10 +35,10 @@ class MemoryRetriever(ABC):
|
||||
|
||||
class SemanticRetriever(MemoryRetriever):
|
||||
"""Retrieves memories based on semantic similarity."""
|
||||
|
||||
|
||||
def __init__(self, embedding_model: str | None = None):
|
||||
self.embedding_model = embedding_model
|
||||
|
||||
|
||||
async def retrieve(
|
||||
self,
|
||||
query: str,
|
||||
@@ -48,12 +48,12 @@ class SemanticRetriever(MemoryRetriever):
|
||||
"""Retrieve semantically similar memories."""
|
||||
if not memories:
|
||||
return []
|
||||
|
||||
|
||||
# If memories have embeddings, use vector similarity
|
||||
if any(m.embedding for m in memories):
|
||||
# Get query embedding
|
||||
query_embedding = await self._get_embedding(query)
|
||||
|
||||
|
||||
if query_embedding:
|
||||
# Calculate similarities
|
||||
scores = []
|
||||
@@ -64,29 +64,29 @@ class SemanticRetriever(MemoryRetriever):
|
||||
memory.embedding,
|
||||
)
|
||||
scores.append((memory, similarity))
|
||||
|
||||
|
||||
# Sort by similarity
|
||||
scores.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
|
||||
return [memory for memory, _ in scores[:limit]]
|
||||
|
||||
|
||||
# Fallback to keyword matching
|
||||
return self._keyword_search(query, memories, limit)
|
||||
|
||||
|
||||
async def _get_embedding(self, text: str) -> List[float] | None:
|
||||
"""Get embedding for text."""
|
||||
# This is a placeholder - implement based on your embedding service
|
||||
return None
|
||||
|
||||
|
||||
def _cosine_similarity(self, vec1: List[float], vec2: List[float]) -> float:
|
||||
"""Calculate cosine similarity between two vectors."""
|
||||
import numpy as np
|
||||
|
||||
|
||||
v1 = np.array(vec1)
|
||||
v2 = np.array(vec2)
|
||||
|
||||
|
||||
return float(np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)))
|
||||
|
||||
|
||||
def _keyword_search(
|
||||
self,
|
||||
query: str,
|
||||
@@ -95,31 +95,31 @@ class SemanticRetriever(MemoryRetriever):
|
||||
) -> List[MemoryEntry]:
|
||||
"""Simple keyword-based search."""
|
||||
query_words = set(query.lower().split())
|
||||
|
||||
|
||||
scores = []
|
||||
for memory in memories:
|
||||
content_words = set(memory.content.lower().split())
|
||||
|
||||
|
||||
# Calculate Jaccard similarity
|
||||
intersection = len(query_words & content_words)
|
||||
union = len(query_words | content_words)
|
||||
|
||||
|
||||
if union > 0:
|
||||
similarity = intersection / union
|
||||
scores.append((memory, similarity))
|
||||
|
||||
|
||||
# Sort by similarity
|
||||
scores.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
|
||||
return [memory for memory, _ in scores[:limit]]
|
||||
|
||||
|
||||
class RecencyRetriever(MemoryRetriever):
|
||||
"""Retrieves memories based on recency."""
|
||||
|
||||
|
||||
def __init__(self, decay_factor: float = 0.99):
|
||||
self.decay_factor = decay_factor
|
||||
|
||||
|
||||
async def retrieve(
|
||||
self,
|
||||
query: str,
|
||||
@@ -133,13 +133,13 @@ class RecencyRetriever(MemoryRetriever):
|
||||
key=lambda m: m.timestamp,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
return sorted_memories[:limit]
|
||||
|
||||
|
||||
class HybridRetriever(MemoryRetriever):
|
||||
"""Combines multiple retrieval strategies."""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
semantic_weight: float = 0.6,
|
||||
@@ -152,7 +152,7 @@ class HybridRetriever(MemoryRetriever):
|
||||
self.importance_weight = importance_weight
|
||||
self.semantic_retriever = SemanticRetriever(embedding_model)
|
||||
self.recency_retriever = RecencyRetriever()
|
||||
|
||||
|
||||
async def retrieve(
|
||||
self,
|
||||
query: str,
|
||||
@@ -162,7 +162,7 @@ class HybridRetriever(MemoryRetriever):
|
||||
"""Retrieve using hybrid scoring."""
|
||||
if not memories:
|
||||
return []
|
||||
|
||||
|
||||
# Get semantic scores
|
||||
semantic_results = await self.semantic_retriever.retrieve(
|
||||
query, memories, len(memories)
|
||||
@@ -171,35 +171,35 @@ class HybridRetriever(MemoryRetriever):
|
||||
m.id: 1.0 - (i / len(semantic_results))
|
||||
for i, m in enumerate(semantic_results)
|
||||
}
|
||||
|
||||
|
||||
# Get recency scores
|
||||
now = time.time()
|
||||
recency_scores = {}
|
||||
for memory in memories:
|
||||
age_days = (now - memory.timestamp) / 86400
|
||||
recency_scores[memory.id] = 1.0 / (1.0 + age_days)
|
||||
|
||||
|
||||
# Get importance scores
|
||||
importance_scores = {m.id: m.importance for m in memories}
|
||||
|
||||
|
||||
# Combine scores
|
||||
final_scores = {}
|
||||
for memory in memories:
|
||||
semantic = semantic_scores.get(memory.id, 0.0)
|
||||
recency = recency_scores.get(memory.id, 0.0)
|
||||
importance = importance_scores.get(memory.id, 0.0)
|
||||
|
||||
|
||||
final_scores[memory.id] = (
|
||||
semantic * self.semantic_weight +
|
||||
recency * self.recency_weight +
|
||||
importance * self.importance_weight
|
||||
semantic * self.semantic_weight
|
||||
+ recency * self.recency_weight
|
||||
+ importance * self.importance_weight
|
||||
)
|
||||
|
||||
|
||||
# Sort by final score
|
||||
sorted_memories = sorted(
|
||||
memories,
|
||||
key=lambda m: final_scores[m.id],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
return sorted_memories[:limit]
|
||||
|
||||
return sorted_memories[:limit]
|
||||
|
||||
@@ -14,7 +14,7 @@ from ..logger import logger
|
||||
|
||||
class MemoryStore(ABC):
|
||||
"""Abstract base class for memory stores."""
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def add(
|
||||
self,
|
||||
@@ -27,22 +27,22 @@ class MemoryStore(ABC):
|
||||
) -> MemoryEntry:
|
||||
"""Add a new memory."""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def get(self, memory_id: str) -> MemoryEntry | None:
|
||||
"""Get a memory by ID."""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def update(self, memory: MemoryEntry) -> None:
|
||||
"""Update an existing memory."""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, memory_id: str) -> None:
|
||||
"""Delete a memory."""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def list(
|
||||
self,
|
||||
@@ -53,7 +53,7 @@ class MemoryStore(ABC):
|
||||
) -> List[MemoryEntry]:
|
||||
"""List memories with filters."""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def count(
|
||||
self,
|
||||
@@ -62,7 +62,7 @@ class MemoryStore(ABC):
|
||||
) -> int:
|
||||
"""Count memories."""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def search_by_embedding(
|
||||
self,
|
||||
@@ -76,10 +76,10 @@ class MemoryStore(ABC):
|
||||
|
||||
class InMemoryMemoryStore(MemoryStore):
|
||||
"""In-memory memory store for development."""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.memories: Dict[str, MemoryEntry] = {}
|
||||
|
||||
|
||||
async def add(
|
||||
self,
|
||||
content: str,
|
||||
@@ -99,23 +99,23 @@ class InMemoryMemoryStore(MemoryStore):
|
||||
metadata=metadata or {},
|
||||
embedding=embedding,
|
||||
)
|
||||
|
||||
|
||||
self.memories[memory.id] = memory
|
||||
return memory
|
||||
|
||||
|
||||
async def get(self, memory_id: str) -> MemoryEntry | None:
|
||||
"""Get a memory by ID."""
|
||||
return self.memories.get(memory_id)
|
||||
|
||||
|
||||
async def update(self, memory: MemoryEntry) -> None:
|
||||
"""Update an existing memory."""
|
||||
if memory.id in self.memories:
|
||||
self.memories[memory.id] = memory
|
||||
|
||||
|
||||
async def delete(self, memory_id: str) -> None:
|
||||
"""Delete a memory."""
|
||||
self.memories.pop(memory_id, None)
|
||||
|
||||
|
||||
async def list(
|
||||
self,
|
||||
type: MemoryType | None = None,
|
||||
@@ -125,7 +125,7 @@ class InMemoryMemoryStore(MemoryStore):
|
||||
) -> List[MemoryEntry]:
|
||||
"""List memories with filters."""
|
||||
memories = list(self.memories.values())
|
||||
|
||||
|
||||
# Apply filters
|
||||
if type:
|
||||
memories = [m for m in memories if m.type == type]
|
||||
@@ -133,16 +133,16 @@ class InMemoryMemoryStore(MemoryStore):
|
||||
memories = [m for m in memories if m.agent_name == agent_name]
|
||||
if min_importance > 0:
|
||||
memories = [m for m in memories if m.importance >= min_importance]
|
||||
|
||||
|
||||
# Sort by timestamp (newest first)
|
||||
memories.sort(key=lambda m: m.timestamp, reverse=True)
|
||||
|
||||
|
||||
# Apply limit
|
||||
if limit:
|
||||
memories = memories[:limit]
|
||||
|
||||
|
||||
return memories
|
||||
|
||||
|
||||
async def count(
|
||||
self,
|
||||
type: MemoryType | None = None,
|
||||
@@ -151,7 +151,7 @@ class InMemoryMemoryStore(MemoryStore):
|
||||
"""Count memories."""
|
||||
memories = await self.list(type=type, agent_name=agent_name)
|
||||
return len(memories)
|
||||
|
||||
|
||||
async def search_by_embedding(
|
||||
self,
|
||||
embedding: List[float],
|
||||
@@ -161,32 +161,32 @@ class InMemoryMemoryStore(MemoryStore):
|
||||
"""Search memories by embedding similarity."""
|
||||
if not embedding:
|
||||
return []
|
||||
|
||||
|
||||
results = []
|
||||
query_vec = np.array(embedding)
|
||||
|
||||
|
||||
for memory in self.memories.values():
|
||||
if not memory.embedding:
|
||||
continue
|
||||
|
||||
|
||||
# Calculate cosine similarity
|
||||
memory_vec = np.array(memory.embedding)
|
||||
similarity = np.dot(query_vec, memory_vec) / (
|
||||
np.linalg.norm(query_vec) * np.linalg.norm(memory_vec)
|
||||
)
|
||||
|
||||
|
||||
if similarity >= threshold:
|
||||
results.append((memory, float(similarity)))
|
||||
|
||||
|
||||
# Sort by similarity
|
||||
results.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
|
||||
return results[:limit]
|
||||
|
||||
|
||||
class VectorMemoryStore(MemoryStore):
|
||||
"""Vector database backed memory store."""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
collection_name: str = "agent_memories",
|
||||
@@ -194,7 +194,7 @@ class VectorMemoryStore(MemoryStore):
|
||||
**vector_db_kwargs,
|
||||
):
|
||||
"""Initialize vector memory store.
|
||||
|
||||
|
||||
Args:
|
||||
collection_name: Name of the vector collection
|
||||
embedding_model: Model to use for embeddings
|
||||
@@ -205,7 +205,7 @@ class VectorMemoryStore(MemoryStore):
|
||||
self.vector_db_kwargs = vector_db_kwargs
|
||||
self._client = None
|
||||
self._collection = None
|
||||
|
||||
|
||||
async def _get_client(self):
|
||||
"""Get or create vector database client."""
|
||||
if self._client is None:
|
||||
@@ -216,15 +216,15 @@ class VectorMemoryStore(MemoryStore):
|
||||
"Vector memory store requires 'chromadb' package. "
|
||||
"Install with: pip install chromadb"
|
||||
)
|
||||
|
||||
|
||||
self._client = chromadb.Client(**self.vector_db_kwargs)
|
||||
self._collection = self._client.get_or_create_collection(
|
||||
name=self.collection_name,
|
||||
metadata={"hnsw:space": "cosine"},
|
||||
)
|
||||
|
||||
|
||||
return self._client, self._collection
|
||||
|
||||
|
||||
async def _get_embedding(self, text: str) -> List[float]:
|
||||
"""Get embedding for text."""
|
||||
if self.embedding_model:
|
||||
@@ -234,7 +234,7 @@ class VectorMemoryStore(MemoryStore):
|
||||
else:
|
||||
# Use ChromaDB's default embedding
|
||||
return None
|
||||
|
||||
|
||||
async def add(
|
||||
self,
|
||||
content: str,
|
||||
@@ -246,7 +246,7 @@ class VectorMemoryStore(MemoryStore):
|
||||
) -> MemoryEntry:
|
||||
"""Add a new memory."""
|
||||
_, collection = await self._get_client()
|
||||
|
||||
|
||||
memory = MemoryEntry(
|
||||
id=str(uuid.uuid4()),
|
||||
type=type,
|
||||
@@ -256,7 +256,7 @@ class VectorMemoryStore(MemoryStore):
|
||||
metadata=metadata or {},
|
||||
embedding=embedding,
|
||||
)
|
||||
|
||||
|
||||
# Prepare metadata for ChromaDB
|
||||
chroma_metadata = {
|
||||
"type": type.value,
|
||||
@@ -265,7 +265,7 @@ class VectorMemoryStore(MemoryStore):
|
||||
"timestamp": memory.timestamp,
|
||||
**memory.metadata,
|
||||
}
|
||||
|
||||
|
||||
# Add to vector database
|
||||
collection.add(
|
||||
ids=[memory.id],
|
||||
@@ -273,21 +273,21 @@ class VectorMemoryStore(MemoryStore):
|
||||
metadatas=[chroma_metadata],
|
||||
embeddings=[embedding] if embedding else None,
|
||||
)
|
||||
|
||||
|
||||
return memory
|
||||
|
||||
|
||||
async def get(self, memory_id: str) -> MemoryEntry | None:
|
||||
"""Get a memory by ID."""
|
||||
_, collection = await self._get_client()
|
||||
|
||||
|
||||
result = collection.get(ids=[memory_id])
|
||||
|
||||
|
||||
if not result["ids"]:
|
||||
return None
|
||||
|
||||
|
||||
# Reconstruct memory entry
|
||||
metadata = result["metadatas"][0]
|
||||
|
||||
|
||||
return MemoryEntry(
|
||||
id=memory_id,
|
||||
type=MemoryType(metadata["type"]),
|
||||
@@ -295,15 +295,18 @@ class VectorMemoryStore(MemoryStore):
|
||||
agent_name=metadata.get("agent_name") or None,
|
||||
importance=metadata.get("importance", 1.0),
|
||||
timestamp=metadata.get("timestamp", 0),
|
||||
metadata={k: v for k, v in metadata.items()
|
||||
if k not in ["type", "agent_name", "importance", "timestamp"]},
|
||||
metadata={
|
||||
k: v
|
||||
for k, v in metadata.items()
|
||||
if k not in ["type", "agent_name", "importance", "timestamp"]
|
||||
},
|
||||
embedding=result.get("embeddings", [None])[0],
|
||||
)
|
||||
|
||||
|
||||
async def update(self, memory: MemoryEntry) -> None:
|
||||
"""Update an existing memory."""
|
||||
_, collection = await self._get_client()
|
||||
|
||||
|
||||
# Update in vector database
|
||||
chroma_metadata = {
|
||||
"type": memory.type.value,
|
||||
@@ -314,19 +317,19 @@ class VectorMemoryStore(MemoryStore):
|
||||
"last_accessed": memory.last_accessed or 0,
|
||||
**memory.metadata,
|
||||
}
|
||||
|
||||
|
||||
collection.update(
|
||||
ids=[memory.id],
|
||||
documents=[memory.content],
|
||||
metadatas=[chroma_metadata],
|
||||
embeddings=[memory.embedding] if memory.embedding else None,
|
||||
)
|
||||
|
||||
|
||||
async def delete(self, memory_id: str) -> None:
|
||||
"""Delete a memory."""
|
||||
_, collection = await self._get_client()
|
||||
collection.delete(ids=[memory_id])
|
||||
|
||||
|
||||
async def list(
|
||||
self,
|
||||
type: MemoryType | None = None,
|
||||
@@ -336,7 +339,7 @@ class VectorMemoryStore(MemoryStore):
|
||||
) -> List[MemoryEntry]:
|
||||
"""List memories with filters."""
|
||||
_, collection = await self._get_client()
|
||||
|
||||
|
||||
# Build where clause
|
||||
where = {}
|
||||
if type:
|
||||
@@ -345,18 +348,18 @@ class VectorMemoryStore(MemoryStore):
|
||||
where["agent_name"] = agent_name
|
||||
if min_importance > 0:
|
||||
where["importance"] = {"$gte": min_importance}
|
||||
|
||||
|
||||
# Query collection
|
||||
result = collection.get(
|
||||
where=where if where else None,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
# Convert to memory entries
|
||||
memories = []
|
||||
for i in range(len(result["ids"])):
|
||||
metadata = result["metadatas"][i]
|
||||
|
||||
|
||||
memory = MemoryEntry(
|
||||
id=result["ids"][i],
|
||||
type=MemoryType(metadata["type"]),
|
||||
@@ -366,18 +369,28 @@ class VectorMemoryStore(MemoryStore):
|
||||
timestamp=metadata.get("timestamp", 0),
|
||||
access_count=metadata.get("access_count", 0),
|
||||
last_accessed=metadata.get("last_accessed"),
|
||||
metadata={k: v for k, v in metadata.items()
|
||||
if k not in ["type", "agent_name", "importance", "timestamp",
|
||||
"access_count", "last_accessed"]},
|
||||
metadata={
|
||||
k: v
|
||||
for k, v in metadata.items()
|
||||
if k
|
||||
not in [
|
||||
"type",
|
||||
"agent_name",
|
||||
"importance",
|
||||
"timestamp",
|
||||
"access_count",
|
||||
"last_accessed",
|
||||
]
|
||||
},
|
||||
embedding=result.get("embeddings", [None] * len(result["ids"]))[i],
|
||||
)
|
||||
memories.append(memory)
|
||||
|
||||
|
||||
# Sort by timestamp
|
||||
memories.sort(key=lambda m: m.timestamp, reverse=True)
|
||||
|
||||
|
||||
return memories
|
||||
|
||||
|
||||
async def count(
|
||||
self,
|
||||
type: MemoryType | None = None,
|
||||
@@ -386,7 +399,7 @@ class VectorMemoryStore(MemoryStore):
|
||||
"""Count memories."""
|
||||
memories = await self.list(type=type, agent_name=agent_name)
|
||||
return len(memories)
|
||||
|
||||
|
||||
async def search_by_embedding(
|
||||
self,
|
||||
embedding: List[float],
|
||||
@@ -395,26 +408,26 @@ class VectorMemoryStore(MemoryStore):
|
||||
) -> List[tuple[MemoryEntry, float]]:
|
||||
"""Search memories by embedding similarity."""
|
||||
_, collection = await self._get_client()
|
||||
|
||||
|
||||
# Query by embedding
|
||||
result = collection.query(
|
||||
query_embeddings=[embedding],
|
||||
n_results=limit,
|
||||
)
|
||||
|
||||
|
||||
if not result["ids"][0]:
|
||||
return []
|
||||
|
||||
|
||||
# Convert to memory entries with scores
|
||||
memories_with_scores = []
|
||||
|
||||
|
||||
for i in range(len(result["ids"][0])):
|
||||
metadata = result["metadatas"][0][i]
|
||||
distance = result["distances"][0][i]
|
||||
|
||||
|
||||
# Convert distance to similarity (1 - normalized distance)
|
||||
similarity = 1.0 - (distance / 2.0) # Cosine distance is [0, 2]
|
||||
|
||||
|
||||
if similarity >= threshold:
|
||||
memory = MemoryEntry(
|
||||
id=result["ids"][0][i],
|
||||
@@ -425,11 +438,21 @@ class VectorMemoryStore(MemoryStore):
|
||||
timestamp=metadata.get("timestamp", 0),
|
||||
access_count=metadata.get("access_count", 0),
|
||||
last_accessed=metadata.get("last_accessed"),
|
||||
metadata={k: v for k, v in metadata.items()
|
||||
if k not in ["type", "agent_name", "importance", "timestamp",
|
||||
"access_count", "last_accessed"]},
|
||||
metadata={
|
||||
k: v
|
||||
for k, v in metadata.items()
|
||||
if k
|
||||
not in [
|
||||
"type",
|
||||
"agent_name",
|
||||
"importance",
|
||||
"timestamp",
|
||||
"access_count",
|
||||
"last_accessed",
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
memories_with_scores.append((memory, similarity))
|
||||
|
||||
return memories_with_scores
|
||||
|
||||
return memories_with_scores
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
class MemoryType(Enum):
|
||||
"""Type of memory entry."""
|
||||
|
||||
SHORT_TERM = "short_term"
|
||||
LONG_TERM = "long_term"
|
||||
WORKING = "working"
|
||||
@@ -25,6 +26,7 @@ class MemoryType(Enum):
|
||||
@dataclass
|
||||
class MemoryEntry:
|
||||
"""A single memory entry."""
|
||||
|
||||
id: str
|
||||
type: MemoryType
|
||||
content: Any
|
||||
@@ -35,7 +37,7 @@ class MemoryEntry:
|
||||
last_accessed: Optional[float] = None
|
||||
agent_name: Optional[str] = None
|
||||
importance: float = 1.0
|
||||
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
@@ -48,9 +50,9 @@ class MemoryEntry:
|
||||
"access_count": self.access_count,
|
||||
"last_accessed": self.last_accessed,
|
||||
"agent_name": self.agent_name,
|
||||
"importance": self.importance
|
||||
"importance": self.importance,
|
||||
}
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> MemoryEntry:
|
||||
"""Create from dictionary."""
|
||||
@@ -64,5 +66,5 @@ class MemoryEntry:
|
||||
access_count=data.get("access_count", 0),
|
||||
last_accessed=data.get("last_accessed"),
|
||||
agent_name=data.get("agent_name"),
|
||||
importance=data.get("importance", 1.0)
|
||||
)
|
||||
importance=data.get("importance", 1.0),
|
||||
)
|
||||
|
||||
@@ -50,7 +50,8 @@ class ModelSettings:
|
||||
frequency_penalty=override.frequency_penalty or self.frequency_penalty,
|
||||
presence_penalty=override.presence_penalty or self.presence_penalty,
|
||||
tool_choice=override.tool_choice or self.tool_choice,
|
||||
parallel_tool_calls=override.parallel_tool_calls or self.parallel_tool_calls,
|
||||
parallel_tool_calls=override.parallel_tool_calls
|
||||
or self.parallel_tool_calls,
|
||||
truncation=override.truncation or self.truncation,
|
||||
max_tokens=override.max_tokens or self.max_tokens,
|
||||
)
|
||||
|
||||
@@ -64,10 +64,7 @@ class HanzoNodeProvider(ModelProvider):
|
||||
model = model_name or "gpt-oss:20b"
|
||||
print(f"Using Hanzo Node model: {model}")
|
||||
|
||||
return OpenAIChatCompletionsModel(
|
||||
model=model,
|
||||
openai_client=self.client
|
||||
)
|
||||
return OpenAIChatCompletionsModel(model=model, openai_client=self.client)
|
||||
|
||||
@property
|
||||
def is_local(self) -> bool:
|
||||
@@ -92,8 +89,7 @@ class HanzoNodeProvider(ModelProvider):
|
||||
|
||||
# Convenience function to create a Hanzo Node provider
|
||||
def create_hanzo_node_provider(
|
||||
port: int = 3690,
|
||||
api_key: Optional[str] = None
|
||||
port: int = 3690, api_key: Optional[str] = None
|
||||
) -> HanzoNodeProvider:
|
||||
"""
|
||||
Create a Hanzo Node provider with default settings.
|
||||
@@ -105,4 +101,4 @@ def create_hanzo_node_provider(
|
||||
Returns:
|
||||
Configured HanzoNodeProvider instance
|
||||
"""
|
||||
return HanzoNodeProvider(port=port, api_key=api_key)
|
||||
return HanzoNodeProvider(port=port, api_key=api_key)
|
||||
|
||||
@@ -53,14 +53,23 @@ from openai.types.responses import (
|
||||
ResponseTextDeltaEvent,
|
||||
ResponseUsage,
|
||||
)
|
||||
from openai.types.responses.response_input_param import FunctionCallOutput, ItemReference, Message
|
||||
from openai.types.responses.response_input_param import (
|
||||
FunctionCallOutput,
|
||||
ItemReference,
|
||||
Message,
|
||||
)
|
||||
from openai.types.responses.response_usage import OutputTokensDetails
|
||||
|
||||
from .. import _debug
|
||||
from ..agent_output import AgentOutputSchema
|
||||
from ..exceptions import AgentsException, UserError
|
||||
from ..handoffs import Handoff
|
||||
from ..items import ModelResponse, TResponseInputItem, TResponseOutputItem, TResponseStreamEvent
|
||||
from ..items import (
|
||||
ModelResponse,
|
||||
TResponseInputItem,
|
||||
TResponseOutputItem,
|
||||
TResponseStreamEvent,
|
||||
)
|
||||
from ..logger import logger
|
||||
from ..tool import FunctionTool, Tool
|
||||
from ..tracing import generation_span
|
||||
@@ -145,7 +154,9 @@ class OpenAIChatCompletionsModel(Model):
|
||||
else Usage()
|
||||
)
|
||||
if tracing.include_data():
|
||||
span_generation.span_data.output = [response.choices[0].message.model_dump()]
|
||||
span_generation.span_data.output = [
|
||||
response.choices[0].message.model_dump()
|
||||
]
|
||||
span_generation.span_data.usage = {
|
||||
"input_tokens": usage.input_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
@@ -323,12 +334,14 @@ class OpenAIChatCompletionsModel(Model):
|
||||
if delta.tool_calls:
|
||||
for tc_delta in delta.tool_calls:
|
||||
if tc_delta.index not in state.function_calls:
|
||||
state.function_calls[tc_delta.index] = ResponseFunctionToolCall(
|
||||
id=FAKE_RESPONSES_ID,
|
||||
arguments="",
|
||||
name="",
|
||||
type="function_call",
|
||||
call_id="",
|
||||
state.function_calls[tc_delta.index] = (
|
||||
ResponseFunctionToolCall(
|
||||
id=FAKE_RESPONSES_ID,
|
||||
arguments="",
|
||||
name="",
|
||||
type="function_call",
|
||||
call_id="",
|
||||
)
|
||||
)
|
||||
tc_function = tc_delta.function
|
||||
|
||||
@@ -338,7 +351,9 @@ class OpenAIChatCompletionsModel(Model):
|
||||
state.function_calls[tc_delta.index].name += (
|
||||
tc_function.name if tc_function else ""
|
||||
) or ""
|
||||
state.function_calls[tc_delta.index].call_id += tc_delta.id or ""
|
||||
state.function_calls[tc_delta.index].call_id += (
|
||||
tc_delta.id or ""
|
||||
)
|
||||
|
||||
function_call_starting_index = 0
|
||||
if state.text_content_index_and_output:
|
||||
@@ -409,7 +424,10 @@ class OpenAIChatCompletionsModel(Model):
|
||||
|
||||
# Finally, send the Response completed event
|
||||
outputs: list[ResponseOutputItem] = []
|
||||
if state.text_content_index_and_output or state.refusal_content_index_and_output:
|
||||
if (
|
||||
state.text_content_index_and_output
|
||||
or state.refusal_content_index_and_output
|
||||
):
|
||||
assistant_msg = ResponseOutputMessage(
|
||||
id=FAKE_RESPONSES_ID,
|
||||
content=[],
|
||||
@@ -420,7 +438,9 @@ class OpenAIChatCompletionsModel(Model):
|
||||
if state.text_content_index_and_output:
|
||||
assistant_msg.content.append(state.text_content_index_and_output[1])
|
||||
if state.refusal_content_index_and_output:
|
||||
assistant_msg.content.append(state.refusal_content_index_and_output[1])
|
||||
assistant_msg.content.append(
|
||||
state.refusal_content_index_and_output[1]
|
||||
)
|
||||
outputs.append(assistant_msg)
|
||||
|
||||
# send a ResponseOutputItemDone for the assistant message
|
||||
@@ -446,11 +466,15 @@ class OpenAIChatCompletionsModel(Model):
|
||||
reasoning_tokens=(
|
||||
usage.completion_tokens_details.reasoning_tokens
|
||||
if getattr(usage, "completion_tokens_details", None)
|
||||
and getattr(usage.completion_tokens_details, "reasoning_tokens", None)
|
||||
and getattr(
|
||||
usage.completion_tokens_details,
|
||||
"reasoning_tokens",
|
||||
None,
|
||||
)
|
||||
else 0
|
||||
)
|
||||
),
|
||||
input_tokens_details={"cached_tokens": 0}
|
||||
input_tokens_details={"cached_tokens": 0},
|
||||
)
|
||||
if usage
|
||||
else None
|
||||
@@ -524,12 +548,16 @@ class OpenAIChatCompletionsModel(Model):
|
||||
span.span_data.input = converted_messages
|
||||
|
||||
parallel_tool_calls = (
|
||||
True if model_settings.parallel_tool_calls and tools and len(tools) > 0 else NOT_GIVEN
|
||||
True
|
||||
if model_settings.parallel_tool_calls and tools and len(tools) > 0
|
||||
else NOT_GIVEN
|
||||
)
|
||||
tool_choice = _Converter.convert_tool_choice(model_settings.tool_choice)
|
||||
response_format = _Converter.convert_response_format(output_schema)
|
||||
|
||||
converted_tools = [ToolConverter.to_openai(tool) for tool in tools] if tools else []
|
||||
converted_tools = (
|
||||
[ToolConverter.to_openai(tool) for tool in tools] if tools else []
|
||||
)
|
||||
|
||||
for handoff in handoffs:
|
||||
converted_tools.append(ToolConverter.convert_handoff_tool(handoff))
|
||||
@@ -551,8 +579,12 @@ class OpenAIChatCompletionsModel(Model):
|
||||
tools=converted_tools or NOT_GIVEN,
|
||||
temperature=self._non_null_or_not_given(model_settings.temperature),
|
||||
top_p=self._non_null_or_not_given(model_settings.top_p),
|
||||
frequency_penalty=self._non_null_or_not_given(model_settings.frequency_penalty),
|
||||
presence_penalty=self._non_null_or_not_given(model_settings.presence_penalty),
|
||||
frequency_penalty=self._non_null_or_not_given(
|
||||
model_settings.frequency_penalty
|
||||
),
|
||||
presence_penalty=self._non_null_or_not_given(
|
||||
model_settings.presence_penalty
|
||||
),
|
||||
max_tokens=self._non_null_or_not_given(model_settings.max_tokens),
|
||||
tool_choice=tool_choice,
|
||||
response_format=response_format,
|
||||
@@ -571,9 +603,11 @@ class OpenAIChatCompletionsModel(Model):
|
||||
model=self.model,
|
||||
object="response",
|
||||
output=[],
|
||||
tool_choice=cast(Literal["auto", "required", "none"], tool_choice)
|
||||
if tool_choice != NOT_GIVEN
|
||||
else "auto",
|
||||
tool_choice=(
|
||||
cast(Literal["auto", "required", "none"], tool_choice)
|
||||
if tool_choice != NOT_GIVEN
|
||||
else "auto"
|
||||
),
|
||||
top_p=model_settings.top_p,
|
||||
temperature=model_settings.temperature,
|
||||
tools=[],
|
||||
@@ -625,7 +659,9 @@ class _Converter:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def message_to_output_items(cls, message: ChatCompletionMessage) -> list[TResponseOutputItem]:
|
||||
def message_to_output_items(
|
||||
cls, message: ChatCompletionMessage
|
||||
) -> list[TResponseOutputItem]:
|
||||
items: list[TResponseOutputItem] = []
|
||||
|
||||
message_item = ResponseOutputMessage(
|
||||
@@ -637,7 +673,9 @@ class _Converter:
|
||||
)
|
||||
if message.content:
|
||||
message_item.content.append(
|
||||
ResponseOutputText(text=message.content, type="output_text", annotations=[])
|
||||
ResponseOutputText(
|
||||
text=message.content, type="output_text", annotations=[]
|
||||
)
|
||||
)
|
||||
if message.refusal:
|
||||
message_item.content.append(
|
||||
@@ -699,13 +737,17 @@ class _Converter:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def maybe_file_search_call(cls, item: Any) -> ResponseFileSearchToolCallParam | None:
|
||||
def maybe_file_search_call(
|
||||
cls, item: Any
|
||||
) -> ResponseFileSearchToolCallParam | None:
|
||||
if isinstance(item, dict) and item.get("type") == "file_search_call":
|
||||
return cast(ResponseFileSearchToolCallParam, item)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def maybe_function_tool_call(cls, item: Any) -> ResponseFunctionToolCallParam | None:
|
||||
def maybe_function_tool_call(
|
||||
cls, item: Any
|
||||
) -> ResponseFunctionToolCallParam | None:
|
||||
if isinstance(item, dict) and item.get("type") == "function_call":
|
||||
return cast(ResponseFunctionToolCallParam, item)
|
||||
return None
|
||||
@@ -726,7 +768,9 @@ class _Converter:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def maybe_response_output_message(cls, item: Any) -> ResponseOutputMessageParam | None:
|
||||
def maybe_response_output_message(
|
||||
cls, item: Any
|
||||
) -> ResponseOutputMessageParam | None:
|
||||
# ResponseOutputMessage is only used for messages with role assistant
|
||||
if (
|
||||
isinstance(item, dict)
|
||||
@@ -768,7 +812,10 @@ class _Converter:
|
||||
)
|
||||
elif isinstance(c, dict) and c.get("type") == "input_image":
|
||||
casted_image_param = cast(ResponseInputImageParam, c)
|
||||
if "image_url" not in casted_image_param or not casted_image_param["image_url"]:
|
||||
if (
|
||||
"image_url" not in casted_image_param
|
||||
or not casted_image_param["image_url"]
|
||||
):
|
||||
raise UserError(
|
||||
f"Only image URLs are supported for input_image {casted_image_param}"
|
||||
)
|
||||
@@ -782,7 +829,9 @@ class _Converter:
|
||||
)
|
||||
)
|
||||
elif isinstance(c, dict) and c.get("type") == "input_file":
|
||||
raise UserError(f"File uploads are not supported for chat completions {c}")
|
||||
raise UserError(
|
||||
f"File uploads are not supported for chat completions {c}"
|
||||
)
|
||||
else:
|
||||
raise UserError(f"Unknonw content: {c}")
|
||||
return out
|
||||
@@ -828,7 +877,9 @@ class _Converter:
|
||||
def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:
|
||||
nonlocal current_assistant_msg
|
||||
if current_assistant_msg is None:
|
||||
current_assistant_msg = ChatCompletionAssistantMessageParam(role="assistant")
|
||||
current_assistant_msg = ChatCompletionAssistantMessageParam(
|
||||
role="assistant"
|
||||
)
|
||||
current_assistant_msg["tool_calls"] = []
|
||||
return current_assistant_msg
|
||||
|
||||
@@ -914,7 +965,9 @@ class _Converter:
|
||||
f"Only audio IDs are supported for chat completions, but got: {c}"
|
||||
)
|
||||
else:
|
||||
raise UserError(f"Unknown content type in ResponseOutputMessage: {c}")
|
||||
raise UserError(
|
||||
f"Unknown content type in ResponseOutputMessage: {c}"
|
||||
)
|
||||
|
||||
if text_segments:
|
||||
combined = "\n".join(text_segments)
|
||||
|
||||
@@ -35,9 +35,9 @@ class OpenAIProvider(ModelProvider):
|
||||
use_responses: bool | None = None,
|
||||
) -> None:
|
||||
if openai_client is not None:
|
||||
assert api_key is None and base_url is None, (
|
||||
"Don't provide api_key or base_url if you provide openai_client"
|
||||
)
|
||||
assert (
|
||||
api_key is None and base_url is None
|
||||
), "Don't provide api_key or base_url if you provide openai_client"
|
||||
self._client: AsyncOpenAI | None = openai_client
|
||||
else:
|
||||
self._client = None
|
||||
@@ -59,6 +59,7 @@ class OpenAIProvider(ModelProvider):
|
||||
# Only allow a dummy key if explicitly enabled via environment to support tests
|
||||
if api_key is None:
|
||||
import os
|
||||
|
||||
if os.getenv("ALLOW_DUMMY_OPENAI_KEY") in {"1", "true", "True"}:
|
||||
api_key = "sk-dummy"
|
||||
self._client = _openai_shared.get_default_openai_client() or AsyncOpenAI(
|
||||
@@ -77,13 +78,16 @@ class OpenAIProvider(ModelProvider):
|
||||
# Try to construct a real client; if unavailable (e.g., no API key) fall back to a
|
||||
# lightweight stub so tests that only check isinstance can proceed without network creds.
|
||||
import os
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
except Exception as e:
|
||||
if os.getenv("ALLOW_DUMMY_OPENAI_KEY") not in {"1", "true", "True"}:
|
||||
raise
|
||||
|
||||
class _ClientStub:
|
||||
base_url = ""
|
||||
|
||||
client = _ClientStub() # type: ignore
|
||||
|
||||
return (
|
||||
|
||||
@@ -109,7 +109,11 @@ class OpenAIResponsesModel(Model):
|
||||
SpanError(
|
||||
message="Error getting response",
|
||||
data={
|
||||
"error": str(e) if tracing.include_data() else e.__class__.__name__,
|
||||
"error": (
|
||||
str(e)
|
||||
if tracing.include_data()
|
||||
else e.__class__.__name__
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -155,7 +159,9 @@ class OpenAIResponsesModel(Model):
|
||||
# Ensure sequence_number is present for compatibility with newer SDKs
|
||||
if getattr(chunk, "sequence_number", None) is None:
|
||||
try:
|
||||
chunk = chunk.model_copy(update={"sequence_number": sequence_number})
|
||||
chunk = chunk.model_copy(
|
||||
update={"sequence_number": sequence_number}
|
||||
)
|
||||
except Exception:
|
||||
# If model_copy isn't available, fall back to yielding as-is
|
||||
pass
|
||||
@@ -173,7 +179,11 @@ class OpenAIResponsesModel(Model):
|
||||
SpanError(
|
||||
message="Error streaming response",
|
||||
data={
|
||||
"error": str(e) if tracing.include_data() else e.__class__.__name__,
|
||||
"error": (
|
||||
str(e)
|
||||
if tracing.include_data()
|
||||
else e.__class__.__name__
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -217,7 +227,9 @@ class OpenAIResponsesModel(Model):
|
||||
list_input = ItemHelpers.input_to_new_input_list(input)
|
||||
|
||||
parallel_tool_calls = (
|
||||
True if model_settings.parallel_tool_calls and tools and len(tools) > 0 else NOT_GIVEN
|
||||
True
|
||||
if model_settings.parallel_tool_calls and tools and len(tools) > 0
|
||||
else NOT_GIVEN
|
||||
)
|
||||
|
||||
tool_choice = Converter.convert_tool_choice(model_settings.tool_choice)
|
||||
@@ -323,7 +335,9 @@ class Converter:
|
||||
|
||||
computer_tools = [tool for tool in tools if isinstance(tool, ComputerTool)]
|
||||
if len(computer_tools) > 1:
|
||||
raise UserError(f"You can only provide one computer tool. Got {len(computer_tools)}")
|
||||
raise UserError(
|
||||
f"You can only provide one computer tool. Got {len(computer_tools)}"
|
||||
)
|
||||
|
||||
for tool in tools:
|
||||
converted_tool, include = cls._convert_tool(tool)
|
||||
@@ -369,7 +383,9 @@ class Converter:
|
||||
if tool.filters:
|
||||
converted_tool["filters"] = tool.filters
|
||||
|
||||
includes = "file_search_call.results" if tool.include_search_results else None
|
||||
includes = (
|
||||
"file_search_call.results" if tool.include_search_results else None
|
||||
)
|
||||
elif isinstance(tool, ComputerTool):
|
||||
converted_tool = {
|
||||
"type": "computer_use_preview",
|
||||
|
||||
@@ -29,4 +29,4 @@ __all__ = [
|
||||
"routing_strategy",
|
||||
"NetworkNode",
|
||||
"NodeStatus",
|
||||
]
|
||||
]
|
||||
|
||||
@@ -25,49 +25,49 @@ from .router import Router, RoutingDecision, SemanticRouter
|
||||
@dataclass
|
||||
class NetworkConfig:
|
||||
"""Configuration for an agent network."""
|
||||
|
||||
|
||||
name: str = "Agent Network"
|
||||
"""Name of the network for tracing and logging."""
|
||||
|
||||
|
||||
default_model: str = "gpt-3.5-turbo"
|
||||
"""Default model to use for agents in the network."""
|
||||
|
||||
|
||||
state_store: StateStore | None = None
|
||||
"""State store for sharing data between agents. Defaults to InMemoryStateStore."""
|
||||
|
||||
|
||||
enable_parallel_execution: bool = True
|
||||
"""Whether to enable parallel execution of independent agents."""
|
||||
|
||||
|
||||
max_parallel_agents: int = 5
|
||||
"""Maximum number of agents that can run in parallel."""
|
||||
|
||||
|
||||
enable_tracing: bool = True
|
||||
"""Whether to enable detailed network tracing."""
|
||||
|
||||
|
||||
retry_failed_nodes: bool = True
|
||||
"""Whether to retry failed agent nodes."""
|
||||
|
||||
|
||||
max_retries: int = 3
|
||||
"""Maximum number of retries for failed nodes."""
|
||||
|
||||
|
||||
class AgentNetwork:
|
||||
"""A network of agents that can collaborate and share state.
|
||||
|
||||
|
||||
This class provides a way to organize agents into a network where they can:
|
||||
- Share state through a common state store
|
||||
- Route tasks intelligently using routers
|
||||
- Execute in parallel when possible
|
||||
- Handle failures gracefully
|
||||
"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: NetworkConfig | None = None,
|
||||
router: Router | None = None,
|
||||
):
|
||||
"""Initialize an agent network.
|
||||
|
||||
|
||||
Args:
|
||||
config: Network configuration
|
||||
router: Router for intelligent task routing
|
||||
@@ -77,7 +77,7 @@ class AgentNetwork:
|
||||
self.nodes: Dict[str, NetworkNode] = {}
|
||||
self.state_store = self.config.state_store or InMemoryStateStore()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
|
||||
def add_agent(
|
||||
self,
|
||||
agent: Agent[TContext],
|
||||
@@ -87,45 +87,47 @@ class AgentNetwork:
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> NetworkNode:
|
||||
"""Add an agent to the network.
|
||||
|
||||
|
||||
Args:
|
||||
agent: The agent to add
|
||||
capabilities: List of capabilities this agent provides
|
||||
dependencies: List of agent names this agent depends on
|
||||
metadata: Additional metadata for routing decisions
|
||||
|
||||
|
||||
Returns:
|
||||
The network node wrapping the agent
|
||||
"""
|
||||
if agent.name in self.nodes:
|
||||
raise AgentsException(f"Agent '{agent.name}' already exists in network")
|
||||
|
||||
|
||||
node = NetworkNode(
|
||||
agent=agent,
|
||||
capabilities=capabilities or [],
|
||||
dependencies=dependencies or [],
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
|
||||
self.nodes[agent.name] = node
|
||||
|
||||
|
||||
# Update router with new agent information
|
||||
self.router.update_agent_info(agent.name, node.capabilities, node.metadata)
|
||||
|
||||
logger.debug(f"Added agent '{agent.name}' to network with capabilities: {node.capabilities}")
|
||||
|
||||
|
||||
logger.debug(
|
||||
f"Added agent '{agent.name}' to network with capabilities: {node.capabilities}"
|
||||
)
|
||||
|
||||
return node
|
||||
|
||||
|
||||
def remove_agent(self, agent_name: str) -> None:
|
||||
"""Remove an agent from the network."""
|
||||
if agent_name not in self.nodes:
|
||||
raise AgentsException(f"Agent '{agent_name}' not found in network")
|
||||
|
||||
|
||||
del self.nodes[agent_name]
|
||||
self.router.remove_agent_info(agent_name)
|
||||
|
||||
|
||||
logger.debug(f"Removed agent '{agent_name}' from network")
|
||||
|
||||
|
||||
async def run(
|
||||
self,
|
||||
input: str | list[TResponseInputItem],
|
||||
@@ -137,7 +139,7 @@ class AgentNetwork:
|
||||
run_config: RunConfig | None = None,
|
||||
) -> RunResult:
|
||||
"""Run the network with the given input.
|
||||
|
||||
|
||||
Args:
|
||||
input: Initial input to the network
|
||||
starting_agent: Name of the agent to start with (if None, router decides)
|
||||
@@ -145,7 +147,7 @@ class AgentNetwork:
|
||||
max_turns: Maximum number of agent turns
|
||||
hooks: Lifecycle hooks
|
||||
run_config: Run configuration
|
||||
|
||||
|
||||
Returns:
|
||||
Result of the network execution
|
||||
"""
|
||||
@@ -154,18 +156,20 @@ class AgentNetwork:
|
||||
for node in self.nodes.values():
|
||||
node.status = NodeStatus.PENDING
|
||||
node.error = None
|
||||
|
||||
|
||||
# Create network context wrapper
|
||||
network_context = NetworkContextWrapper(
|
||||
context=context,
|
||||
state_store=self.state_store,
|
||||
network=self,
|
||||
)
|
||||
|
||||
|
||||
# Determine starting agent
|
||||
if starting_agent:
|
||||
if starting_agent not in self.nodes:
|
||||
raise AgentsException(f"Starting agent '{starting_agent}' not found in network")
|
||||
raise AgentsException(
|
||||
f"Starting agent '{starting_agent}' not found in network"
|
||||
)
|
||||
agent = self.nodes[starting_agent].agent
|
||||
else:
|
||||
# Use router to determine starting agent
|
||||
@@ -174,21 +178,23 @@ class AgentNetwork:
|
||||
available_agents=list(self.nodes.keys()),
|
||||
context=network_context,
|
||||
)
|
||||
|
||||
|
||||
if not decision.selected_agent:
|
||||
raise AgentsException("Router could not determine starting agent")
|
||||
|
||||
|
||||
agent = self.nodes[decision.selected_agent].agent
|
||||
logger.debug(f"Router selected starting agent: {decision.selected_agent} (confidence: {decision.confidence})")
|
||||
|
||||
logger.debug(
|
||||
f"Router selected starting agent: {decision.selected_agent} (confidence: {decision.confidence})"
|
||||
)
|
||||
|
||||
# Create network-aware handoffs
|
||||
network_handoffs = self._create_network_handoffs(agent.name)
|
||||
|
||||
|
||||
# Clone agent with network handoffs
|
||||
network_agent = agent.clone(
|
||||
handoffs=list(agent.handoffs) + network_handoffs,
|
||||
)
|
||||
|
||||
|
||||
# Run with network context
|
||||
with custom_span("network_execution", {"network": self.config.name}):
|
||||
result = await Runner.run(
|
||||
@@ -199,9 +205,9 @@ class AgentNetwork:
|
||||
hooks=hooks or NetworkHooks(self),
|
||||
run_config=run_config,
|
||||
)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def run_parallel(
|
||||
self,
|
||||
tasks: List[Dict[str, Any]],
|
||||
@@ -210,12 +216,12 @@ class AgentNetwork:
|
||||
run_config: RunConfig | None = None,
|
||||
) -> List[RunResult]:
|
||||
"""Run multiple tasks in parallel across the network.
|
||||
|
||||
|
||||
Args:
|
||||
tasks: List of tasks, each with 'input' and optional 'agent' keys
|
||||
context: Shared context
|
||||
run_config: Run configuration
|
||||
|
||||
|
||||
Returns:
|
||||
List of results for each task
|
||||
"""
|
||||
@@ -231,10 +237,10 @@ class AgentNetwork:
|
||||
)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
|
||||
# Create tasks for parallel execution
|
||||
semaphore = asyncio.Semaphore(self.config.max_parallel_agents)
|
||||
|
||||
|
||||
async def run_task(task: Dict[str, Any]) -> RunResult:
|
||||
async with semaphore:
|
||||
return await self.run(
|
||||
@@ -243,13 +249,13 @@ class AgentNetwork:
|
||||
context=context,
|
||||
run_config=run_config,
|
||||
)
|
||||
|
||||
|
||||
# Run all tasks in parallel
|
||||
results = await asyncio.gather(
|
||||
*[run_task(task) for task in tasks],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
|
||||
# Handle any exceptions
|
||||
final_results = []
|
||||
for i, result in enumerate(results):
|
||||
@@ -268,17 +274,17 @@ class AgentNetwork:
|
||||
raise result
|
||||
else:
|
||||
final_results.append(result)
|
||||
|
||||
|
||||
return final_results
|
||||
|
||||
|
||||
def _create_network_handoffs(self, current_agent: str) -> List[Handoff]:
|
||||
"""Create handoffs to other agents in the network."""
|
||||
handoffs = []
|
||||
|
||||
|
||||
for name, node in self.nodes.items():
|
||||
if name == current_agent:
|
||||
continue
|
||||
|
||||
|
||||
# Create a dynamic handoff with network routing
|
||||
async def make_handoff_filter(target_name: str):
|
||||
async def network_handoff_filter(
|
||||
@@ -291,7 +297,7 @@ class AgentNetwork:
|
||||
available_agents=[target_name],
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
|
||||
if decision.selected_agent == target_name:
|
||||
# Add routing metadata
|
||||
if messages and isinstance(messages[-1], dict):
|
||||
@@ -301,23 +307,23 @@ class AgentNetwork:
|
||||
"confidence": decision.confidence,
|
||||
"reason": decision.reason,
|
||||
}
|
||||
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
return network_handoff_filter
|
||||
|
||||
|
||||
handoff = Handoff(
|
||||
agent=node.agent,
|
||||
input_filter=make_handoff_filter(name),
|
||||
)
|
||||
handoffs.append(handoff)
|
||||
|
||||
|
||||
return handoffs
|
||||
|
||||
|
||||
class NetworkContextWrapper(RunContextWrapper):
|
||||
"""Context wrapper that provides access to network state."""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context: TContext | None,
|
||||
@@ -327,19 +333,23 @@ class NetworkContextWrapper(RunContextWrapper):
|
||||
super().__init__(context)
|
||||
self.state_store = state_store
|
||||
self.network = network
|
||||
|
||||
|
||||
async def get_state(self, key: str, namespace: str | None = None) -> Any:
|
||||
"""Get a value from the shared state store."""
|
||||
return await self.state_store.get(key, namespace)
|
||||
|
||||
async def set_state(self, key: str, value: Any, namespace: str | None = None) -> None:
|
||||
|
||||
async def set_state(
|
||||
self, key: str, value: Any, namespace: str | None = None
|
||||
) -> None:
|
||||
"""Set a value in the shared state store."""
|
||||
await self.state_store.set(key, value, namespace)
|
||||
|
||||
async def update_state(self, key: str, updater: Callable[[Any], Any], namespace: str | None = None) -> Any:
|
||||
|
||||
async def update_state(
|
||||
self, key: str, updater: Callable[[Any], Any], namespace: str | None = None
|
||||
) -> Any:
|
||||
"""Update a value in the shared state store."""
|
||||
return await self.state_store.update(key, updater, namespace)
|
||||
|
||||
|
||||
def get_network_info(self) -> Dict[str, Any]:
|
||||
"""Get information about the network."""
|
||||
return {
|
||||
@@ -352,25 +362,31 @@ class NetworkContextWrapper(RunContextWrapper):
|
||||
|
||||
class NetworkHooks(RunHooks):
|
||||
"""Hooks for network execution tracking."""
|
||||
|
||||
|
||||
def __init__(self, network: AgentNetwork):
|
||||
self.network = network
|
||||
|
||||
async def on_agent_start(self, context: RunContextWrapper[Any], agent: Agent[Any]) -> None:
|
||||
|
||||
async def on_agent_start(
|
||||
self, context: RunContextWrapper[Any], agent: Agent[Any]
|
||||
) -> None:
|
||||
"""Called when an agent starts execution."""
|
||||
if agent.name in self.network.nodes:
|
||||
node = self.network.nodes[agent.name]
|
||||
node.status = NodeStatus.RUNNING
|
||||
node.execution_count += 1
|
||||
|
||||
logger.debug(f"Network agent '{agent.name}' started (execution #{node.execution_count})")
|
||||
|
||||
async def on_agent_end(self, context: RunContextWrapper[Any], agent: Agent[Any]) -> None:
|
||||
|
||||
logger.debug(
|
||||
f"Network agent '{agent.name}' started (execution #{node.execution_count})"
|
||||
)
|
||||
|
||||
async def on_agent_end(
|
||||
self, context: RunContextWrapper[Any], agent: Agent[Any]
|
||||
) -> None:
|
||||
"""Called when an agent ends execution."""
|
||||
if agent.name in self.network.nodes:
|
||||
node = self.network.nodes[agent.name]
|
||||
node.status = NodeStatus.COMPLETED
|
||||
|
||||
|
||||
logger.debug(f"Network agent '{agent.name}' completed")
|
||||
|
||||
|
||||
@@ -383,7 +399,7 @@ def create_network(
|
||||
enable_parallel_execution: bool = True,
|
||||
) -> AgentNetwork:
|
||||
"""Create a new agent network.
|
||||
|
||||
|
||||
Args:
|
||||
agents: List of agents to include in the network
|
||||
router: Router to use for agent selection
|
||||
@@ -391,29 +407,31 @@ def create_network(
|
||||
default_model: Default model to use
|
||||
name: Name of the network
|
||||
enable_parallel_execution: Whether to enable parallel execution
|
||||
|
||||
|
||||
Returns:
|
||||
AgentNetwork instance
|
||||
"""
|
||||
if router is None:
|
||||
from .router import SemanticRouter
|
||||
|
||||
router = SemanticRouter()
|
||||
|
||||
|
||||
if state_store is None:
|
||||
from ..state import InMemoryStateStore
|
||||
|
||||
state_store = InMemoryStateStore()
|
||||
|
||||
|
||||
config = NetworkConfig(
|
||||
name=name,
|
||||
default_model=default_model,
|
||||
state_store=state_store,
|
||||
enable_parallel_execution=enable_parallel_execution,
|
||||
)
|
||||
|
||||
|
||||
network = AgentNetwork(config=config, router=router)
|
||||
|
||||
|
||||
# Add all agents to the network
|
||||
for agent in agents:
|
||||
network.add_agent(agent)
|
||||
|
||||
return network
|
||||
|
||||
return network
|
||||
|
||||
@@ -12,19 +12,19 @@ from ..run_context import TContext
|
||||
|
||||
class NodeStatus(Enum):
|
||||
"""Status of a node in the network."""
|
||||
|
||||
|
||||
PENDING = "pending"
|
||||
"""Node has not been executed yet."""
|
||||
|
||||
|
||||
RUNNING = "running"
|
||||
"""Node is currently executing."""
|
||||
|
||||
|
||||
COMPLETED = "completed"
|
||||
"""Node has completed successfully."""
|
||||
|
||||
|
||||
FAILED = "failed"
|
||||
"""Node execution failed."""
|
||||
|
||||
|
||||
SKIPPED = "skipped"
|
||||
"""Node was skipped (e.g., due to dependencies)."""
|
||||
|
||||
@@ -32,38 +32,38 @@ class NodeStatus(Enum):
|
||||
@dataclass
|
||||
class NetworkNode:
|
||||
"""A node in the agent network."""
|
||||
|
||||
|
||||
agent: Agent[TContext]
|
||||
"""The agent associated with this node."""
|
||||
|
||||
|
||||
capabilities: List[str] = field(default_factory=list)
|
||||
"""List of capabilities this agent provides."""
|
||||
|
||||
|
||||
dependencies: List[str] = field(default_factory=list)
|
||||
"""List of agent names this node depends on."""
|
||||
|
||||
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
"""Additional metadata for routing and orchestration."""
|
||||
|
||||
|
||||
status: NodeStatus = NodeStatus.PENDING
|
||||
"""Current status of the node."""
|
||||
|
||||
|
||||
error: Exception | None = None
|
||||
"""Error if the node failed."""
|
||||
|
||||
|
||||
execution_count: int = 0
|
||||
"""Number of times this node has been executed."""
|
||||
|
||||
|
||||
last_execution_time: float | None = None
|
||||
"""Timestamp of last execution."""
|
||||
|
||||
|
||||
average_execution_time: float | None = None
|
||||
"""Average execution time in seconds."""
|
||||
|
||||
|
||||
def can_execute(self, completed_nodes: List[str]) -> bool:
|
||||
"""Check if this node can execute based on dependencies."""
|
||||
return all(dep in completed_nodes for dep in self.dependencies)
|
||||
|
||||
|
||||
def get_info(self) -> Dict[str, Any]:
|
||||
"""Get information about this node."""
|
||||
return {
|
||||
@@ -74,4 +74,4 @@ class NetworkNode:
|
||||
"execution_count": self.execution_count,
|
||||
"average_execution_time": self.average_execution_time,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,40 +16,42 @@ from ..tool import function_tool
|
||||
@dataclass
|
||||
class RoutingDecision:
|
||||
"""Result of a routing decision."""
|
||||
|
||||
|
||||
selected_agent: str | None
|
||||
"""The agent selected to handle the request."""
|
||||
|
||||
|
||||
confidence: float
|
||||
"""Confidence in the routing decision (0.0 to 1.0)."""
|
||||
|
||||
|
||||
reason: str | None = None
|
||||
"""Optional explanation for the routing decision."""
|
||||
|
||||
|
||||
fallback_agents: List[str] = None
|
||||
"""Ordered list of fallback agents if primary fails."""
|
||||
|
||||
|
||||
metadata: Dict[str, Any] = None
|
||||
"""Additional metadata about the routing decision."""
|
||||
|
||||
|
||||
class Router(ABC):
|
||||
"""Abstract base class for routing strategies."""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.agent_info: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def update_agent_info(self, agent_name: str, capabilities: List[str], metadata: Dict[str, Any]) -> None:
|
||||
|
||||
def update_agent_info(
|
||||
self, agent_name: str, capabilities: List[str], metadata: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Update information about an agent."""
|
||||
self.agent_info[agent_name] = {
|
||||
"capabilities": capabilities,
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
|
||||
def remove_agent_info(self, agent_name: str) -> None:
|
||||
"""Remove information about an agent."""
|
||||
self.agent_info.pop(agent_name, None)
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def route(
|
||||
self,
|
||||
@@ -58,12 +60,12 @@ class Router(ABC):
|
||||
context: RunContextWrapper[Any] | None = None,
|
||||
) -> RoutingDecision:
|
||||
"""Route the input to the most appropriate agent.
|
||||
|
||||
|
||||
Args:
|
||||
input: The input to route
|
||||
available_agents: List of available agent names
|
||||
context: Optional context for routing decisions
|
||||
|
||||
|
||||
Returns:
|
||||
Routing decision with selected agent
|
||||
"""
|
||||
@@ -72,11 +74,11 @@ class Router(ABC):
|
||||
|
||||
class SemanticRouter(Router):
|
||||
"""Router that uses semantic understanding to route requests."""
|
||||
|
||||
|
||||
def __init__(self, model: str | None = None):
|
||||
super().__init__()
|
||||
self.model = model
|
||||
|
||||
|
||||
async def route(
|
||||
self,
|
||||
input: str | list[TResponseInputItem],
|
||||
@@ -86,7 +88,7 @@ class SemanticRouter(Router):
|
||||
"""Route based on semantic understanding of the input."""
|
||||
if not available_agents:
|
||||
return RoutingDecision(selected_agent=None, confidence=0.0)
|
||||
|
||||
|
||||
# Extract text from input
|
||||
if isinstance(input, str):
|
||||
text = input
|
||||
@@ -97,7 +99,7 @@ class SemanticRouter(Router):
|
||||
if isinstance(item, dict) and item.get("role") == "user":
|
||||
text = item.get("content", "")
|
||||
break
|
||||
|
||||
|
||||
if not text:
|
||||
# Default to first available agent
|
||||
return RoutingDecision(
|
||||
@@ -105,46 +107,46 @@ class SemanticRouter(Router):
|
||||
confidence=0.5,
|
||||
reason="No user input found, using default agent",
|
||||
)
|
||||
|
||||
|
||||
# Score each agent based on capabilities
|
||||
scores: Dict[str, float] = {}
|
||||
|
||||
|
||||
for agent_name in available_agents:
|
||||
if agent_name not in self.agent_info:
|
||||
scores[agent_name] = 0.5 # Default score
|
||||
continue
|
||||
|
||||
|
||||
info = self.agent_info[agent_name]
|
||||
capabilities = info.get("capabilities", [])
|
||||
|
||||
|
||||
# Simple keyword matching for now
|
||||
score = 0.0
|
||||
matches = []
|
||||
|
||||
|
||||
for capability in capabilities:
|
||||
if capability.lower() in text.lower():
|
||||
score += 1.0
|
||||
matches.append(capability)
|
||||
|
||||
|
||||
# Normalize score
|
||||
if capabilities:
|
||||
score = score / len(capabilities)
|
||||
else:
|
||||
score = 0.5
|
||||
|
||||
|
||||
scores[agent_name] = score
|
||||
|
||||
|
||||
if matches:
|
||||
logger.debug(f"Agent '{agent_name}' matched capabilities: {matches}")
|
||||
|
||||
|
||||
# Select agent with highest score
|
||||
best_agent = max(scores.keys(), key=lambda k: scores[k])
|
||||
best_score = scores[best_agent]
|
||||
|
||||
|
||||
# Get fallback agents
|
||||
sorted_agents = sorted(scores.keys(), key=lambda k: scores[k], reverse=True)
|
||||
fallback_agents = sorted_agents[1:] if len(sorted_agents) > 1 else []
|
||||
|
||||
|
||||
return RoutingDecision(
|
||||
selected_agent=best_agent,
|
||||
confidence=best_score,
|
||||
@@ -156,11 +158,11 @@ class SemanticRouter(Router):
|
||||
|
||||
class RuleBasedRouter(Router):
|
||||
"""Router that uses predefined rules to route requests."""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.rules: List[RoutingRule] = []
|
||||
|
||||
|
||||
def add_rule(
|
||||
self,
|
||||
pattern: str | re.Pattern,
|
||||
@@ -169,7 +171,7 @@ class RuleBasedRouter(Router):
|
||||
condition: Callable[[str], bool] | None = None,
|
||||
) -> None:
|
||||
"""Add a routing rule.
|
||||
|
||||
|
||||
Args:
|
||||
pattern: Regex pattern to match
|
||||
agent: Agent to route to if pattern matches
|
||||
@@ -178,17 +180,19 @@ class RuleBasedRouter(Router):
|
||||
"""
|
||||
if isinstance(pattern, str):
|
||||
pattern = re.compile(pattern, re.IGNORECASE)
|
||||
|
||||
self.rules.append(RoutingRule(
|
||||
pattern=pattern,
|
||||
agent=agent,
|
||||
priority=priority,
|
||||
condition=condition,
|
||||
))
|
||||
|
||||
|
||||
self.rules.append(
|
||||
RoutingRule(
|
||||
pattern=pattern,
|
||||
agent=agent,
|
||||
priority=priority,
|
||||
condition=condition,
|
||||
)
|
||||
)
|
||||
|
||||
# Sort rules by priority
|
||||
self.rules.sort(key=lambda r: r.priority, reverse=True)
|
||||
|
||||
|
||||
async def route(
|
||||
self,
|
||||
input: str | list[TResponseInputItem],
|
||||
@@ -206,23 +210,23 @@ class RuleBasedRouter(Router):
|
||||
if isinstance(item, dict) and item.get("role") == "user":
|
||||
text = item.get("content", "")
|
||||
break
|
||||
|
||||
|
||||
# Check each rule
|
||||
for rule in self.rules:
|
||||
if rule.agent not in available_agents:
|
||||
continue
|
||||
|
||||
|
||||
if rule.pattern.search(text):
|
||||
# Check additional condition if provided
|
||||
if rule.condition and not rule.condition(text):
|
||||
continue
|
||||
|
||||
|
||||
return RoutingDecision(
|
||||
selected_agent=rule.agent,
|
||||
confidence=1.0,
|
||||
reason=f"Matched rule: {rule.pattern.pattern}",
|
||||
)
|
||||
|
||||
|
||||
# No rule matched
|
||||
if available_agents:
|
||||
return RoutingDecision(
|
||||
@@ -240,13 +244,13 @@ class RuleBasedRouter(Router):
|
||||
|
||||
class LoadBalancingRouter(Router):
|
||||
"""Router that distributes load across agents."""
|
||||
|
||||
|
||||
def __init__(self, strategy: str = "round_robin"):
|
||||
super().__init__()
|
||||
self.strategy = strategy
|
||||
self.agent_loads: Dict[str, int] = {}
|
||||
self.last_index = 0
|
||||
|
||||
|
||||
async def route(
|
||||
self,
|
||||
input: str | list[TResponseInputItem],
|
||||
@@ -256,33 +260,35 @@ class LoadBalancingRouter(Router):
|
||||
"""Route based on load balancing strategy."""
|
||||
if not available_agents:
|
||||
return RoutingDecision(selected_agent=None, confidence=0.0)
|
||||
|
||||
|
||||
if self.strategy == "round_robin":
|
||||
# Round-robin selection
|
||||
self.last_index = (self.last_index + 1) % len(available_agents)
|
||||
selected = available_agents[self.last_index]
|
||||
|
||||
|
||||
return RoutingDecision(
|
||||
selected_agent=selected,
|
||||
confidence=1.0,
|
||||
reason="Round-robin selection",
|
||||
)
|
||||
|
||||
|
||||
elif self.strategy == "least_loaded":
|
||||
# Select agent with least load
|
||||
loads = [(agent, self.agent_loads.get(agent, 0)) for agent in available_agents]
|
||||
loads = [
|
||||
(agent, self.agent_loads.get(agent, 0)) for agent in available_agents
|
||||
]
|
||||
loads.sort(key=lambda x: x[1])
|
||||
|
||||
|
||||
selected = loads[0][0]
|
||||
self.agent_loads[selected] = self.agent_loads.get(selected, 0) + 1
|
||||
|
||||
|
||||
return RoutingDecision(
|
||||
selected_agent=selected,
|
||||
confidence=1.0,
|
||||
reason=f"Least loaded agent (load: {loads[0][1]})",
|
||||
metadata={"loads": dict(loads)},
|
||||
)
|
||||
|
||||
|
||||
else:
|
||||
# Default to first agent
|
||||
return RoutingDecision(
|
||||
@@ -290,7 +296,7 @@ class LoadBalancingRouter(Router):
|
||||
confidence=0.5,
|
||||
reason="Unknown strategy, using default",
|
||||
)
|
||||
|
||||
|
||||
def reset_load(self, agent: str | None = None) -> None:
|
||||
"""Reset load counters."""
|
||||
if agent:
|
||||
@@ -302,6 +308,7 @@ class LoadBalancingRouter(Router):
|
||||
@dataclass
|
||||
class RoutingRule:
|
||||
"""A rule for rule-based routing."""
|
||||
|
||||
pattern: re.Pattern
|
||||
agent: str
|
||||
priority: int = 0
|
||||
@@ -310,14 +317,14 @@ class RoutingRule:
|
||||
|
||||
class RoutingStrategy:
|
||||
"""Composite routing strategy that can combine multiple routers."""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.routers: List[Tuple[Router, float]] = []
|
||||
|
||||
|
||||
def add_router(self, router: Router, weight: float = 1.0) -> None:
|
||||
"""Add a router with optional weight."""
|
||||
self.routers.append((router, weight))
|
||||
|
||||
|
||||
async def route(
|
||||
self,
|
||||
input: str | list[TResponseInputItem],
|
||||
@@ -327,30 +334,32 @@ class RoutingStrategy:
|
||||
"""Route using weighted combination of routers."""
|
||||
if not self.routers:
|
||||
return RoutingDecision(selected_agent=None, confidence=0.0)
|
||||
|
||||
|
||||
# Get decisions from all routers
|
||||
decisions: List[Tuple[RoutingDecision, float]] = []
|
||||
|
||||
|
||||
for router, weight in self.routers:
|
||||
decision = await router.route(input, available_agents, context)
|
||||
decisions.append((decision, weight))
|
||||
|
||||
|
||||
# Weighted voting
|
||||
agent_scores: Dict[str, float] = {}
|
||||
total_weight = sum(weight for _, weight in decisions)
|
||||
|
||||
|
||||
for decision, weight in decisions:
|
||||
if decision.selected_agent:
|
||||
score = decision.confidence * weight / total_weight
|
||||
agent_scores[decision.selected_agent] = agent_scores.get(decision.selected_agent, 0) + score
|
||||
|
||||
agent_scores[decision.selected_agent] = (
|
||||
agent_scores.get(decision.selected_agent, 0) + score
|
||||
)
|
||||
|
||||
if not agent_scores:
|
||||
return RoutingDecision(selected_agent=None, confidence=0.0)
|
||||
|
||||
|
||||
# Select agent with highest weighted score
|
||||
best_agent = max(agent_scores.keys(), key=lambda k: agent_scores[k])
|
||||
confidence = agent_scores[best_agent]
|
||||
|
||||
|
||||
return RoutingDecision(
|
||||
selected_agent=best_agent,
|
||||
confidence=confidence,
|
||||
@@ -365,21 +374,21 @@ def routing_strategy(
|
||||
load_balancing: str | None = None,
|
||||
) -> RoutingStrategy:
|
||||
"""Create a composite routing strategy.
|
||||
|
||||
|
||||
Args:
|
||||
semantic_weight: Weight for semantic routing (0 to disable)
|
||||
rules: List of rule definitions
|
||||
load_balancing: Load balancing strategy ("round_robin" or "least_loaded")
|
||||
|
||||
|
||||
Returns:
|
||||
Configured routing strategy
|
||||
"""
|
||||
strategy = RoutingStrategy()
|
||||
|
||||
|
||||
# Add semantic router
|
||||
if semantic_weight > 0:
|
||||
strategy.add_router(SemanticRouter(), semantic_weight)
|
||||
|
||||
|
||||
# Add rule-based router
|
||||
if rules:
|
||||
rule_router = RuleBasedRouter()
|
||||
@@ -391,9 +400,9 @@ def routing_strategy(
|
||||
condition=rule.get("condition"),
|
||||
)
|
||||
strategy.add_router(rule_router, 2.0) # Higher weight for explicit rules
|
||||
|
||||
|
||||
# Add load balancing router
|
||||
if load_balancing:
|
||||
strategy.add_router(LoadBalancingRouter(load_balancing), 0.5)
|
||||
|
||||
return strategy
|
||||
|
||||
return strategy
|
||||
|
||||
@@ -21,4 +21,4 @@ __all__ = [
|
||||
"UIStreamer",
|
||||
"StreamUpdate",
|
||||
"UpdateType",
|
||||
]
|
||||
]
|
||||
|
||||
@@ -22,7 +22,7 @@ from .workflow import Workflow, WorkflowStep, StepType
|
||||
@dataclass
|
||||
class StepResult:
|
||||
"""Result of a workflow step execution."""
|
||||
|
||||
|
||||
step_id: str
|
||||
success: bool
|
||||
output: Any = None
|
||||
@@ -30,7 +30,7 @@ class StepResult:
|
||||
start_time: float = field(default_factory=time.time)
|
||||
end_time: float | None = None
|
||||
retries: int = 0
|
||||
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
"""Get execution duration."""
|
||||
@@ -42,7 +42,7 @@ class StepResult:
|
||||
@dataclass
|
||||
class ExecutionResult:
|
||||
"""Result of a workflow execution."""
|
||||
|
||||
|
||||
workflow_id: str
|
||||
execution_id: str
|
||||
success: bool
|
||||
@@ -54,7 +54,7 @@ class ExecutionResult:
|
||||
start_time: float = field(default_factory=time.time)
|
||||
end_time: float | None = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
"""Get execution duration."""
|
||||
@@ -65,7 +65,7 @@ class ExecutionResult:
|
||||
|
||||
class WorkflowExecutor:
|
||||
"""Executes workflows with proper orchestration."""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow: Workflow,
|
||||
@@ -75,7 +75,7 @@ class WorkflowExecutor:
|
||||
max_retries: int = 3,
|
||||
):
|
||||
"""Initialize executor.
|
||||
|
||||
|
||||
Args:
|
||||
workflow: Workflow to execute
|
||||
network: Agent network
|
||||
@@ -92,22 +92,22 @@ class WorkflowExecutor:
|
||||
self.ui_streamer: UIStreamer | None = None
|
||||
self._step_outputs: Dict[str, Any] = {}
|
||||
self._completed_steps: set[str] = set()
|
||||
|
||||
|
||||
def set_ui_streamer(self, streamer: UIStreamer) -> None:
|
||||
"""Set UI streamer for updates."""
|
||||
self.ui_streamer = streamer
|
||||
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
input: Any,
|
||||
context: TContext | None = None,
|
||||
) -> ExecutionResult:
|
||||
"""Execute the workflow.
|
||||
|
||||
|
||||
Args:
|
||||
input: Initial input data
|
||||
context: Execution context
|
||||
|
||||
|
||||
Returns:
|
||||
Execution result
|
||||
"""
|
||||
@@ -121,7 +121,7 @@ class WorkflowExecutor:
|
||||
error=f"Workflow validation failed: {'; '.join(errors)}",
|
||||
total_steps=len(self.workflow.steps),
|
||||
)
|
||||
|
||||
|
||||
# Initialize result
|
||||
result = ExecutionResult(
|
||||
workflow_id=self.workflow.id,
|
||||
@@ -129,14 +129,14 @@ class WorkflowExecutor:
|
||||
success=True,
|
||||
total_steps=len(self.workflow.steps),
|
||||
)
|
||||
|
||||
|
||||
# Create execution context
|
||||
exec_context = ExecutionContext(
|
||||
input=input,
|
||||
context=context,
|
||||
executor=self,
|
||||
)
|
||||
|
||||
|
||||
# Send start update
|
||||
await self._send_update(
|
||||
UpdateType.WORKFLOW_START,
|
||||
@@ -146,41 +146,41 @@ class WorkflowExecutor:
|
||||
"total_steps": len(self.workflow.steps),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
# Get execution order
|
||||
execution_batches = self.workflow.get_execution_order()
|
||||
|
||||
|
||||
# Execute batches
|
||||
for batch in execution_batches:
|
||||
# Execute steps in parallel within batch
|
||||
batch_tasks = []
|
||||
|
||||
|
||||
for step_id in batch:
|
||||
if step_id in self._completed_steps:
|
||||
continue
|
||||
|
||||
|
||||
step = self.workflow.steps[step_id]
|
||||
task = self._execute_step(step, exec_context, result)
|
||||
batch_tasks.append(task)
|
||||
|
||||
|
||||
# Wait for batch to complete
|
||||
if batch_tasks:
|
||||
await asyncio.gather(*batch_tasks, return_exceptions=True)
|
||||
|
||||
|
||||
# Set final output
|
||||
if self.workflow.entry_point:
|
||||
result.output = self._step_outputs.get(self.workflow.entry_point)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Workflow execution failed: {e}")
|
||||
result.success = False
|
||||
result.error = str(e)
|
||||
|
||||
|
||||
finally:
|
||||
result.end_time = time.time()
|
||||
result.steps_completed = len(self._completed_steps)
|
||||
|
||||
|
||||
# Send completion update
|
||||
await self._send_update(
|
||||
UpdateType.WORKFLOW_COMPLETE,
|
||||
@@ -191,9 +191,9 @@ class WorkflowExecutor:
|
||||
"error": result.error,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def _execute_step(
|
||||
self,
|
||||
step: WorkflowStep,
|
||||
@@ -202,7 +202,7 @@ class WorkflowExecutor:
|
||||
) -> None:
|
||||
"""Execute a single step."""
|
||||
step_result = StepResult(step_id=step.id)
|
||||
|
||||
|
||||
# Send step start update
|
||||
await self._send_update(
|
||||
UpdateType.STEP_START,
|
||||
@@ -212,7 +212,7 @@ class WorkflowExecutor:
|
||||
"step_type": step.type.value,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
# Execute based on step type
|
||||
if step.type == StepType.AGENT:
|
||||
@@ -229,38 +229,38 @@ class WorkflowExecutor:
|
||||
output = await self._execute_wait_step(step, context)
|
||||
else:
|
||||
raise AgentsException(f"Unknown step type: {step.type}")
|
||||
|
||||
|
||||
# Store output
|
||||
self._step_outputs[step.id] = output
|
||||
self._completed_steps.add(step.id)
|
||||
|
||||
|
||||
step_result.success = True
|
||||
step_result.output = output
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Step {step.id} failed: {e}")
|
||||
step_result.success = False
|
||||
step_result.error = str(e)
|
||||
|
||||
|
||||
# Retry if configured
|
||||
if self.retry_failed and step_result.retries < self.max_retries:
|
||||
step_result.retries += 1
|
||||
logger.info(f"Retrying step {step.id} (attempt {step_result.retries})")
|
||||
|
||||
|
||||
# Recursive retry
|
||||
await asyncio.sleep(2 ** step_result.retries) # Exponential backoff
|
||||
await asyncio.sleep(2**step_result.retries) # Exponential backoff
|
||||
await self._execute_step(step, context, result)
|
||||
return
|
||||
|
||||
|
||||
# Handle error step
|
||||
if step.on_error and step.on_error in self.workflow.steps:
|
||||
error_step = self.workflow.steps[step.on_error]
|
||||
await self._execute_step(error_step, context, result)
|
||||
|
||||
|
||||
finally:
|
||||
step_result.end_time = time.time()
|
||||
result.step_results[step.id] = step_result
|
||||
|
||||
|
||||
# Send step complete update
|
||||
await self._send_update(
|
||||
UpdateType.STEP_COMPLETE,
|
||||
@@ -271,7 +271,7 @@ class WorkflowExecutor:
|
||||
"error": step_result.error,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _execute_agent_step(
|
||||
self,
|
||||
step: WorkflowStep,
|
||||
@@ -280,14 +280,14 @@ class WorkflowExecutor:
|
||||
"""Execute an agent step."""
|
||||
config = step.config
|
||||
agent_name = config["agent_name"]
|
||||
|
||||
|
||||
# Get input
|
||||
step_input = context.get_step_input(step.id)
|
||||
|
||||
|
||||
# Apply input transform if provided
|
||||
if transform := config.get("input_transform"):
|
||||
step_input = transform(step_input)
|
||||
|
||||
|
||||
# Run agent
|
||||
with custom_span("workflow_agent_step", {"agent": agent_name}):
|
||||
result = await self.network.run(
|
||||
@@ -295,16 +295,16 @@ class WorkflowExecutor:
|
||||
starting_agent=agent_name,
|
||||
context=context.context,
|
||||
)
|
||||
|
||||
|
||||
# Extract output
|
||||
output = result.final_output
|
||||
|
||||
|
||||
# Apply output transform if provided
|
||||
if transform := config.get("output_transform"):
|
||||
output = transform(output)
|
||||
|
||||
|
||||
return output
|
||||
|
||||
|
||||
async def _execute_parallel_step(
|
||||
self,
|
||||
step: WorkflowStep,
|
||||
@@ -312,7 +312,7 @@ class WorkflowExecutor:
|
||||
) -> List[Any]:
|
||||
"""Execute parallel steps."""
|
||||
step_ids = step.config["step_ids"]
|
||||
|
||||
|
||||
# Execute sub-steps in parallel
|
||||
tasks = []
|
||||
for step_id in step_ids:
|
||||
@@ -320,18 +320,18 @@ class WorkflowExecutor:
|
||||
sub_step = self.workflow.steps[step_id]
|
||||
task = self._execute_step(sub_step, context, None)
|
||||
tasks.append(task)
|
||||
|
||||
|
||||
# Wait for all to complete
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
# Collect outputs
|
||||
outputs = []
|
||||
for step_id in step_ids:
|
||||
if step_id in self._step_outputs:
|
||||
outputs.append(self._step_outputs[step_id])
|
||||
|
||||
|
||||
return outputs
|
||||
|
||||
|
||||
async def _execute_conditional_step(
|
||||
self,
|
||||
step: WorkflowStep,
|
||||
@@ -341,16 +341,16 @@ class WorkflowExecutor:
|
||||
condition = step.config["condition"]
|
||||
if_true = step.config["if_true"]
|
||||
if_false = step.config.get("if_false")
|
||||
|
||||
|
||||
# Evaluate condition
|
||||
step_input = context.get_step_input(step.id)
|
||||
|
||||
|
||||
if callable(condition):
|
||||
result = condition(step_input)
|
||||
else:
|
||||
# Simple expression evaluation
|
||||
result = eval(condition, {"input": step_input})
|
||||
|
||||
|
||||
# Execute appropriate branch
|
||||
if result and if_true in self.workflow.steps:
|
||||
branch_step = self.workflow.steps[if_true]
|
||||
@@ -360,9 +360,9 @@ class WorkflowExecutor:
|
||||
branch_step = self.workflow.steps[if_false]
|
||||
await self._execute_step(branch_step, context, None)
|
||||
return self._step_outputs.get(if_false)
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _execute_loop_step(
|
||||
self,
|
||||
step: WorkflowStep,
|
||||
@@ -372,36 +372,36 @@ class WorkflowExecutor:
|
||||
over = step.config["over"]
|
||||
body = step.config["body"]
|
||||
max_iterations = step.config.get("max_iterations")
|
||||
|
||||
|
||||
# Get items to loop over
|
||||
step_input = context.get_step_input(step.id)
|
||||
|
||||
|
||||
if callable(over):
|
||||
items = over(step_input)
|
||||
else:
|
||||
# Simple path evaluation
|
||||
items = eval(f"input.{over}", {"input": step_input})
|
||||
|
||||
|
||||
# Limit iterations
|
||||
if max_iterations:
|
||||
items = items[:max_iterations]
|
||||
|
||||
|
||||
# Execute body for each item
|
||||
outputs = []
|
||||
|
||||
|
||||
for i, item in enumerate(items):
|
||||
if body in self.workflow.steps:
|
||||
# Set loop context
|
||||
context.set_loop_item(item, i)
|
||||
|
||||
|
||||
body_step = self.workflow.steps[body]
|
||||
await self._execute_step(body_step, context, None)
|
||||
|
||||
|
||||
if body in self._step_outputs:
|
||||
outputs.append(self._step_outputs[body])
|
||||
|
||||
|
||||
return outputs
|
||||
|
||||
|
||||
async def _execute_transform_step(
|
||||
self,
|
||||
step: WorkflowStep,
|
||||
@@ -410,12 +410,12 @@ class WorkflowExecutor:
|
||||
"""Execute transform step."""
|
||||
transform = step.config["transform"]
|
||||
step_input = context.get_step_input(step.id)
|
||||
|
||||
|
||||
if callable(transform):
|
||||
return transform(step_input)
|
||||
else:
|
||||
raise AgentsException("Transform must be a callable")
|
||||
|
||||
|
||||
async def _execute_wait_step(
|
||||
self,
|
||||
step: WorkflowStep,
|
||||
@@ -425,7 +425,7 @@ class WorkflowExecutor:
|
||||
# Simple timeout wait for now
|
||||
timeout = step.config.get("timeout", 1.0)
|
||||
await asyncio.sleep(timeout)
|
||||
|
||||
|
||||
async def _send_update(self, type: UpdateType, data: Dict[str, Any]) -> None:
|
||||
"""Send update to UI streamer."""
|
||||
if self.ui_streamer:
|
||||
@@ -435,7 +435,7 @@ class WorkflowExecutor:
|
||||
data=data,
|
||||
)
|
||||
await self.ui_streamer.send(update)
|
||||
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get current execution status."""
|
||||
return {
|
||||
@@ -452,17 +452,17 @@ class WorkflowExecutor:
|
||||
@dataclass
|
||||
class ExecutionContext:
|
||||
"""Context for workflow execution."""
|
||||
|
||||
|
||||
input: Any
|
||||
context: TContext | None
|
||||
executor: WorkflowExecutor
|
||||
_loop_item: Any = None
|
||||
_loop_index: int = 0
|
||||
|
||||
|
||||
def get_step_input(self, step_id: str) -> Any:
|
||||
"""Get input for a step."""
|
||||
step = self.executor.workflow.steps[step_id]
|
||||
|
||||
|
||||
# If step has dependencies, use their outputs
|
||||
if step.depends_on:
|
||||
# Single dependency: use its output directly
|
||||
@@ -470,7 +470,7 @@ class ExecutionContext:
|
||||
dep_id = step.depends_on[0]
|
||||
if dep_id in self.executor._step_outputs:
|
||||
return self.executor._step_outputs[dep_id]
|
||||
|
||||
|
||||
# Multiple dependencies: collect as list
|
||||
else:
|
||||
outputs = []
|
||||
@@ -478,15 +478,15 @@ class ExecutionContext:
|
||||
if dep_id in self.executor._step_outputs:
|
||||
outputs.append(self.executor._step_outputs[dep_id])
|
||||
return outputs
|
||||
|
||||
|
||||
# Check if in loop context
|
||||
if self._loop_item is not None:
|
||||
return self._loop_item
|
||||
|
||||
|
||||
# Default to workflow input
|
||||
return self.input
|
||||
|
||||
|
||||
def set_loop_item(self, item: Any, index: int) -> None:
|
||||
"""Set current loop item."""
|
||||
self._loop_item = item
|
||||
self._loop_index = index
|
||||
self._loop_index = index
|
||||
|
||||
@@ -22,46 +22,46 @@ from .workflow import Workflow
|
||||
@dataclass
|
||||
class OrchestrationConfig:
|
||||
"""Configuration for orchestration."""
|
||||
|
||||
|
||||
name: str = "Agent Orchestrator"
|
||||
"""Name of the orchestrator."""
|
||||
|
||||
|
||||
enable_tracing: bool = True
|
||||
"""Whether to enable detailed tracing."""
|
||||
|
||||
|
||||
enable_ui_streaming: bool = True
|
||||
"""Whether to enable UI streaming updates."""
|
||||
|
||||
|
||||
max_concurrent_workflows: int = 10
|
||||
"""Maximum number of concurrent workflows."""
|
||||
|
||||
|
||||
state_store: StateStore | None = None
|
||||
"""State store for persistence."""
|
||||
|
||||
|
||||
retry_failed_steps: bool = True
|
||||
"""Whether to retry failed workflow steps."""
|
||||
|
||||
|
||||
max_retries: int = 3
|
||||
"""Maximum retries for failed steps."""
|
||||
|
||||
|
||||
class Orchestrator:
|
||||
"""High-level orchestrator for complex agent systems.
|
||||
|
||||
|
||||
This class provides:
|
||||
- Workflow management and execution
|
||||
- Network orchestration
|
||||
- UI streaming capabilities
|
||||
- Comprehensive tracing and debugging
|
||||
"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OrchestrationConfig | None = None,
|
||||
network: AgentNetwork | None = None,
|
||||
):
|
||||
"""Initialize orchestrator.
|
||||
|
||||
|
||||
Args:
|
||||
config: Orchestration configuration
|
||||
network: Agent network to use
|
||||
@@ -73,19 +73,19 @@ class Orchestrator:
|
||||
self.ui_streamer = UIStreamer() if self.config.enable_ui_streaming else None
|
||||
self.state_store = self.config.state_store or InMemoryStateStore()
|
||||
self._semaphore = asyncio.Semaphore(self.config.max_concurrent_workflows)
|
||||
|
||||
|
||||
def register_workflow(self, workflow: Workflow) -> None:
|
||||
"""Register a workflow with the orchestrator.
|
||||
|
||||
|
||||
Args:
|
||||
workflow: Workflow to register
|
||||
"""
|
||||
if workflow.id in self.workflows:
|
||||
raise AgentsException(f"Workflow '{workflow.id}' already registered")
|
||||
|
||||
|
||||
self.workflows[workflow.id] = workflow
|
||||
logger.debug(f"Registered workflow: {workflow.id}")
|
||||
|
||||
|
||||
def register_agent(
|
||||
self,
|
||||
agent: Agent[TContext],
|
||||
@@ -93,7 +93,7 @@ class Orchestrator:
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Register an agent with the network.
|
||||
|
||||
|
||||
Args:
|
||||
agent: Agent to register
|
||||
capabilities: Agent capabilities
|
||||
@@ -104,7 +104,7 @@ class Orchestrator:
|
||||
capabilities=capabilities,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
async def execute_workflow(
|
||||
self,
|
||||
workflow_id: str,
|
||||
@@ -113,21 +113,21 @@ class Orchestrator:
|
||||
stream_updates: bool | None = None,
|
||||
) -> ExecutionResult:
|
||||
"""Execute a workflow.
|
||||
|
||||
|
||||
Args:
|
||||
workflow_id: ID of workflow to execute
|
||||
input: Input data for the workflow
|
||||
context: Execution context
|
||||
stream_updates: Whether to stream UI updates
|
||||
|
||||
|
||||
Returns:
|
||||
Execution result
|
||||
"""
|
||||
if workflow_id not in self.workflows:
|
||||
raise AgentsException(f"Workflow '{workflow_id}' not found")
|
||||
|
||||
|
||||
workflow = self.workflows[workflow_id]
|
||||
|
||||
|
||||
# Use semaphore to limit concurrent workflows
|
||||
async with self._semaphore:
|
||||
# Create executor
|
||||
@@ -138,19 +138,21 @@ class Orchestrator:
|
||||
retry_failed=self.config.retry_failed_steps,
|
||||
max_retries=self.config.max_retries,
|
||||
)
|
||||
|
||||
|
||||
# Store executor
|
||||
execution_id = executor.execution_id
|
||||
self.executors[execution_id] = executor
|
||||
|
||||
|
||||
# Set up UI streaming if enabled
|
||||
if stream_updates or (stream_updates is None and self.config.enable_ui_streaming):
|
||||
if stream_updates or (
|
||||
stream_updates is None and self.config.enable_ui_streaming
|
||||
):
|
||||
if self.ui_streamer:
|
||||
executor.set_ui_streamer(self.ui_streamer)
|
||||
|
||||
|
||||
# Execute with tracing
|
||||
trace_name = f"workflow_execution:{workflow.name}"
|
||||
|
||||
|
||||
with trace(trace_name, metadata={"workflow_id": workflow_id}):
|
||||
try:
|
||||
result = await executor.execute(input, context)
|
||||
@@ -158,23 +160,23 @@ class Orchestrator:
|
||||
finally:
|
||||
# Clean up
|
||||
del self.executors[execution_id]
|
||||
|
||||
|
||||
async def execute_parallel_workflows(
|
||||
self,
|
||||
executions: List[Dict[str, Any]],
|
||||
context: TContext | None = None,
|
||||
) -> List[ExecutionResult]:
|
||||
"""Execute multiple workflows in parallel.
|
||||
|
||||
|
||||
Args:
|
||||
executions: List of execution specs with 'workflow_id' and 'input'
|
||||
context: Shared context
|
||||
|
||||
|
||||
Returns:
|
||||
List of execution results
|
||||
"""
|
||||
tasks = []
|
||||
|
||||
|
||||
for spec in executions:
|
||||
task = self.execute_workflow(
|
||||
workflow_id=spec["workflow_id"],
|
||||
@@ -183,28 +185,32 @@ class Orchestrator:
|
||||
stream_updates=spec.get("stream_updates", True),
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
# Handle exceptions
|
||||
final_results = []
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"Workflow {executions[i]['workflow_id']} failed: {result}")
|
||||
logger.error(
|
||||
f"Workflow {executions[i]['workflow_id']} failed: {result}"
|
||||
)
|
||||
# Create failed result
|
||||
final_results.append(ExecutionResult(
|
||||
workflow_id=executions[i]["workflow_id"],
|
||||
execution_id="failed",
|
||||
success=False,
|
||||
error=str(result),
|
||||
steps_completed=0,
|
||||
total_steps=0,
|
||||
))
|
||||
final_results.append(
|
||||
ExecutionResult(
|
||||
workflow_id=executions[i]["workflow_id"],
|
||||
execution_id="failed",
|
||||
success=False,
|
||||
error=str(result),
|
||||
steps_completed=0,
|
||||
total_steps=0,
|
||||
)
|
||||
)
|
||||
else:
|
||||
final_results.append(result)
|
||||
|
||||
|
||||
return final_results
|
||||
|
||||
|
||||
async def run_agent(
|
||||
self,
|
||||
agent_name: str,
|
||||
@@ -213,13 +219,13 @@ class Orchestrator:
|
||||
**kwargs,
|
||||
) -> RunResult:
|
||||
"""Run a single agent through the network.
|
||||
|
||||
|
||||
Args:
|
||||
agent_name: Name of agent to run
|
||||
input: Input for the agent
|
||||
context: Execution context
|
||||
**kwargs: Additional arguments for runner
|
||||
|
||||
|
||||
Returns:
|
||||
Agent run result
|
||||
"""
|
||||
@@ -229,7 +235,7 @@ class Orchestrator:
|
||||
context=context,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def create_workflow_from_agents(
|
||||
self,
|
||||
name: str,
|
||||
@@ -237,17 +243,17 @@ class Orchestrator:
|
||||
parallel: bool = False,
|
||||
) -> Workflow:
|
||||
"""Create a workflow from a list of agents.
|
||||
|
||||
|
||||
Args:
|
||||
name: Workflow name
|
||||
agents: List of agent names
|
||||
parallel: Whether to run agents in parallel
|
||||
|
||||
|
||||
Returns:
|
||||
Created workflow
|
||||
"""
|
||||
workflow = Workflow(name=name)
|
||||
|
||||
|
||||
if parallel:
|
||||
# Create parallel execution
|
||||
parallel_agents = []
|
||||
@@ -257,11 +263,11 @@ class Orchestrator:
|
||||
agent_name=agent_name,
|
||||
)
|
||||
parallel_agents.append(step.id)
|
||||
|
||||
|
||||
# Mark as parallel
|
||||
for step_id in parallel_agents[1:]:
|
||||
workflow.steps[step_id].depends_on = []
|
||||
|
||||
|
||||
else:
|
||||
# Create sequential execution
|
||||
for agent_name in agents:
|
||||
@@ -269,59 +275,61 @@ class Orchestrator:
|
||||
name=f"Run {agent_name}",
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
|
||||
return workflow
|
||||
|
||||
|
||||
def get_execution_status(self, execution_id: str) -> Dict[str, Any] | None:
|
||||
"""Get status of a running execution.
|
||||
|
||||
|
||||
Args:
|
||||
execution_id: Execution ID
|
||||
|
||||
|
||||
Returns:
|
||||
Status information or None if not found
|
||||
"""
|
||||
executor = self.executors.get(execution_id)
|
||||
|
||||
|
||||
if not executor:
|
||||
return None
|
||||
|
||||
|
||||
return executor.get_status()
|
||||
|
||||
|
||||
async def stream_updates(self, execution_id: str | None = None):
|
||||
"""Stream UI updates for executions.
|
||||
|
||||
|
||||
Args:
|
||||
execution_id: Specific execution to stream, or None for all
|
||||
|
||||
|
||||
Yields:
|
||||
Stream updates
|
||||
"""
|
||||
if not self.ui_streamer:
|
||||
return
|
||||
|
||||
|
||||
async for update in self.ui_streamer.stream(execution_id):
|
||||
yield update
|
||||
|
||||
|
||||
def visualize_workflow(self, workflow_id: str) -> str:
|
||||
"""Generate a visual representation of a workflow.
|
||||
|
||||
|
||||
Args:
|
||||
workflow_id: Workflow to visualize
|
||||
|
||||
|
||||
Returns:
|
||||
Mermaid diagram string
|
||||
"""
|
||||
if workflow_id not in self.workflows:
|
||||
raise AgentsException(f"Workflow '{workflow_id}' not found")
|
||||
|
||||
|
||||
workflow = self.workflows[workflow_id]
|
||||
return workflow.to_mermaid()
|
||||
|
||||
|
||||
async def get_state(self, key: str, namespace: str | None = None) -> Any:
|
||||
"""Get a value from the state store."""
|
||||
return await self.state_store.get(key, namespace)
|
||||
|
||||
async def set_state(self, key: str, value: Any, namespace: str | None = None) -> None:
|
||||
|
||||
async def set_state(
|
||||
self, key: str, value: Any, namespace: str | None = None
|
||||
) -> None:
|
||||
"""Set a value in the state store."""
|
||||
await self.state_store.set(key, value, namespace)
|
||||
await self.state_store.set(key, value, namespace)
|
||||
|
||||
@@ -13,34 +13,34 @@ from ..logger import logger
|
||||
|
||||
class UpdateType(Enum):
|
||||
"""Type of UI update."""
|
||||
|
||||
|
||||
WORKFLOW_START = "workflow_start"
|
||||
"""Workflow execution started."""
|
||||
|
||||
|
||||
WORKFLOW_COMPLETE = "workflow_complete"
|
||||
"""Workflow execution completed."""
|
||||
|
||||
|
||||
STEP_START = "step_start"
|
||||
"""Step execution started."""
|
||||
|
||||
|
||||
STEP_COMPLETE = "step_complete"
|
||||
"""Step execution completed."""
|
||||
|
||||
|
||||
STEP_PROGRESS = "step_progress"
|
||||
"""Step progress update."""
|
||||
|
||||
|
||||
AGENT_MESSAGE = "agent_message"
|
||||
"""Message from an agent."""
|
||||
|
||||
|
||||
TOOL_CALL = "tool_call"
|
||||
"""Tool was called."""
|
||||
|
||||
|
||||
ERROR = "error"
|
||||
"""An error occurred."""
|
||||
|
||||
|
||||
LOG = "log"
|
||||
"""Log message."""
|
||||
|
||||
|
||||
CUSTOM = "custom"
|
||||
"""Custom update type."""
|
||||
|
||||
@@ -48,29 +48,29 @@ class UpdateType(Enum):
|
||||
@dataclass
|
||||
class StreamUpdate:
|
||||
"""A single stream update."""
|
||||
|
||||
|
||||
execution_id: str
|
||||
"""ID of the execution this update belongs to."""
|
||||
|
||||
|
||||
type: UpdateType
|
||||
"""Type of update."""
|
||||
|
||||
|
||||
data: Dict[str, Any]
|
||||
"""Update data."""
|
||||
|
||||
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
"""When the update was created."""
|
||||
|
||||
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
"""Additional metadata."""
|
||||
|
||||
|
||||
class UIStreamer:
|
||||
"""Manages UI streaming for workflow execution."""
|
||||
|
||||
|
||||
def __init__(self, buffer_size: int = 1000):
|
||||
"""Initialize UI streamer.
|
||||
|
||||
|
||||
Args:
|
||||
buffer_size: Maximum updates to buffer
|
||||
"""
|
||||
@@ -79,10 +79,10 @@ class UIStreamer:
|
||||
self._subscribers: Dict[str, List[asyncio.Queue[StreamUpdate]]] = {}
|
||||
self._buffer: List[StreamUpdate] = []
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def send(self, update: StreamUpdate) -> None:
|
||||
"""Send an update to subscribers.
|
||||
|
||||
|
||||
Args:
|
||||
update: Update to send
|
||||
"""
|
||||
@@ -91,15 +91,17 @@ class UIStreamer:
|
||||
self._buffer.append(update)
|
||||
if len(self._buffer) > self.buffer_size:
|
||||
self._buffer.pop(0)
|
||||
|
||||
|
||||
# Send to execution-specific subscribers
|
||||
if update.execution_id in self._subscribers:
|
||||
for queue in self._subscribers[update.execution_id]:
|
||||
try:
|
||||
await queue.put(update)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning(f"UI stream queue full for execution {update.execution_id}")
|
||||
|
||||
logger.warning(
|
||||
f"UI stream queue full for execution {update.execution_id}"
|
||||
)
|
||||
|
||||
# Send to global subscribers
|
||||
if "*" in self._subscribers:
|
||||
for queue in self._subscribers["*"]:
|
||||
@@ -107,7 +109,7 @@ class UIStreamer:
|
||||
await queue.put(update)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("UI stream queue full for global subscriber")
|
||||
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
execution_id: str | None = None,
|
||||
@@ -115,37 +117,37 @@ class UIStreamer:
|
||||
queue_size: int = 100,
|
||||
) -> AsyncIterator[StreamUpdate]:
|
||||
"""Stream updates for an execution.
|
||||
|
||||
|
||||
Args:
|
||||
execution_id: Execution to stream, or None for all
|
||||
include_history: Whether to include buffered updates
|
||||
queue_size: Size of subscriber queue
|
||||
|
||||
|
||||
Yields:
|
||||
Stream updates
|
||||
"""
|
||||
# Create subscriber queue
|
||||
queue: asyncio.Queue[StreamUpdate] = asyncio.Queue(maxsize=queue_size)
|
||||
|
||||
|
||||
# Subscribe
|
||||
sub_key = execution_id or "*"
|
||||
async with self._lock:
|
||||
if sub_key not in self._subscribers:
|
||||
self._subscribers[sub_key] = []
|
||||
self._subscribers[sub_key].append(queue)
|
||||
|
||||
|
||||
# Send history if requested
|
||||
if include_history:
|
||||
for update in self._buffer:
|
||||
if execution_id is None or update.execution_id == execution_id:
|
||||
await queue.put(update)
|
||||
|
||||
|
||||
try:
|
||||
# Stream updates
|
||||
while True:
|
||||
update = await queue.get()
|
||||
yield update
|
||||
|
||||
|
||||
finally:
|
||||
# Unsubscribe
|
||||
async with self._lock:
|
||||
@@ -153,7 +155,7 @@ class UIStreamer:
|
||||
self._subscribers[sub_key].remove(queue)
|
||||
if not self._subscribers[sub_key]:
|
||||
del self._subscribers[sub_key]
|
||||
|
||||
|
||||
async def send_workflow_start(
|
||||
self,
|
||||
execution_id: str,
|
||||
@@ -162,16 +164,18 @@ class UIStreamer:
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Send workflow start update."""
|
||||
await self.send(StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.WORKFLOW_START,
|
||||
data={
|
||||
"workflow_name": workflow_name,
|
||||
"total_steps": total_steps,
|
||||
**kwargs,
|
||||
},
|
||||
))
|
||||
|
||||
await self.send(
|
||||
StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.WORKFLOW_START,
|
||||
data={
|
||||
"workflow_name": workflow_name,
|
||||
"total_steps": total_steps,
|
||||
**kwargs,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def send_workflow_complete(
|
||||
self,
|
||||
execution_id: str,
|
||||
@@ -180,16 +184,18 @@ class UIStreamer:
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Send workflow completion update."""
|
||||
await self.send(StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.WORKFLOW_COMPLETE,
|
||||
data={
|
||||
"success": success,
|
||||
"duration": duration,
|
||||
**kwargs,
|
||||
},
|
||||
))
|
||||
|
||||
await self.send(
|
||||
StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.WORKFLOW_COMPLETE,
|
||||
data={
|
||||
"success": success,
|
||||
"duration": duration,
|
||||
**kwargs,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def send_step_start(
|
||||
self,
|
||||
execution_id: str,
|
||||
@@ -198,16 +204,18 @@ class UIStreamer:
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Send step start update."""
|
||||
await self.send(StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.STEP_START,
|
||||
data={
|
||||
"step_id": step_id,
|
||||
"step_name": step_name,
|
||||
**kwargs,
|
||||
},
|
||||
))
|
||||
|
||||
await self.send(
|
||||
StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.STEP_START,
|
||||
data={
|
||||
"step_id": step_id,
|
||||
"step_name": step_name,
|
||||
**kwargs,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def send_step_complete(
|
||||
self,
|
||||
execution_id: str,
|
||||
@@ -217,17 +225,19 @@ class UIStreamer:
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Send step completion update."""
|
||||
await self.send(StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.STEP_COMPLETE,
|
||||
data={
|
||||
"step_id": step_id,
|
||||
"success": success,
|
||||
"duration": duration,
|
||||
**kwargs,
|
||||
},
|
||||
))
|
||||
|
||||
await self.send(
|
||||
StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.STEP_COMPLETE,
|
||||
data={
|
||||
"step_id": step_id,
|
||||
"success": success,
|
||||
"duration": duration,
|
||||
**kwargs,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def send_step_progress(
|
||||
self,
|
||||
execution_id: str,
|
||||
@@ -237,7 +247,7 @@ class UIStreamer:
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Send step progress update.
|
||||
|
||||
|
||||
Args:
|
||||
execution_id: Execution ID
|
||||
step_id: Step ID
|
||||
@@ -245,17 +255,19 @@ class UIStreamer:
|
||||
message: Optional progress message
|
||||
**kwargs: Additional data
|
||||
"""
|
||||
await self.send(StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.STEP_PROGRESS,
|
||||
data={
|
||||
"step_id": step_id,
|
||||
"progress": progress,
|
||||
"message": message,
|
||||
**kwargs,
|
||||
},
|
||||
))
|
||||
|
||||
await self.send(
|
||||
StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.STEP_PROGRESS,
|
||||
data={
|
||||
"step_id": step_id,
|
||||
"progress": progress,
|
||||
"message": message,
|
||||
**kwargs,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def send_agent_message(
|
||||
self,
|
||||
execution_id: str,
|
||||
@@ -265,17 +277,19 @@ class UIStreamer:
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Send agent message update."""
|
||||
await self.send(StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.AGENT_MESSAGE,
|
||||
data={
|
||||
"agent_name": agent_name,
|
||||
"role": role,
|
||||
"content": content,
|
||||
**kwargs,
|
||||
},
|
||||
))
|
||||
|
||||
await self.send(
|
||||
StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.AGENT_MESSAGE,
|
||||
data={
|
||||
"agent_name": agent_name,
|
||||
"role": role,
|
||||
"content": content,
|
||||
**kwargs,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def send_tool_call(
|
||||
self,
|
||||
execution_id: str,
|
||||
@@ -285,17 +299,19 @@ class UIStreamer:
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Send tool call update."""
|
||||
await self.send(StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.TOOL_CALL,
|
||||
data={
|
||||
"tool_name": tool_name,
|
||||
"arguments": arguments,
|
||||
"result": result,
|
||||
**kwargs,
|
||||
},
|
||||
))
|
||||
|
||||
await self.send(
|
||||
StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.TOOL_CALL,
|
||||
data={
|
||||
"tool_name": tool_name,
|
||||
"arguments": arguments,
|
||||
"result": result,
|
||||
**kwargs,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def send_error(
|
||||
self,
|
||||
execution_id: str,
|
||||
@@ -304,16 +320,18 @@ class UIStreamer:
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Send error update."""
|
||||
await self.send(StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.ERROR,
|
||||
data={
|
||||
"error": error,
|
||||
"step_id": step_id,
|
||||
**kwargs,
|
||||
},
|
||||
))
|
||||
|
||||
await self.send(
|
||||
StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.ERROR,
|
||||
data={
|
||||
"error": error,
|
||||
"step_id": step_id,
|
||||
**kwargs,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def send_log(
|
||||
self,
|
||||
execution_id: str,
|
||||
@@ -322,36 +340,38 @@ class UIStreamer:
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Send log message update."""
|
||||
await self.send(StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.LOG,
|
||||
data={
|
||||
"level": level,
|
||||
"message": message,
|
||||
**kwargs,
|
||||
},
|
||||
))
|
||||
|
||||
await self.send(
|
||||
StreamUpdate(
|
||||
execution_id=execution_id,
|
||||
type=UpdateType.LOG,
|
||||
data={
|
||||
"level": level,
|
||||
"message": message,
|
||||
**kwargs,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def get_buffer(self, execution_id: str | None = None) -> List[StreamUpdate]:
|
||||
"""Get buffered updates.
|
||||
|
||||
|
||||
Args:
|
||||
execution_id: Filter by execution ID
|
||||
|
||||
|
||||
Returns:
|
||||
List of buffered updates
|
||||
"""
|
||||
if execution_id:
|
||||
return [u for u in self._buffer if u.execution_id == execution_id]
|
||||
return self._buffer.copy()
|
||||
|
||||
|
||||
def clear_buffer(self, execution_id: str | None = None) -> None:
|
||||
"""Clear buffered updates.
|
||||
|
||||
|
||||
Args:
|
||||
execution_id: Clear only for specific execution
|
||||
"""
|
||||
if execution_id:
|
||||
self._buffer = [u for u in self._buffer if u.execution_id != execution_id]
|
||||
else:
|
||||
self._buffer.clear()
|
||||
self._buffer.clear()
|
||||
|
||||
@@ -12,22 +12,22 @@ from ..exceptions import AgentsException
|
||||
|
||||
class StepType(Enum):
|
||||
"""Type of workflow step."""
|
||||
|
||||
|
||||
AGENT = "agent"
|
||||
"""Run an agent."""
|
||||
|
||||
|
||||
PARALLEL = "parallel"
|
||||
"""Run multiple steps in parallel."""
|
||||
|
||||
|
||||
CONDITIONAL = "conditional"
|
||||
"""Conditional branching."""
|
||||
|
||||
|
||||
LOOP = "loop"
|
||||
"""Loop over items."""
|
||||
|
||||
|
||||
TRANSFORM = "transform"
|
||||
"""Data transformation."""
|
||||
|
||||
|
||||
WAIT = "wait"
|
||||
"""Wait for condition or timeout."""
|
||||
|
||||
@@ -35,38 +35,38 @@ class StepType(Enum):
|
||||
@dataclass
|
||||
class WorkflowStep:
|
||||
"""A single step in a workflow."""
|
||||
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
"""Unique ID for the step."""
|
||||
|
||||
|
||||
name: str = ""
|
||||
"""Human-readable name."""
|
||||
|
||||
|
||||
type: StepType = StepType.AGENT
|
||||
"""Type of step."""
|
||||
|
||||
|
||||
config: Dict[str, Any] = field(default_factory=dict)
|
||||
"""Step configuration."""
|
||||
|
||||
|
||||
depends_on: List[str] = field(default_factory=list)
|
||||
"""IDs of steps this depends on."""
|
||||
|
||||
|
||||
retry_config: Dict[str, Any] = field(default_factory=dict)
|
||||
"""Retry configuration for this step."""
|
||||
|
||||
|
||||
timeout: float | None = None
|
||||
"""Timeout in seconds."""
|
||||
|
||||
|
||||
on_error: str | None = None
|
||||
"""Step to run on error."""
|
||||
|
||||
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
"""Additional metadata."""
|
||||
|
||||
|
||||
class Workflow:
|
||||
"""A workflow definition."""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
@@ -74,7 +74,7 @@ class Workflow:
|
||||
version: str = "1.0.0",
|
||||
):
|
||||
"""Initialize workflow.
|
||||
|
||||
|
||||
Args:
|
||||
name: Workflow name
|
||||
description: Workflow description
|
||||
@@ -87,27 +87,27 @@ class Workflow:
|
||||
self.steps: Dict[str, WorkflowStep] = {}
|
||||
self.entry_point: str | None = None
|
||||
self.metadata: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def add_step(self, step: WorkflowStep) -> WorkflowStep:
|
||||
"""Add a step to the workflow.
|
||||
|
||||
|
||||
Args:
|
||||
step: Step to add
|
||||
|
||||
|
||||
Returns:
|
||||
The added step
|
||||
"""
|
||||
if step.id in self.steps:
|
||||
raise AgentsException(f"Step '{step.id}' already exists")
|
||||
|
||||
|
||||
self.steps[step.id] = step
|
||||
|
||||
|
||||
# Set as entry point if first step
|
||||
if self.entry_point is None:
|
||||
self.entry_point = step.id
|
||||
|
||||
|
||||
return step
|
||||
|
||||
|
||||
def add_agent_step(
|
||||
self,
|
||||
name: str,
|
||||
@@ -117,14 +117,14 @@ class Workflow:
|
||||
**kwargs,
|
||||
) -> WorkflowStep:
|
||||
"""Add an agent execution step.
|
||||
|
||||
|
||||
Args:
|
||||
name: Step name
|
||||
agent_name: Name of agent to run
|
||||
input_transform: Optional input transformation
|
||||
output_transform: Optional output transformation
|
||||
**kwargs: Additional step configuration
|
||||
|
||||
|
||||
Returns:
|
||||
The created step
|
||||
"""
|
||||
@@ -134,31 +134,31 @@ class Workflow:
|
||||
"output_transform": output_transform,
|
||||
}
|
||||
config.update(kwargs)
|
||||
|
||||
|
||||
step = WorkflowStep(
|
||||
name=name,
|
||||
type=StepType.AGENT,
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
# Auto-depend on previous step if exists
|
||||
if self.steps:
|
||||
last_step_id = list(self.steps.keys())[-1]
|
||||
step.depends_on = [last_step_id]
|
||||
|
||||
|
||||
return self.add_step(step)
|
||||
|
||||
|
||||
def add_parallel_step(
|
||||
self,
|
||||
name: str,
|
||||
steps: List[WorkflowStep],
|
||||
) -> WorkflowStep:
|
||||
"""Add a parallel execution step.
|
||||
|
||||
|
||||
Args:
|
||||
name: Step name
|
||||
steps: Steps to run in parallel
|
||||
|
||||
|
||||
Returns:
|
||||
The created parallel step
|
||||
"""
|
||||
@@ -167,16 +167,16 @@ class Workflow:
|
||||
for sub_step in steps:
|
||||
self.add_step(sub_step)
|
||||
step_ids.append(sub_step.id)
|
||||
|
||||
|
||||
# Create parallel container
|
||||
parallel_step = WorkflowStep(
|
||||
name=name,
|
||||
type=StepType.PARALLEL,
|
||||
config={"step_ids": step_ids},
|
||||
)
|
||||
|
||||
|
||||
return self.add_step(parallel_step)
|
||||
|
||||
|
||||
def add_conditional_step(
|
||||
self,
|
||||
name: str,
|
||||
@@ -185,13 +185,13 @@ class Workflow:
|
||||
if_false: str | None = None,
|
||||
) -> WorkflowStep:
|
||||
"""Add a conditional branching step.
|
||||
|
||||
|
||||
Args:
|
||||
name: Step name
|
||||
condition: Condition function or expression
|
||||
if_true: Step ID to run if true
|
||||
if_false: Step ID to run if false
|
||||
|
||||
|
||||
Returns:
|
||||
The created conditional step
|
||||
"""
|
||||
@@ -204,9 +204,9 @@ class Workflow:
|
||||
"if_false": if_false,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
return self.add_step(step)
|
||||
|
||||
|
||||
def add_loop_step(
|
||||
self,
|
||||
name: str,
|
||||
@@ -215,13 +215,13 @@ class Workflow:
|
||||
max_iterations: int | None = None,
|
||||
) -> WorkflowStep:
|
||||
"""Add a loop step.
|
||||
|
||||
|
||||
Args:
|
||||
name: Step name
|
||||
over: Data path or function to get items
|
||||
body: Step ID to run for each item
|
||||
max_iterations: Maximum iterations
|
||||
|
||||
|
||||
Returns:
|
||||
The created loop step
|
||||
"""
|
||||
@@ -234,20 +234,20 @@ class Workflow:
|
||||
"max_iterations": max_iterations,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
return self.add_step(step)
|
||||
|
||||
|
||||
def add_transform_step(
|
||||
self,
|
||||
name: str,
|
||||
transform: Callable[[Any], Any],
|
||||
) -> WorkflowStep:
|
||||
"""Add a data transformation step.
|
||||
|
||||
|
||||
Args:
|
||||
name: Step name
|
||||
transform: Transformation function
|
||||
|
||||
|
||||
Returns:
|
||||
The created transform step
|
||||
"""
|
||||
@@ -256,24 +256,26 @@ class Workflow:
|
||||
type=StepType.TRANSFORM,
|
||||
config={"transform": transform},
|
||||
)
|
||||
|
||||
|
||||
return self.add_step(step)
|
||||
|
||||
|
||||
def get_execution_order(self) -> List[List[str]]:
|
||||
"""Get the execution order respecting dependencies.
|
||||
|
||||
|
||||
Returns:
|
||||
List of step ID batches that can run in parallel
|
||||
"""
|
||||
if not self.steps:
|
||||
return []
|
||||
|
||||
|
||||
# Topological sort with batching
|
||||
visited = set()
|
||||
in_degree = {step_id: len(step.depends_on) for step_id, step in self.steps.items()}
|
||||
|
||||
in_degree = {
|
||||
step_id: len(step.depends_on) for step_id, step in self.steps.items()
|
||||
}
|
||||
|
||||
batches = []
|
||||
|
||||
|
||||
while len(visited) < len(self.steps):
|
||||
# Find all steps with no remaining dependencies
|
||||
batch = []
|
||||
@@ -281,42 +283,44 @@ class Workflow:
|
||||
if step_id not in visited and degree == 0:
|
||||
batch.append(step_id)
|
||||
visited.add(step_id)
|
||||
|
||||
|
||||
if not batch:
|
||||
# Circular dependency
|
||||
raise AgentsException("Circular dependency detected in workflow")
|
||||
|
||||
|
||||
batches.append(batch)
|
||||
|
||||
|
||||
# Update in-degrees
|
||||
for step_id in batch:
|
||||
# Find steps that depend on this one
|
||||
for other_id, other_step in self.steps.items():
|
||||
if step_id in other_step.depends_on:
|
||||
in_degree[other_id] -= 1
|
||||
|
||||
|
||||
return batches
|
||||
|
||||
|
||||
def validate(self) -> List[str]:
|
||||
"""Validate the workflow.
|
||||
|
||||
|
||||
Returns:
|
||||
List of validation errors (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
|
||||
# Check entry point
|
||||
if not self.entry_point:
|
||||
errors.append("No entry point defined")
|
||||
elif self.entry_point not in self.steps:
|
||||
errors.append(f"Entry point '{self.entry_point}' not found")
|
||||
|
||||
|
||||
# Check dependencies
|
||||
for step_id, step in self.steps.items():
|
||||
for dep_id in step.depends_on:
|
||||
if dep_id not in self.steps:
|
||||
errors.append(f"Step '{step_id}' depends on unknown step '{dep_id}'")
|
||||
|
||||
errors.append(
|
||||
f"Step '{step_id}' depends on unknown step '{dep_id}'"
|
||||
)
|
||||
|
||||
# Check step configurations
|
||||
for step_id, step in self.steps.items():
|
||||
if step.type == StepType.AGENT:
|
||||
@@ -327,15 +331,15 @@ class Workflow:
|
||||
errors.append(f"Conditional step '{step_id}' missing condition")
|
||||
if "if_true" not in step.config:
|
||||
errors.append(f"Conditional step '{step_id}' missing if_true")
|
||||
|
||||
|
||||
# Try to get execution order (checks for cycles)
|
||||
try:
|
||||
self.get_execution_order()
|
||||
except AgentsException as e:
|
||||
errors.append(str(e))
|
||||
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary representation."""
|
||||
return {
|
||||
@@ -360,11 +364,11 @@ class Workflow:
|
||||
},
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
def to_mermaid(self) -> str:
|
||||
"""Generate Mermaid diagram of the workflow."""
|
||||
lines = ["graph TD"]
|
||||
|
||||
|
||||
# Add nodes
|
||||
for step_id, step in self.steps.items():
|
||||
label = step.name or step_id[:8]
|
||||
@@ -377,81 +381,73 @@ class Workflow:
|
||||
StepType.WAIT: f"{step_id}[({label})]",
|
||||
}
|
||||
lines.append(f" {shape.get(step.type, f'{step_id}[{label}]')}")
|
||||
|
||||
|
||||
# Add edges
|
||||
for step_id, step in self.steps.items():
|
||||
for dep_id in step.depends_on:
|
||||
lines.append(f" {dep_id} --> {step_id}")
|
||||
|
||||
|
||||
# Add conditional branches
|
||||
for step_id, step in self.steps.items():
|
||||
if step.type == StepType.CONDITIONAL:
|
||||
if_true = step.config.get("if_true")
|
||||
if_false = step.config.get("if_false")
|
||||
|
||||
|
||||
if if_true:
|
||||
lines.append(f" {step_id} -->|true| {if_true}")
|
||||
if if_false:
|
||||
lines.append(f" {step_id} -->|false| {if_false}")
|
||||
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class Step:
|
||||
"""Helper class for creating workflow steps."""
|
||||
|
||||
|
||||
@staticmethod
|
||||
def agent(agent_name: str, prompt: str, **kwargs) -> WorkflowStep:
|
||||
"""Create an agent execution step.
|
||||
|
||||
|
||||
Args:
|
||||
agent_name: Name of the agent to run
|
||||
prompt: Input prompt for the agent
|
||||
**kwargs: Additional configuration
|
||||
|
||||
|
||||
Returns:
|
||||
WorkflowStep configured for agent execution
|
||||
"""
|
||||
config = {
|
||||
"agent_name": agent_name,
|
||||
"prompt": prompt,
|
||||
**kwargs
|
||||
}
|
||||
config = {"agent_name": agent_name, "prompt": prompt, **kwargs}
|
||||
return WorkflowStep(
|
||||
name=f"Run {agent_name}",
|
||||
type=StepType.AGENT,
|
||||
config=config
|
||||
name=f"Run {agent_name}", type=StepType.AGENT, config=config
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def parallel(steps: List[WorkflowStep]) -> WorkflowStep:
|
||||
"""Create a parallel execution step.
|
||||
|
||||
|
||||
Args:
|
||||
steps: Steps to run in parallel
|
||||
|
||||
|
||||
Returns:
|
||||
WorkflowStep configured for parallel execution
|
||||
"""
|
||||
return WorkflowStep(
|
||||
name="Parallel Steps",
|
||||
type=StepType.PARALLEL,
|
||||
config={"steps": steps}
|
||||
name="Parallel Steps", type=StepType.PARALLEL, config={"steps": steps}
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def conditional(
|
||||
condition: Callable[[Any], bool],
|
||||
if_true: WorkflowStep,
|
||||
if_false: WorkflowStep | None = None
|
||||
if_false: WorkflowStep | None = None,
|
||||
) -> WorkflowStep:
|
||||
"""Create a conditional execution step.
|
||||
|
||||
|
||||
Args:
|
||||
condition: Function to evaluate condition
|
||||
if_true: Step to run if condition is true
|
||||
if_false: Step to run if condition is false
|
||||
|
||||
|
||||
Returns:
|
||||
WorkflowStep configured for conditional execution
|
||||
"""
|
||||
@@ -461,64 +457,56 @@ class Step:
|
||||
config={
|
||||
"condition": condition,
|
||||
"if_true": if_true.id,
|
||||
"if_false": if_false.id if if_false else None
|
||||
}
|
||||
"if_false": if_false.id if if_false else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def loop(
|
||||
over: str | Callable[[Any], List[Any]],
|
||||
body: WorkflowStep,
|
||||
max_iterations: int | None = None
|
||||
max_iterations: int | None = None,
|
||||
) -> WorkflowStep:
|
||||
"""Create a loop execution step.
|
||||
|
||||
|
||||
Args:
|
||||
over: Data path or function to get items
|
||||
body: Step to run for each item
|
||||
max_iterations: Maximum iterations
|
||||
|
||||
|
||||
Returns:
|
||||
WorkflowStep configured for loop execution
|
||||
"""
|
||||
return WorkflowStep(
|
||||
name="Loop",
|
||||
type=StepType.LOOP,
|
||||
config={
|
||||
"over": over,
|
||||
"body": body.id,
|
||||
"max_iterations": max_iterations
|
||||
}
|
||||
config={"over": over, "body": body.id, "max_iterations": max_iterations},
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def transform(transform: Callable[[Any], Any]) -> WorkflowStep:
|
||||
"""Create a data transformation step.
|
||||
|
||||
|
||||
Args:
|
||||
transform: Transformation function
|
||||
|
||||
|
||||
Returns:
|
||||
WorkflowStep configured for transformation
|
||||
"""
|
||||
return WorkflowStep(
|
||||
name="Transform",
|
||||
type=StepType.TRANSFORM,
|
||||
config={"transform": transform}
|
||||
name="Transform", type=StepType.TRANSFORM, config={"transform": transform}
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def wait(duration: float) -> WorkflowStep:
|
||||
"""Create a wait step.
|
||||
|
||||
|
||||
Args:
|
||||
duration: Duration to wait in seconds
|
||||
|
||||
|
||||
Returns:
|
||||
WorkflowStep configured for waiting
|
||||
"""
|
||||
return WorkflowStep(
|
||||
name=f"Wait {duration}s",
|
||||
type=StepType.WAIT,
|
||||
config={"duration": duration}
|
||||
)
|
||||
name=f"Wait {duration}s", type=StepType.WAIT, config={"duration": duration}
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
"""Reflexion module for agent self-correction and rule management.
|
||||
|
||||
This module provides capabilities for agents to:
|
||||
@@ -15,8 +14,10 @@ from .memory import Memory, MemoryType
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class Rule(BaseModel):
|
||||
"""A behavioral rule for the agent."""
|
||||
|
||||
id: str
|
||||
content: str
|
||||
context: str = "general"
|
||||
@@ -24,6 +25,7 @@ class Rule(BaseModel):
|
||||
created_at: float
|
||||
updated_at: float
|
||||
|
||||
|
||||
class ReflexionEngine:
|
||||
"""Engine for managing agent reflection and rules."""
|
||||
|
||||
@@ -43,12 +45,13 @@ class ReflexionEngine:
|
||||
# functionality will use memory.remember(content=f"Rule: {content}", metadata={"type": "rule", "context": context})
|
||||
import time
|
||||
import uuid
|
||||
|
||||
rule = Rule(
|
||||
id=str(uuid.uuid4()),
|
||||
content=content,
|
||||
context=context,
|
||||
created_at=time.time(),
|
||||
updated_at=time.time()
|
||||
updated_at=time.time(),
|
||||
)
|
||||
if context not in self.rules:
|
||||
self.rules[context] = []
|
||||
@@ -60,12 +63,16 @@ class ReflexionEngine:
|
||||
# Simple heuristic for now: if failed, suggest checking the error.
|
||||
reflections = []
|
||||
if not success:
|
||||
reflections.append(f"Reflection: Task '{task}' failed. Consider input validation.")
|
||||
reflections.append(
|
||||
f"Reflection: Task '{task}' failed. Consider input validation."
|
||||
)
|
||||
reflections.append(f"Reflection: Analyze error message: {outcome}")
|
||||
else:
|
||||
reflections.append(f"Reflection: Task '{task}' succeeded. Reinforce this path.")
|
||||
|
||||
reflections.append(
|
||||
f"Reflection: Task '{task}' succeeded. Reinforce this path."
|
||||
)
|
||||
|
||||
# Todo: In production, call an LLM here to analyze the trace:
|
||||
# response = await self.llm.generate(f"Analyze this execution: {task} -> {outcome}")
|
||||
|
||||
|
||||
return reflections
|
||||
|
||||
@@ -74,7 +74,9 @@ class RunResultBase(abc.ABC):
|
||||
|
||||
def to_input_list(self) -> list[TResponseInputItem]:
|
||||
"""Creates a new input list, merging the original input with all the new items generated."""
|
||||
original_items: list[TResponseInputItem] = ItemHelpers.input_to_new_input_list(self.input)
|
||||
original_items: list[TResponseInputItem] = ItemHelpers.input_to_new_input_list(
|
||||
self.input
|
||||
)
|
||||
new_items = [item.to_input_item() for item in self.new_items]
|
||||
|
||||
return original_items + new_items
|
||||
@@ -183,13 +185,17 @@ class RunResultStreaming(RunResultBase):
|
||||
|
||||
def _check_errors(self):
|
||||
if self.current_turn > self.max_turns:
|
||||
self._stored_exception = MaxTurnsExceeded(f"Max turns ({self.max_turns}) exceeded")
|
||||
self._stored_exception = MaxTurnsExceeded(
|
||||
f"Max turns ({self.max_turns}) exceeded"
|
||||
)
|
||||
|
||||
# Fetch all the completed guardrail results from the queue and raise if needed
|
||||
while not self._input_guardrail_queue.empty():
|
||||
guardrail_result = self._input_guardrail_queue.get_nowait()
|
||||
if guardrail_result.output.tripwire_triggered:
|
||||
self._stored_exception = InputGuardrailTripwireTriggered(guardrail_result)
|
||||
self._stored_exception = InputGuardrailTripwireTriggered(
|
||||
guardrail_result
|
||||
)
|
||||
|
||||
# Check the tasks for any exceptions
|
||||
if self._run_impl_task and self._run_impl_task.done():
|
||||
|
||||
@@ -27,7 +27,12 @@ from .exceptions import (
|
||||
ModelBehaviorError,
|
||||
OutputGuardrailTripwireTriggered,
|
||||
)
|
||||
from .guardrail import InputGuardrail, InputGuardrailResult, OutputGuardrail, OutputGuardrailResult
|
||||
from .guardrail import (
|
||||
InputGuardrail,
|
||||
InputGuardrailResult,
|
||||
OutputGuardrail,
|
||||
OutputGuardrailResult,
|
||||
)
|
||||
from .handoffs import Handoff, HandoffInputFilter, handoff
|
||||
from .items import ItemHelpers, ModelResponse, RunItem, TResponseInputItem
|
||||
from .lifecycle import RunHooks
|
||||
@@ -176,7 +181,9 @@ class Runner:
|
||||
# Start an agent span if we don't have one. This span is ended if the current
|
||||
# agent changes, or if the agent loop ends.
|
||||
if current_span is None:
|
||||
handoff_names = [h.agent_name for h in cls._get_handoffs(current_agent)]
|
||||
handoff_names = [
|
||||
h.agent_name for h in cls._get_handoffs(current_agent)
|
||||
]
|
||||
tool_names = [t.name for t in current_agent.tools]
|
||||
if output_schema := cls._get_output_schema(current_agent):
|
||||
output_type_name = output_schema.output_type_name()
|
||||
@@ -243,7 +250,8 @@ class Runner:
|
||||
|
||||
if isinstance(turn_result.next_step, NextStepFinalOutput):
|
||||
output_guardrail_results = await cls._run_output_guardrails(
|
||||
current_agent.output_guardrails + (run_config.output_guardrails or []),
|
||||
current_agent.output_guardrails
|
||||
+ (run_config.output_guardrails or []),
|
||||
current_agent,
|
||||
turn_result.next_step.output,
|
||||
context_wrapper,
|
||||
@@ -258,7 +266,9 @@ class Runner:
|
||||
output_guardrail_results=output_guardrail_results,
|
||||
)
|
||||
elif isinstance(turn_result.next_step, NextStepHandoff):
|
||||
current_agent = cast(Agent[TContext], turn_result.next_step.new_agent)
|
||||
current_agent = cast(
|
||||
Agent[TContext], turn_result.next_step.new_agent
|
||||
)
|
||||
current_span.finish(reset_current=True)
|
||||
current_span = None
|
||||
should_run_agent_start_hooks = True
|
||||
@@ -482,7 +492,9 @@ class Runner:
|
||||
current_turn = 0
|
||||
should_run_agent_start_hooks = True
|
||||
|
||||
streamed_result._event_queue.put_nowait(AgentUpdatedStreamEvent(new_agent=current_agent))
|
||||
streamed_result._event_queue.put_nowait(
|
||||
AgentUpdatedStreamEvent(new_agent=current_agent)
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -492,7 +504,9 @@ class Runner:
|
||||
# Start an agent span if we don't have one. This span is ended if the current
|
||||
# agent changes, or if the agent loop ends.
|
||||
if current_span is None:
|
||||
handoff_names = [h.agent_name for h in cls._get_handoffs(current_agent)]
|
||||
handoff_names = [
|
||||
h.agent_name for h in cls._get_handoffs(current_agent)
|
||||
]
|
||||
tool_names = [t.name for t in current_agent.tools]
|
||||
if output_schema := cls._get_output_schema(current_agent):
|
||||
output_type_name = output_schema.output_type_name()
|
||||
@@ -526,8 +540,11 @@ class Runner:
|
||||
streamed_result._input_guardrails_task = asyncio.create_task(
|
||||
cls._run_input_guardrails_with_queue(
|
||||
starting_agent,
|
||||
starting_agent.input_guardrails + (run_config.input_guardrails or []),
|
||||
copy.deepcopy(ItemHelpers.input_to_new_input_list(starting_input)),
|
||||
starting_agent.input_guardrails
|
||||
+ (run_config.input_guardrails or []),
|
||||
copy.deepcopy(
|
||||
ItemHelpers.input_to_new_input_list(starting_input)
|
||||
),
|
||||
context_wrapper,
|
||||
streamed_result,
|
||||
current_span,
|
||||
@@ -570,12 +587,16 @@ class Runner:
|
||||
)
|
||||
|
||||
try:
|
||||
output_guardrail_results = await streamed_result._output_guardrails_task
|
||||
output_guardrail_results = (
|
||||
await streamed_result._output_guardrails_task
|
||||
)
|
||||
except Exception:
|
||||
# Exceptions will be checked in the stream_events loop
|
||||
output_guardrail_results = []
|
||||
|
||||
streamed_result.output_guardrail_results = output_guardrail_results
|
||||
streamed_result.output_guardrail_results = (
|
||||
output_guardrail_results
|
||||
)
|
||||
streamed_result.final_output = turn_result.next_step.output
|
||||
streamed_result.is_complete = True
|
||||
streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
|
||||
@@ -683,7 +704,9 @@ class Runner:
|
||||
run_config=run_config,
|
||||
)
|
||||
|
||||
RunImpl.stream_step_result_to_queue(single_step_result, streamed_result._event_queue)
|
||||
RunImpl.stream_step_result_to_queue(
|
||||
single_step_result, streamed_result._event_queue
|
||||
)
|
||||
return single_step_result
|
||||
|
||||
@classmethod
|
||||
@@ -714,7 +737,9 @@ class Runner:
|
||||
output_schema = cls._get_output_schema(agent)
|
||||
handoffs = cls._get_handoffs(agent)
|
||||
input = ItemHelpers.input_to_new_input_list(original_input)
|
||||
input.extend([generated_item.to_input_item() for generated_item in generated_items])
|
||||
input.extend(
|
||||
[generated_item.to_input_item() for generated_item in generated_items]
|
||||
)
|
||||
|
||||
new_response = await cls._get_new_response(
|
||||
agent,
|
||||
@@ -821,7 +846,9 @@ class Runner:
|
||||
|
||||
guardrail_tasks = [
|
||||
asyncio.create_task(
|
||||
RunImpl.run_single_output_guardrail(guardrail, agent, agent_output, context)
|
||||
RunImpl.run_single_output_guardrail(
|
||||
guardrail, agent, agent_output, context
|
||||
)
|
||||
)
|
||||
for guardrail in guardrails
|
||||
]
|
||||
|
||||
@@ -17,4 +17,4 @@ __all__ = [
|
||||
"StateSerializer",
|
||||
"JSONSerializer",
|
||||
"PickleSerializer",
|
||||
]
|
||||
]
|
||||
|
||||
@@ -10,53 +10,53 @@ if TYPE_CHECKING:
|
||||
|
||||
class StateNamespace:
|
||||
"""A namespaced view of a state store.
|
||||
|
||||
|
||||
This allows you to work with a subset of the state store
|
||||
without worrying about key collisions.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, store: StateStore, namespace: str):
|
||||
self.store = store
|
||||
self.namespace = namespace
|
||||
|
||||
|
||||
async def get(self, key: str) -> Any:
|
||||
"""Get a value from this namespace."""
|
||||
return await self.store.get(key, self.namespace)
|
||||
|
||||
|
||||
async def set(self, key: str, value: Any) -> None:
|
||||
"""Set a value in this namespace."""
|
||||
await self.store.set(key, value, self.namespace)
|
||||
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
"""Delete a value from this namespace."""
|
||||
await self.store.delete(key, self.namespace)
|
||||
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
"""Check if a key exists in this namespace."""
|
||||
return await self.store.exists(key, self.namespace)
|
||||
|
||||
|
||||
async def keys(self, pattern: str | None = None) -> List[str]:
|
||||
"""List keys in this namespace."""
|
||||
return await self.store.keys(pattern, self.namespace)
|
||||
|
||||
|
||||
async def update(self, key: str, updater: Callable[[Any], Any]) -> Any:
|
||||
"""Update a value atomically in this namespace."""
|
||||
return await self.store.update(key, updater, self.namespace)
|
||||
|
||||
|
||||
async def increment(self, key: str, amount: int = 1) -> int:
|
||||
"""Increment a numeric value in this namespace."""
|
||||
return await self.store.increment(key, amount, self.namespace)
|
||||
|
||||
|
||||
async def append(self, key: str, item: Any) -> List[Any]:
|
||||
"""Append to a list value in this namespace."""
|
||||
return await self.store.append(key, item, self.namespace)
|
||||
|
||||
|
||||
def sub_namespace(self, name: str) -> StateNamespace:
|
||||
"""Create a sub-namespace."""
|
||||
return StateNamespace(self.store, f"{self.namespace}:{name}")
|
||||
|
||||
|
||||
async def clear(self) -> None:
|
||||
"""Clear all keys in this namespace."""
|
||||
keys = await self.keys()
|
||||
for key in keys:
|
||||
await self.delete(key)
|
||||
await self.delete(key)
|
||||
|
||||
@@ -10,12 +10,12 @@ from typing import Any
|
||||
|
||||
class StateSerializer(ABC):
|
||||
"""Abstract base class for state serializers."""
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def serialize(self, obj: Any) -> bytes:
|
||||
"""Serialize an object to bytes."""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def deserialize(self, data: bytes) -> Any:
|
||||
"""Deserialize bytes to an object."""
|
||||
@@ -24,18 +24,18 @@ class StateSerializer(ABC):
|
||||
|
||||
class JSONSerializer(StateSerializer):
|
||||
"""JSON serializer for state storage.
|
||||
|
||||
|
||||
This is the default serializer. It's human-readable but
|
||||
limited to JSON-serializable types.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, encoding: str = "utf-8"):
|
||||
self.encoding = encoding
|
||||
|
||||
|
||||
def serialize(self, obj: Any) -> bytes:
|
||||
"""Serialize to JSON bytes."""
|
||||
return json.dumps(obj).encode(self.encoding)
|
||||
|
||||
|
||||
def deserialize(self, data: bytes) -> Any:
|
||||
"""Deserialize from JSON bytes."""
|
||||
return json.loads(data.decode(self.encoding))
|
||||
@@ -43,18 +43,18 @@ class JSONSerializer(StateSerializer):
|
||||
|
||||
class PickleSerializer(StateSerializer):
|
||||
"""Pickle serializer for state storage.
|
||||
|
||||
|
||||
This can handle any Python object but is not human-readable
|
||||
and has security implications (don't unpickle untrusted data).
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, protocol: int = pickle.HIGHEST_PROTOCOL):
|
||||
self.protocol = protocol
|
||||
|
||||
|
||||
def serialize(self, obj: Any) -> bytes:
|
||||
"""Serialize to pickle bytes."""
|
||||
return pickle.dumps(obj, protocol=self.protocol)
|
||||
|
||||
|
||||
def deserialize(self, data: bytes) -> Any:
|
||||
"""Deserialize from pickle bytes."""
|
||||
return pickle.loads(data)
|
||||
return pickle.loads(data)
|
||||
|
||||
@@ -14,54 +14,55 @@ from ..logger import logger
|
||||
from .namespace import StateNamespace
|
||||
from .serializer import StateSerializer, JSONSerializer
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class StateStore(ABC):
|
||||
"""Abstract base class for state stores."""
|
||||
|
||||
|
||||
def __init__(self, serializer: StateSerializer | None = None):
|
||||
self.serializer = serializer or JSONSerializer()
|
||||
self._locks: Dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _get_lock(self, key: str) -> asyncio.Lock:
|
||||
"""Get or create a lock for a key."""
|
||||
if key not in self._locks:
|
||||
self._locks[key] = asyncio.Lock()
|
||||
return self._locks[key]
|
||||
|
||||
|
||||
def _make_key(self, key: str, namespace: str | None = None) -> str:
|
||||
"""Create a namespaced key."""
|
||||
if namespace:
|
||||
return f"{namespace}:{key}"
|
||||
return key
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def get(self, key: str, namespace: str | None = None) -> Any:
|
||||
"""Get a value from the store."""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def set(self, key: str, value: Any, namespace: str | None = None) -> None:
|
||||
"""Set a value in the store."""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, key: str, namespace: str | None = None) -> None:
|
||||
"""Delete a value from the store."""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def exists(self, key: str, namespace: str | None = None) -> bool:
|
||||
"""Check if a key exists."""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def keys(self, pattern: str | None = None, namespace: str | None = None) -> List[str]:
|
||||
async def keys(
|
||||
self, pattern: str | None = None, namespace: str | None = None
|
||||
) -> List[str]:
|
||||
"""List keys matching a pattern."""
|
||||
pass
|
||||
|
||||
|
||||
async def update(
|
||||
self,
|
||||
key: str,
|
||||
@@ -69,12 +70,12 @@ class StateStore(ABC):
|
||||
namespace: str | None = None,
|
||||
) -> Any:
|
||||
"""Update a value atomically.
|
||||
|
||||
|
||||
Args:
|
||||
key: Key to update
|
||||
updater: Function that takes current value and returns new value
|
||||
namespace: Optional namespace
|
||||
|
||||
|
||||
Returns:
|
||||
The updated value
|
||||
"""
|
||||
@@ -84,15 +85,22 @@ class StateStore(ABC):
|
||||
updated = updater(current)
|
||||
await self.set(key, updated, namespace)
|
||||
return updated
|
||||
|
||||
async def increment(self, key: str, amount: int = 1, namespace: str | None = None) -> int:
|
||||
|
||||
async def increment(
|
||||
self, key: str, amount: int = 1, namespace: str | None = None
|
||||
) -> int:
|
||||
"""Increment a numeric value."""
|
||||
|
||||
def inc(val):
|
||||
return (val or 0) + amount
|
||||
|
||||
return await self.update(key, inc, namespace)
|
||||
|
||||
async def append(self, key: str, item: Any, namespace: str | None = None) -> List[Any]:
|
||||
|
||||
async def append(
|
||||
self, key: str, item: Any, namespace: str | None = None
|
||||
) -> List[Any]:
|
||||
"""Append to a list value."""
|
||||
|
||||
def app(val):
|
||||
if val is None:
|
||||
return [item]
|
||||
@@ -100,8 +108,9 @@ class StateStore(ABC):
|
||||
raise ValueError(f"Value at key '{key}' is not a list")
|
||||
val.append(item)
|
||||
return val
|
||||
|
||||
return await self.update(key, app, namespace)
|
||||
|
||||
|
||||
def namespace(self, name: str) -> StateNamespace:
|
||||
"""Create a namespace view of the store."""
|
||||
return StateNamespace(self, name)
|
||||
@@ -109,49 +118,51 @@ class StateStore(ABC):
|
||||
|
||||
class InMemoryStateStore(StateStore):
|
||||
"""In-memory state store for development and testing."""
|
||||
|
||||
|
||||
def __init__(self, serializer: StateSerializer | None = None):
|
||||
super().__init__(serializer)
|
||||
self._data: Dict[str, Any] = {}
|
||||
|
||||
|
||||
async def get(self, key: str, namespace: str | None = None) -> Any:
|
||||
"""Get a value from memory."""
|
||||
full_key = self._make_key(key, namespace)
|
||||
return self._data.get(full_key)
|
||||
|
||||
|
||||
async def set(self, key: str, value: Any, namespace: str | None = None) -> None:
|
||||
"""Set a value in memory."""
|
||||
full_key = self._make_key(key, namespace)
|
||||
self._data[full_key] = value
|
||||
logger.debug(f"Set state: {full_key} = {type(value).__name__}")
|
||||
|
||||
|
||||
async def delete(self, key: str, namespace: str | None = None) -> None:
|
||||
"""Delete a value from memory."""
|
||||
full_key = self._make_key(key, namespace)
|
||||
self._data.pop(full_key, None)
|
||||
|
||||
|
||||
async def exists(self, key: str, namespace: str | None = None) -> bool:
|
||||
"""Check if a key exists in memory."""
|
||||
full_key = self._make_key(key, namespace)
|
||||
return full_key in self._data
|
||||
|
||||
async def keys(self, pattern: str | None = None, namespace: str | None = None) -> List[str]:
|
||||
|
||||
async def keys(
|
||||
self, pattern: str | None = None, namespace: str | None = None
|
||||
) -> List[str]:
|
||||
"""List keys in memory."""
|
||||
prefix = f"{namespace}:" if namespace else ""
|
||||
keys = []
|
||||
|
||||
|
||||
for key in self._data:
|
||||
if namespace and not key.startswith(prefix):
|
||||
continue
|
||||
|
||||
|
||||
# Remove namespace prefix from result
|
||||
clean_key = key[len(prefix):] if namespace else key
|
||||
|
||||
clean_key = key[len(prefix) :] if namespace else key
|
||||
|
||||
if pattern is None or pattern in clean_key:
|
||||
keys.append(clean_key)
|
||||
|
||||
|
||||
return keys
|
||||
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all data (for testing)."""
|
||||
self._data.clear()
|
||||
@@ -159,7 +170,7 @@ class InMemoryStateStore(StateStore):
|
||||
|
||||
class RedisStateStore(StateStore):
|
||||
"""Redis-backed state store for production use."""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "redis://localhost:6379",
|
||||
@@ -170,75 +181,79 @@ class RedisStateStore(StateStore):
|
||||
self.url = url
|
||||
self.redis_kwargs = redis_kwargs
|
||||
self._redis = None
|
||||
|
||||
|
||||
async def _get_redis(self):
|
||||
"""Get or create Redis connection."""
|
||||
if self._redis is None:
|
||||
try:
|
||||
import redis.asyncio as redis
|
||||
except ImportError:
|
||||
raise ImportError("Redis support requires 'redis' package. Install with: pip install redis")
|
||||
|
||||
raise ImportError(
|
||||
"Redis support requires 'redis' package. Install with: pip install redis"
|
||||
)
|
||||
|
||||
self._redis = await redis.from_url(self.url, **self.redis_kwargs)
|
||||
return self._redis
|
||||
|
||||
|
||||
async def get(self, key: str, namespace: str | None = None) -> Any:
|
||||
"""Get a value from Redis."""
|
||||
redis = await self._get_redis()
|
||||
full_key = self._make_key(key, namespace)
|
||||
|
||||
|
||||
data = await redis.get(full_key)
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
|
||||
return self.serializer.deserialize(data)
|
||||
|
||||
|
||||
async def set(self, key: str, value: Any, namespace: str | None = None) -> None:
|
||||
"""Set a value in Redis."""
|
||||
redis = await self._get_redis()
|
||||
full_key = self._make_key(key, namespace)
|
||||
|
||||
|
||||
data = self.serializer.serialize(value)
|
||||
await redis.set(full_key, data)
|
||||
|
||||
|
||||
async def delete(self, key: str, namespace: str | None = None) -> None:
|
||||
"""Delete a value from Redis."""
|
||||
redis = await self._get_redis()
|
||||
full_key = self._make_key(key, namespace)
|
||||
await redis.delete(full_key)
|
||||
|
||||
|
||||
async def exists(self, key: str, namespace: str | None = None) -> bool:
|
||||
"""Check if a key exists in Redis."""
|
||||
redis = await self._get_redis()
|
||||
full_key = self._make_key(key, namespace)
|
||||
return await redis.exists(full_key) > 0
|
||||
|
||||
async def keys(self, pattern: str | None = None, namespace: str | None = None) -> List[str]:
|
||||
|
||||
async def keys(
|
||||
self, pattern: str | None = None, namespace: str | None = None
|
||||
) -> List[str]:
|
||||
"""List keys in Redis."""
|
||||
redis = await self._get_redis()
|
||||
|
||||
|
||||
# Build search pattern
|
||||
prefix = f"{namespace}:" if namespace else ""
|
||||
search_pattern = f"{prefix}*{pattern}*" if pattern else f"{prefix}*"
|
||||
|
||||
|
||||
# Get matching keys
|
||||
keys = []
|
||||
cursor = 0
|
||||
|
||||
|
||||
while True:
|
||||
cursor, batch = await redis.scan(cursor, match=search_pattern, count=100)
|
||||
|
||||
|
||||
for key in batch:
|
||||
key_str = key.decode() if isinstance(key, bytes) else key
|
||||
# Remove namespace prefix
|
||||
clean_key = key_str[len(prefix):] if namespace else key_str
|
||||
clean_key = key_str[len(prefix) :] if namespace else key_str
|
||||
keys.append(clean_key)
|
||||
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
|
||||
return keys
|
||||
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close Redis connection."""
|
||||
if self._redis:
|
||||
@@ -247,7 +262,7 @@ class RedisStateStore(StateStore):
|
||||
|
||||
class FileStateStore(StateStore):
|
||||
"""File-based state store for persistence without external dependencies."""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
directory: str | Path = ".agent_state",
|
||||
@@ -256,74 +271,76 @@ class FileStateStore(StateStore):
|
||||
super().__init__(serializer)
|
||||
self.directory = Path(directory)
|
||||
self.directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _get_file_path(self, key: str, namespace: str | None = None) -> Path:
|
||||
"""Get file path for a key."""
|
||||
full_key = self._make_key(key, namespace)
|
||||
# Replace special characters for filesystem compatibility
|
||||
safe_key = full_key.replace(":", "_").replace("/", "_")
|
||||
return self.directory / f"{safe_key}.state"
|
||||
|
||||
|
||||
async def get(self, key: str, namespace: str | None = None) -> Any:
|
||||
"""Get a value from file."""
|
||||
file_path = self._get_file_path(key, namespace)
|
||||
|
||||
|
||||
if not file_path.exists():
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
data = file_path.read_bytes()
|
||||
return self.serializer.deserialize(data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading state file {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def set(self, key: str, value: Any, namespace: str | None = None) -> None:
|
||||
"""Set a value in file."""
|
||||
file_path = self._get_file_path(key, namespace)
|
||||
|
||||
|
||||
try:
|
||||
data = self.serializer.serialize(value)
|
||||
file_path.write_bytes(data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing state file {file_path}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def delete(self, key: str, namespace: str | None = None) -> None:
|
||||
"""Delete a file."""
|
||||
file_path = self._get_file_path(key, namespace)
|
||||
|
||||
|
||||
try:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting state file {file_path}: {e}")
|
||||
|
||||
|
||||
async def exists(self, key: str, namespace: str | None = None) -> bool:
|
||||
"""Check if a file exists."""
|
||||
file_path = self._get_file_path(key, namespace)
|
||||
return file_path.exists()
|
||||
|
||||
async def keys(self, pattern: str | None = None, namespace: str | None = None) -> List[str]:
|
||||
|
||||
async def keys(
|
||||
self, pattern: str | None = None, namespace: str | None = None
|
||||
) -> List[str]:
|
||||
"""List keys from files."""
|
||||
keys = []
|
||||
prefix = f"{namespace}_" if namespace else ""
|
||||
|
||||
|
||||
for file_path in self.directory.glob("*.state"):
|
||||
filename = file_path.stem
|
||||
|
||||
|
||||
# Check namespace
|
||||
if namespace and not filename.startswith(prefix):
|
||||
continue
|
||||
|
||||
|
||||
# Extract key
|
||||
if namespace:
|
||||
key = filename[len(prefix):].replace("_", ":")
|
||||
key = filename[len(prefix) :].replace("_", ":")
|
||||
else:
|
||||
key = filename.replace("_", ":")
|
||||
|
||||
|
||||
# Check pattern
|
||||
if pattern is None or pattern in key:
|
||||
keys.append(key)
|
||||
|
||||
return keys
|
||||
|
||||
return keys
|
||||
|
||||
@@ -54,5 +54,7 @@ class AgentUpdatedStreamEvent:
|
||||
type: Literal["agent_updated_stream_event"] = "agent_updated_stream_event"
|
||||
|
||||
|
||||
StreamEvent: TypeAlias = Union[RawResponsesStreamEvent, RunItemStreamEvent, AgentUpdatedStreamEvent]
|
||||
StreamEvent: TypeAlias = Union[
|
||||
RawResponsesStreamEvent, RunItemStreamEvent, AgentUpdatedStreamEvent
|
||||
]
|
||||
"""A streaming event from an agent."""
|
||||
|
||||
@@ -39,13 +39,17 @@ def _ensure_strict_json_schema(
|
||||
defs = json_schema.get("$defs")
|
||||
if is_dict(defs):
|
||||
for def_name, def_schema in defs.items():
|
||||
_ensure_strict_json_schema(def_schema, path=(*path, "$defs", def_name), root=root)
|
||||
_ensure_strict_json_schema(
|
||||
def_schema, path=(*path, "$defs", def_name), root=root
|
||||
)
|
||||
|
||||
definitions = json_schema.get("definitions")
|
||||
if is_dict(definitions):
|
||||
for definition_name, definition_schema in definitions.items():
|
||||
_ensure_strict_json_schema(
|
||||
definition_schema, path=(*path, "definitions", definition_name), root=root
|
||||
definition_schema,
|
||||
path=(*path, "definitions", definition_name),
|
||||
root=root,
|
||||
)
|
||||
|
||||
typ = json_schema.get("type")
|
||||
@@ -56,15 +60,15 @@ def _ensure_strict_json_schema(
|
||||
and "additionalProperties" in json_schema
|
||||
and json_schema["additionalProperties"] is True
|
||||
):
|
||||
# Allow relaxed schema for kwargs containers generated by function_schema
|
||||
if json_schema.get("title") == "Kwargs":
|
||||
return json_schema
|
||||
raise UserError(
|
||||
"additionalProperties should not be set for object types. This could be because "
|
||||
"you're using an older version of Pydantic, or because you configured additional "
|
||||
"properties to be allowed. If you really need this, update the function or output tool "
|
||||
"to not use a strict schema."
|
||||
)
|
||||
# Allow relaxed schema for kwargs containers generated by function_schema
|
||||
if json_schema.get("title") == "Kwargs":
|
||||
return json_schema
|
||||
raise UserError(
|
||||
"additionalProperties should not be set for object types. This could be because "
|
||||
"you're using an older version of Pydantic, or because you configured additional "
|
||||
"properties to be allowed. If you really need this, update the function or output tool "
|
||||
"to not use a strict schema."
|
||||
)
|
||||
|
||||
# object types
|
||||
# { 'type': 'object', 'properties': { 'a': {...} } }
|
||||
@@ -72,7 +76,9 @@ def _ensure_strict_json_schema(
|
||||
if is_dict(properties):
|
||||
json_schema["required"] = list(properties.keys())
|
||||
json_schema["properties"] = {
|
||||
key: _ensure_strict_json_schema(prop_schema, path=(*path, "properties", key), root=root)
|
||||
key: _ensure_strict_json_schema(
|
||||
prop_schema, path=(*path, "properties", key), root=root
|
||||
)
|
||||
for key, prop_schema in properties.items()
|
||||
}
|
||||
|
||||
@@ -80,13 +86,17 @@ def _ensure_strict_json_schema(
|
||||
# { 'type': 'array', 'items': {...} }
|
||||
items = json_schema.get("items")
|
||||
if is_dict(items):
|
||||
json_schema["items"] = _ensure_strict_json_schema(items, path=(*path, "items"), root=root)
|
||||
json_schema["items"] = _ensure_strict_json_schema(
|
||||
items, path=(*path, "items"), root=root
|
||||
)
|
||||
|
||||
# unions
|
||||
any_of = json_schema.get("anyOf")
|
||||
if is_list(any_of):
|
||||
json_schema["anyOf"] = [
|
||||
_ensure_strict_json_schema(variant, path=(*path, "anyOf", str(i)), root=root)
|
||||
_ensure_strict_json_schema(
|
||||
variant, path=(*path, "anyOf", str(i)), root=root
|
||||
)
|
||||
for i, variant in enumerate(any_of)
|
||||
]
|
||||
|
||||
@@ -95,12 +105,16 @@ def _ensure_strict_json_schema(
|
||||
if is_list(all_of):
|
||||
if len(all_of) == 1:
|
||||
json_schema.update(
|
||||
_ensure_strict_json_schema(all_of[0], path=(*path, "allOf", "0"), root=root)
|
||||
_ensure_strict_json_schema(
|
||||
all_of[0], path=(*path, "allOf", "0"), root=root
|
||||
)
|
||||
)
|
||||
json_schema.pop("allOf")
|
||||
else:
|
||||
json_schema["allOf"] = [
|
||||
_ensure_strict_json_schema(entry, path=(*path, "allOf", str(i)), root=root)
|
||||
_ensure_strict_json_schema(
|
||||
entry, path=(*path, "allOf", str(i)), root=root
|
||||
)
|
||||
for i, entry in enumerate(all_of)
|
||||
]
|
||||
|
||||
@@ -143,9 +157,9 @@ def resolve_ref(*, root: dict[str, object], ref: str) -> object:
|
||||
resolved = root
|
||||
for key in path:
|
||||
value = resolved[key]
|
||||
assert is_dict(value), (
|
||||
f"encountered non-dictionary entry while resolving {ref} - {resolved}"
|
||||
)
|
||||
assert is_dict(
|
||||
value
|
||||
), f"encountered non-dictionary entry while resolving {ref} - {resolved}"
|
||||
resolved = value
|
||||
|
||||
return resolved
|
||||
|
||||
@@ -25,7 +25,9 @@ ToolParams = ParamSpec("ToolParams")
|
||||
ToolFunctionWithoutContext = Callable[ToolParams, Any]
|
||||
ToolFunctionWithContext = Callable[Concatenate[RunContextWrapper[Any], ToolParams], Any]
|
||||
|
||||
ToolFunction = Union[ToolFunctionWithoutContext[ToolParams], ToolFunctionWithContext[ToolParams]]
|
||||
ToolFunction = Union[
|
||||
ToolFunctionWithoutContext[ToolParams], ToolFunctionWithContext[ToolParams]
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -221,7 +223,9 @@ def function_tool(
|
||||
else schema.params_pydantic_model()
|
||||
)
|
||||
except ValidationError as e:
|
||||
raise ModelBehaviorError(f"Invalid JSON input for tool {schema.name}: {e}") from e
|
||||
raise ModelBehaviorError(
|
||||
f"Invalid JSON input for tool {schema.name}: {e}"
|
||||
) from e
|
||||
|
||||
args, kwargs_dict = schema.to_call_args(parsed)
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@ from .tool import FunctionTool, Tool, function_tool
|
||||
|
||||
class MCPServer(Protocol):
|
||||
"""Protocol for MCP server implementations."""
|
||||
|
||||
|
||||
async def list_tools(self) -> List[Dict[str, Any]]:
|
||||
"""List available tools from the MCP server."""
|
||||
...
|
||||
|
||||
|
||||
async def call_tool(self, name: str, arguments: Dict[str, Any]) -> Any:
|
||||
"""Call a tool on the MCP server."""
|
||||
...
|
||||
@@ -28,19 +28,19 @@ class MCPServer(Protocol):
|
||||
@dataclass
|
||||
class MCPTool(Tool):
|
||||
"""A tool that delegates to an MCP server."""
|
||||
|
||||
|
||||
name: str
|
||||
"""Tool name as exposed by MCP server."""
|
||||
|
||||
|
||||
description: str
|
||||
"""Tool description."""
|
||||
|
||||
|
||||
parameters_schema: Dict[str, Any]
|
||||
"""JSON schema for parameters."""
|
||||
|
||||
|
||||
server: MCPServer
|
||||
"""The MCP server instance."""
|
||||
|
||||
|
||||
async def execute(self, arguments: Dict[str, Any]) -> Any:
|
||||
"""Execute the tool via MCP server."""
|
||||
return await self.server.call_tool(self.name, arguments)
|
||||
@@ -48,47 +48,47 @@ class MCPTool(Tool):
|
||||
|
||||
class MCPToolAdapter:
|
||||
"""Adapts MCP tools to work with the agent framework."""
|
||||
|
||||
|
||||
def __init__(self, server: MCPServer):
|
||||
"""Initialize adapter with MCP server.
|
||||
|
||||
|
||||
Args:
|
||||
server: MCP server instance
|
||||
"""
|
||||
self.server = server
|
||||
self._tools_cache: List[Tool] | None = None
|
||||
|
||||
|
||||
async def get_tools(self) -> List[Tool]:
|
||||
"""Get all tools from the MCP server as agent tools."""
|
||||
if self._tools_cache is not None:
|
||||
return self._tools_cache
|
||||
|
||||
|
||||
mcp_tools = await self.server.list_tools()
|
||||
tools = []
|
||||
|
||||
|
||||
for tool_def in mcp_tools:
|
||||
# Create FunctionTool wrapper
|
||||
tool = self._create_tool_wrapper(tool_def)
|
||||
tools.append(tool)
|
||||
|
||||
|
||||
self._tools_cache = tools
|
||||
return tools
|
||||
|
||||
|
||||
def _create_tool_wrapper(self, tool_def: Dict[str, Any]) -> FunctionTool:
|
||||
"""Create a FunctionTool wrapper for an MCP tool."""
|
||||
name = tool_def["name"]
|
||||
description = tool_def.get("description", "")
|
||||
parameters = tool_def.get("inputSchema", {})
|
||||
|
||||
|
||||
async def tool_function(ctx: RunContextWrapper[Any], **kwargs) -> str:
|
||||
# Call MCP server
|
||||
result = await self.server.call_tool(name, kwargs)
|
||||
|
||||
|
||||
# Convert result to string
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
# Create function tool
|
||||
return FunctionTool(
|
||||
name=name,
|
||||
@@ -96,7 +96,7 @@ class MCPToolAdapter:
|
||||
params_json_schema=parameters,
|
||||
on_invoke_tool=lambda ctx, args: tool_function(ctx, **json.loads(args)),
|
||||
)
|
||||
|
||||
|
||||
async def refresh_tools(self) -> None:
|
||||
"""Refresh the tools cache."""
|
||||
self._tools_cache = None
|
||||
@@ -104,7 +104,7 @@ class MCPToolAdapter:
|
||||
|
||||
class CompositeTool(Tool):
|
||||
"""A tool that combines multiple sub-tools."""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
@@ -113,7 +113,7 @@ class CompositeTool(Tool):
|
||||
orchestrator: Callable[[str, List[Tool]], Tool] | None = None,
|
||||
):
|
||||
"""Initialize composite tool.
|
||||
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
description: Tool description
|
||||
@@ -124,18 +124,18 @@ class CompositeTool(Tool):
|
||||
self.description = description
|
||||
self.tools = tools
|
||||
self.orchestrator = orchestrator or self._default_orchestrator
|
||||
|
||||
|
||||
def _default_orchestrator(self, input: str, tools: List[Tool]) -> Tool:
|
||||
"""Default orchestrator - just uses the first tool."""
|
||||
if not tools:
|
||||
raise AgentsException("No tools available")
|
||||
return tools[0]
|
||||
|
||||
|
||||
async def execute(self, ctx: RunContextWrapper[Any], input: str) -> str:
|
||||
"""Execute the composite tool."""
|
||||
# Select tool to use
|
||||
selected_tool = self.orchestrator(input, self.tools)
|
||||
|
||||
|
||||
# Execute selected tool
|
||||
if isinstance(selected_tool, FunctionTool):
|
||||
return await selected_tool.on_invoke_tool(ctx, input)
|
||||
@@ -145,78 +145,78 @@ class CompositeTool(Tool):
|
||||
|
||||
class ToolRegistry:
|
||||
"""Registry for managing tools across the system."""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self._tools: Dict[str, Tool] = {}
|
||||
self._categories: Dict[str, List[str]] = {}
|
||||
self._mcp_adapters: List[MCPToolAdapter] = []
|
||||
|
||||
|
||||
def register(self, tool: Tool, category: str = "general") -> None:
|
||||
"""Register a tool.
|
||||
|
||||
|
||||
Args:
|
||||
tool: Tool to register
|
||||
category: Tool category
|
||||
"""
|
||||
if tool.name in self._tools:
|
||||
raise AgentsException(f"Tool '{tool.name}' already registered")
|
||||
|
||||
|
||||
self._tools[tool.name] = tool
|
||||
|
||||
|
||||
if category not in self._categories:
|
||||
self._categories[category] = []
|
||||
self._categories[category].append(tool.name)
|
||||
|
||||
|
||||
logger.debug(f"Registered tool '{tool.name}' in category '{category}'")
|
||||
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""Unregister a tool."""
|
||||
if name not in self._tools:
|
||||
return
|
||||
|
||||
|
||||
del self._tools[name]
|
||||
|
||||
|
||||
# Remove from categories
|
||||
for category, tools in self._categories.items():
|
||||
if name in tools:
|
||||
tools.remove(name)
|
||||
|
||||
|
||||
def get(self, name: str) -> Tool | None:
|
||||
"""Get a tool by name."""
|
||||
return self._tools.get(name)
|
||||
|
||||
|
||||
def list(self, category: str | None = None) -> List[Tool]:
|
||||
"""List tools, optionally filtered by category."""
|
||||
if category:
|
||||
tool_names = self._categories.get(category, [])
|
||||
return [self._tools[name] for name in tool_names if name in self._tools]
|
||||
return list(self._tools.values())
|
||||
|
||||
|
||||
def categories(self) -> List[str]:
|
||||
"""List all categories."""
|
||||
return list(self._categories.keys())
|
||||
|
||||
|
||||
async def add_mcp_server(self, server: MCPServer) -> None:
|
||||
"""Add an MCP server to the registry.
|
||||
|
||||
|
||||
Args:
|
||||
server: MCP server to add
|
||||
"""
|
||||
adapter = MCPToolAdapter(server)
|
||||
self._mcp_adapters.append(adapter)
|
||||
|
||||
|
||||
# Load tools from server
|
||||
tools = await adapter.get_tools()
|
||||
for tool in tools:
|
||||
self.register(tool, category="mcp")
|
||||
|
||||
|
||||
async def refresh_mcp_tools(self) -> None:
|
||||
"""Refresh tools from all MCP servers."""
|
||||
# Remove existing MCP tools
|
||||
mcp_tools = self._categories.get("mcp", [])
|
||||
for tool_name in mcp_tools:
|
||||
self.unregister(tool_name)
|
||||
|
||||
|
||||
# Reload from all adapters
|
||||
for adapter in self._mcp_adapters:
|
||||
await adapter.refresh_tools()
|
||||
@@ -246,13 +246,13 @@ def tool_from_function(
|
||||
category: str = "general",
|
||||
) -> FunctionTool:
|
||||
"""Create and register a tool from a function.
|
||||
|
||||
|
||||
Args:
|
||||
func: Function to wrap
|
||||
name: Tool name (defaults to function name)
|
||||
description: Tool description
|
||||
category: Tool category
|
||||
|
||||
|
||||
Returns:
|
||||
The created tool
|
||||
"""
|
||||
@@ -261,44 +261,48 @@ def tool_from_function(
|
||||
name_override=name,
|
||||
description_override=description,
|
||||
)
|
||||
|
||||
|
||||
register_tool(tool, category)
|
||||
return tool
|
||||
|
||||
|
||||
class ToolChain:
|
||||
"""Chain multiple tools together."""
|
||||
|
||||
|
||||
def __init__(self, tools: List[Tool]):
|
||||
"""Initialize tool chain.
|
||||
|
||||
|
||||
Args:
|
||||
tools: Tools to chain in order
|
||||
"""
|
||||
self.tools = tools
|
||||
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
ctx: RunContextWrapper[Any],
|
||||
input: Any,
|
||||
) -> Any:
|
||||
"""Execute the tool chain.
|
||||
|
||||
|
||||
Args:
|
||||
ctx: Execution context
|
||||
input: Initial input
|
||||
|
||||
|
||||
Returns:
|
||||
Final output after all tools
|
||||
"""
|
||||
current_input = input
|
||||
|
||||
|
||||
for tool in self.tools:
|
||||
if isinstance(tool, FunctionTool):
|
||||
# Convert input to JSON string for function tools
|
||||
json_input = json.dumps(current_input) if not isinstance(current_input, str) else current_input
|
||||
json_input = (
|
||||
json.dumps(current_input)
|
||||
if not isinstance(current_input, str)
|
||||
else current_input
|
||||
)
|
||||
output = await tool.on_invoke_tool(ctx, json_input)
|
||||
|
||||
|
||||
# Try to parse output as JSON
|
||||
try:
|
||||
current_input = json.loads(output)
|
||||
@@ -307,9 +311,9 @@ class ToolChain:
|
||||
else:
|
||||
# For other tool types, pass through
|
||||
current_input = str(current_input)
|
||||
|
||||
|
||||
return current_input
|
||||
|
||||
|
||||
def __add__(self, other: Tool | ToolChain) -> ToolChain:
|
||||
"""Add another tool or chain."""
|
||||
if isinstance(other, ToolChain):
|
||||
@@ -321,21 +325,21 @@ class ToolChain:
|
||||
@dataclass
|
||||
class ToolMetrics:
|
||||
"""Metrics for tool execution."""
|
||||
|
||||
|
||||
total_calls: int = 0
|
||||
successful_calls: int = 0
|
||||
failed_calls: int = 0
|
||||
total_duration: float = 0.0
|
||||
last_error: str | None = None
|
||||
last_call_time: float | None = None
|
||||
|
||||
|
||||
@property
|
||||
def success_rate(self) -> float:
|
||||
"""Calculate success rate."""
|
||||
if self.total_calls == 0:
|
||||
return 0.0
|
||||
return self.successful_calls / self.total_calls
|
||||
|
||||
|
||||
@property
|
||||
def average_duration(self) -> float:
|
||||
"""Calculate average duration."""
|
||||
@@ -346,43 +350,43 @@ class ToolMetrics:
|
||||
|
||||
class MonitoredTool(Tool):
|
||||
"""Wrapper that adds monitoring to any tool."""
|
||||
|
||||
|
||||
def __init__(self, tool: Tool):
|
||||
"""Initialize monitored tool.
|
||||
|
||||
|
||||
Args:
|
||||
tool: Tool to monitor
|
||||
"""
|
||||
self.tool = tool
|
||||
self.metrics = ToolMetrics()
|
||||
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get tool name."""
|
||||
return self.tool.name
|
||||
|
||||
|
||||
async def execute(self, ctx: RunContextWrapper[Any], input: str) -> str:
|
||||
"""Execute with monitoring."""
|
||||
import time
|
||||
|
||||
|
||||
start_time = time.time()
|
||||
self.metrics.total_calls += 1
|
||||
self.metrics.last_call_time = start_time
|
||||
|
||||
|
||||
try:
|
||||
if isinstance(self.tool, FunctionTool):
|
||||
result = await self.tool.on_invoke_tool(ctx, input)
|
||||
else:
|
||||
raise AgentsException(f"Cannot monitor tool type: {type(self.tool)}")
|
||||
|
||||
|
||||
# Success
|
||||
self.metrics.successful_calls += 1
|
||||
self.metrics.total_duration += time.time() - start_time
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
except Exception as e:
|
||||
# Failure
|
||||
self.metrics.failed_calls += 1
|
||||
self.metrics.last_error = str(e)
|
||||
raise
|
||||
raise
|
||||
|
||||
@@ -105,7 +105,9 @@ def agent_span(
|
||||
The newly created agent span.
|
||||
"""
|
||||
return GLOBAL_TRACE_PROVIDER.create_span(
|
||||
span_data=AgentSpanData(name=name, handoffs=handoffs, tools=tools, output_type=output_type),
|
||||
span_data=AgentSpanData(
|
||||
name=name, handoffs=handoffs, tools=tools, output_type=output_type
|
||||
),
|
||||
span_id=span_id,
|
||||
parent=parent,
|
||||
disabled=disabled,
|
||||
@@ -181,7 +183,11 @@ def generation_span(
|
||||
"""
|
||||
return GLOBAL_TRACE_PROVIDER.create_span(
|
||||
span_data=GenerationSpanData(
|
||||
input=input, output=output, model=model, model_config=model_config, usage=usage
|
||||
input=input,
|
||||
output=output,
|
||||
model=model,
|
||||
model_config=model_config,
|
||||
usage=usage,
|
||||
),
|
||||
span_id=span_id,
|
||||
parent=parent,
|
||||
|
||||
@@ -93,7 +93,9 @@ class BackendSpanExporter(TracingExporter):
|
||||
while True:
|
||||
attempt += 1
|
||||
try:
|
||||
response = self._client.post(url=self.endpoint, headers=headers, json=payload)
|
||||
response = self._client.post(
|
||||
url=self.endpoint, headers=headers, json=payload
|
||||
)
|
||||
|
||||
# If the response is successful, break out of the loop
|
||||
if response.status_code < 300:
|
||||
@@ -102,7 +104,9 @@ class BackendSpanExporter(TracingExporter):
|
||||
|
||||
# If the response is a client error (4xx), we wont retry
|
||||
if 400 <= response.status_code < 500:
|
||||
logger.error(f"Tracing client error {response.status_code}: {response.text}")
|
||||
logger.error(
|
||||
f"Tracing client error {response.status_code}: {response.text}"
|
||||
)
|
||||
return
|
||||
|
||||
# For 5xx or other unexpected codes, treat it as transient and retry
|
||||
@@ -151,7 +155,9 @@ class BatchTraceProcessor(TracingProcessor):
|
||||
export_trigger_ratio: The ratio of the queue size at which we will trigger an export.
|
||||
"""
|
||||
self._exporter = exporter
|
||||
self._queue: queue.Queue[Trace | Span[Any]] = queue.Queue(maxsize=max_queue_size)
|
||||
self._queue: queue.Queue[Trace | Span[Any]] = queue.Queue(
|
||||
maxsize=max_queue_size
|
||||
)
|
||||
self._max_queue_size = max_queue_size
|
||||
self._max_batch_size = max_batch_size
|
||||
self._schedule_delay = schedule_delay
|
||||
@@ -206,7 +212,10 @@ class BatchTraceProcessor(TracingProcessor):
|
||||
queue_size = self._queue.qsize()
|
||||
|
||||
# If it's time for a scheduled flush or queue is above the trigger threshold
|
||||
if current_time >= self._next_export_time or queue_size >= self._export_trigger_size:
|
||||
if (
|
||||
current_time >= self._next_export_time
|
||||
or queue_size >= self._export_trigger_size
|
||||
):
|
||||
self._export_batches(force=False)
|
||||
# Reset the next scheduled flush time
|
||||
self._next_export_time = time.time() + self._schedule_delay
|
||||
|
||||
@@ -23,7 +23,9 @@ class Scope:
|
||||
return _current_span.get()
|
||||
|
||||
@classmethod
|
||||
def set_current_span(cls, span: "Span[Any] | None") -> "contextvars.Token[Span[Any] | None]":
|
||||
def set_current_span(
|
||||
cls, span: "Span[Any] | None"
|
||||
) -> "contextvars.Token[Span[Any] | None]":
|
||||
return _current_span.set(span)
|
||||
|
||||
@classmethod
|
||||
@@ -35,7 +37,9 @@ class Scope:
|
||||
return _current_trace.get()
|
||||
|
||||
@classmethod
|
||||
def set_current_trace(cls, trace: "Trace | None") -> "contextvars.Token[Trace | None]":
|
||||
def set_current_trace(
|
||||
cls, trace: "Trace | None"
|
||||
) -> "contextvars.Token[Trace | None]":
|
||||
logger.debug(f"Setting current trace: {trace.trace_id if trace else None}")
|
||||
return _current_trace.set(trace)
|
||||
|
||||
|
||||
@@ -83,7 +83,9 @@ class SynchronousMultiTracingProcessor(TracingProcessor):
|
||||
class TraceProvider:
|
||||
def __init__(self):
|
||||
self._multi_processor = SynchronousMultiTracingProcessor()
|
||||
self._disabled = os.environ.get("OPENAI_AGENTS_DISABLE_TRACING", "false").lower() in (
|
||||
self._disabled = os.environ.get(
|
||||
"OPENAI_AGENTS_DISABLE_TRACING", "false"
|
||||
).lower() in (
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
@@ -168,7 +170,9 @@ class TraceProvider:
|
||||
"Returning NoOpSpan."
|
||||
)
|
||||
return NoOpSpan(span_data)
|
||||
elif isinstance(current_trace, NoOpTrace) or isinstance(current_span, NoOpSpan):
|
||||
elif isinstance(current_trace, NoOpTrace) or isinstance(
|
||||
current_span, NoOpSpan
|
||||
):
|
||||
logger.debug(
|
||||
f"Parent {current_span} or {current_trace} is no-op, returning NoOpSpan"
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ try:
|
||||
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
|
||||
except ImportError:
|
||||
# Add src to path if running from project root
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
|
||||
from openai import AsyncOpenAI
|
||||
from agents import Agent, Runner, RunConfig, ModelProvider, Model
|
||||
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
|
||||
@@ -34,14 +34,14 @@ RESET = "\033[0m"
|
||||
|
||||
class HanzoModelProvider(ModelProvider):
|
||||
"""Custom model provider that uses Hanzo Router backend."""
|
||||
|
||||
|
||||
def __init__(self, base_url: str, api_key: str):
|
||||
self.client = AsyncOpenAI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
)
|
||||
print(f"{BLUE}Configured Hanzo Router at: {base_url}{RESET}")
|
||||
|
||||
|
||||
def get_model(self, model_name: str | None) -> Model:
|
||||
# Default to gpt-3.5-turbo if no model specified
|
||||
model = model_name or "gpt-3.5-turbo"
|
||||
@@ -52,19 +52,17 @@ class HanzoModelProvider(ModelProvider):
|
||||
async def test_basic_agent():
|
||||
"""Test basic agent functionality."""
|
||||
print(f"\n{GREEN}=== Testing Basic Agent ==={RESET}")
|
||||
|
||||
|
||||
provider = HanzoModelProvider(HANZO_ROUTER_URL, HANZO_API_KEY)
|
||||
|
||||
|
||||
agent = Agent(
|
||||
name="TestAssistant",
|
||||
instructions="You are a helpful assistant. Be concise.",
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
"What is 2 + 2?",
|
||||
run_config=RunConfig(model_provider=provider)
|
||||
agent, "What is 2 + 2?", run_config=RunConfig(model_provider=provider)
|
||||
)
|
||||
print(f"{GREEN}✓ Basic test passed!{RESET}")
|
||||
print(f"Response: {result.final_output}")
|
||||
@@ -77,12 +75,12 @@ async def test_basic_agent():
|
||||
async def test_with_tools():
|
||||
"""Test agent with tools."""
|
||||
print(f"\n{GREEN}=== Testing Agent with Tools ==={RESET}")
|
||||
|
||||
|
||||
provider = HanzoModelProvider(HANZO_ROUTER_URL, HANZO_API_KEY)
|
||||
|
||||
|
||||
# Define a simple tool
|
||||
from agents import function_tool
|
||||
|
||||
|
||||
@function_tool
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a mathematical expression."""
|
||||
@@ -91,18 +89,16 @@ async def test_with_tools():
|
||||
return f"The result is: {result}"
|
||||
except:
|
||||
return "Invalid expression"
|
||||
|
||||
|
||||
agent = Agent(
|
||||
name="CalculatorAgent",
|
||||
instructions="You are a calculator assistant. Use the calculate tool for math.",
|
||||
tools=[calculate]
|
||||
tools=[calculate],
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
"What is 15 * 23?",
|
||||
run_config=RunConfig(model_provider=provider)
|
||||
agent, "What is 15 * 23?", run_config=RunConfig(model_provider=provider)
|
||||
)
|
||||
print(f"{GREEN}✓ Tool test passed!{RESET}")
|
||||
print(f"Response: {result.final_output}")
|
||||
@@ -115,32 +111,32 @@ async def test_with_tools():
|
||||
async def test_conversation():
|
||||
"""Test multi-turn conversation."""
|
||||
print(f"\n{GREEN}=== Testing Conversation ==={RESET}")
|
||||
|
||||
|
||||
provider = HanzoModelProvider(HANZO_ROUTER_URL, HANZO_API_KEY)
|
||||
|
||||
|
||||
agent = Agent(
|
||||
name="ConversationAgent",
|
||||
instructions="You are a helpful assistant. Remember our conversation context.",
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
# First message
|
||||
result1 = await Runner.run(
|
||||
agent,
|
||||
"My name is Alice. Remember it.",
|
||||
run_config=RunConfig(model_provider=provider)
|
||||
run_config=RunConfig(model_provider=provider),
|
||||
)
|
||||
print(f"Response 1: {result1.final_output}")
|
||||
|
||||
|
||||
# Second message using context
|
||||
result2 = await Runner.run(
|
||||
agent,
|
||||
"What's my name?",
|
||||
run_config=RunConfig(model_provider=provider),
|
||||
context=result1.context
|
||||
context=result1.context,
|
||||
)
|
||||
print(f"Response 2: {result2.final_output}")
|
||||
|
||||
|
||||
if "Alice" in result2.final_output:
|
||||
print(f"{GREEN}✓ Conversation test passed!{RESET}")
|
||||
return True
|
||||
@@ -155,10 +151,10 @@ async def test_conversation():
|
||||
async def check_router_health():
|
||||
"""Check if Hanzo Router is accessible."""
|
||||
import aiohttp
|
||||
|
||||
|
||||
print(f"\n{GREEN}=== Checking Hanzo Router Health ==={RESET}")
|
||||
print(f"Router URL: {HANZO_ROUTER_URL}")
|
||||
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# Try health endpoint
|
||||
@@ -168,21 +164,25 @@ async def check_router_health():
|
||||
print(f"{GREEN}✓ Router health check passed{RESET}")
|
||||
return True
|
||||
else:
|
||||
print(f"{YELLOW}⚠ Router health endpoint returned {resp.status}{RESET}")
|
||||
|
||||
print(
|
||||
f"{YELLOW}⚠ Router health endpoint returned {resp.status}{RESET}"
|
||||
)
|
||||
|
||||
# Try models endpoint
|
||||
models_url = f"{HANZO_ROUTER_URL}/models"
|
||||
headers = {"Authorization": f"Bearer {HANZO_API_KEY}"}
|
||||
async with session.get(models_url, headers=headers) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
print(f"{GREEN}✓ Available models: {len(data.get('data', []))} found{RESET}")
|
||||
for model in data.get('data', [])[:5]:
|
||||
print(
|
||||
f"{GREEN}✓ Available models: {len(data.get('data', []))} found{RESET}"
|
||||
)
|
||||
for model in data.get("data", [])[:5]:
|
||||
print(f" - {model.get('id')}")
|
||||
return True
|
||||
else:
|
||||
print(f"{YELLOW}⚠ Models endpoint returned {resp.status}{RESET}")
|
||||
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
print(f"{RED}✗ Cannot connect to Router: {e}{RESET}")
|
||||
print(f"{YELLOW}Make sure Hanzo Router is running at {HANZO_ROUTER_URL}{RESET}")
|
||||
@@ -190,7 +190,7 @@ async def check_router_health():
|
||||
except Exception as e:
|
||||
print(f"{RED}✗ Unexpected error: {e}{RESET}")
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -199,38 +199,40 @@ async def main():
|
||||
print(f"{BLUE}{'='*60}{RESET}")
|
||||
print(f"{BLUE}Hanzo Agent SDK Backend Integration Test{RESET}")
|
||||
print(f"{BLUE}{'='*60}{RESET}")
|
||||
|
||||
|
||||
# Check router health first
|
||||
if not await check_router_health():
|
||||
print(f"\n{RED}Cannot proceed without Router connection.{RESET}")
|
||||
print(f"{YELLOW}Start the Router with: cd /Users/z/work/hanzo/services && make start-router{RESET}")
|
||||
print(
|
||||
f"{YELLOW}Start the Router with: cd /Users/z/work/hanzo/services && make start-router{RESET}"
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
# Run tests
|
||||
tests = [
|
||||
test_basic_agent,
|
||||
test_with_tools,
|
||||
test_conversation,
|
||||
]
|
||||
|
||||
|
||||
results = []
|
||||
for test in tests:
|
||||
result = await test()
|
||||
results.append(result)
|
||||
|
||||
|
||||
# Summary
|
||||
print(f"\n{BLUE}{'='*60}{RESET}")
|
||||
print(f"{BLUE}Test Summary{RESET}")
|
||||
print(f"{BLUE}{'='*60}{RESET}")
|
||||
|
||||
|
||||
passed = sum(1 for r in results if r)
|
||||
total = len(results)
|
||||
|
||||
|
||||
if passed == total:
|
||||
print(f"{GREEN}✓ All tests passed! ({passed}/{total}){RESET}")
|
||||
else:
|
||||
print(f"{YELLOW}⚠ {passed}/{total} tests passed{RESET}")
|
||||
|
||||
|
||||
print(f"\n{GREEN}The Hanzo Agent SDK is working with the local backend!{RESET}")
|
||||
|
||||
|
||||
@@ -239,6 +241,6 @@ if __name__ == "__main__":
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
# Set a dummy key to prevent OpenAI client from complaining
|
||||
os.environ["OPENAI_API_KEY"] = "sk-dummy"
|
||||
|
||||
|
||||
# Run tests
|
||||
asyncio.run(main())
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -9,7 +9,7 @@ import sys
|
||||
from typing import Optional
|
||||
|
||||
# Add src to path if running from project root
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
|
||||
|
||||
from agents import (
|
||||
Agent,
|
||||
@@ -17,7 +17,7 @@ from agents import (
|
||||
RunConfig,
|
||||
HanzoNodeProvider,
|
||||
create_hanzo_node_provider,
|
||||
function_tool
|
||||
function_tool,
|
||||
)
|
||||
|
||||
# Colors for output
|
||||
@@ -56,7 +56,9 @@ async def test_hanzo_node_connection():
|
||||
is_healthy = await provider.health_check()
|
||||
|
||||
if is_healthy:
|
||||
print(f"{GREEN}✓ Successfully connected to Hanzo Node at {provider.base_url}{RESET}")
|
||||
print(
|
||||
f"{GREEN}✓ Successfully connected to Hanzo Node at {provider.base_url}{RESET}"
|
||||
)
|
||||
return provider
|
||||
else:
|
||||
print(f"{RED}✗ Failed to connect to Hanzo Node at {provider.base_url}{RESET}")
|
||||
@@ -95,11 +97,11 @@ async def test_simple_agent(provider: HanzoNodeProvider):
|
||||
|
||||
print(f"{GREEN}Response:{RESET}")
|
||||
for item in result.items:
|
||||
if hasattr(item, 'content'):
|
||||
if hasattr(item, "content"):
|
||||
for content in item.content:
|
||||
if hasattr(content, 'text'):
|
||||
if hasattr(content, "text"):
|
||||
print(f" {content.text}")
|
||||
elif hasattr(content, 'name') and hasattr(content, 'result'):
|
||||
elif hasattr(content, "name") and hasattr(content, "result"):
|
||||
print(f" Tool: {content.name}")
|
||||
print(f" Result: {content.result}")
|
||||
except Exception as e:
|
||||
@@ -125,10 +127,10 @@ async def test_streaming_agent(provider: HanzoNodeProvider):
|
||||
try:
|
||||
async with runner.run_stream(query) as stream:
|
||||
async for chunk in stream:
|
||||
if hasattr(chunk, 'content'):
|
||||
if hasattr(chunk, "content"):
|
||||
for content in chunk.content:
|
||||
if hasattr(content, 'text'):
|
||||
print(content.text, end='', flush=True)
|
||||
if hasattr(content, "text"):
|
||||
print(content.text, end="", flush=True)
|
||||
print() # New line after streaming
|
||||
except Exception as e:
|
||||
print(f"\n{RED}Error during streaming: {e}{RESET}")
|
||||
@@ -160,4 +162,4 @@ async def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -5,7 +5,8 @@ Quick test of Agent SDK
|
||||
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, 'src')
|
||||
|
||||
sys.path.insert(0, "src")
|
||||
|
||||
# For synchronous execution
|
||||
from agents import Agent, Runner
|
||||
@@ -31,23 +32,21 @@ try:
|
||||
print(f"Answer: {result.final_output}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
|
||||
# Try with explicit provider
|
||||
print("\nTrying with Hanzo Router...")
|
||||
from agents.models.openai_provider import OpenAIProvider
|
||||
|
||||
provider = OpenAIProvider(
|
||||
base_url="http://localhost:4000/v1",
|
||||
api_key="sk-1234"
|
||||
)
|
||||
|
||||
|
||||
provider = OpenAIProvider(base_url="http://localhost:4000/v1", api_key="sk-1234")
|
||||
|
||||
try:
|
||||
from agents import RunConfig
|
||||
|
||||
result = Runner.run_sync(
|
||||
agent,
|
||||
agent,
|
||||
"What's 1+2?",
|
||||
run_config=RunConfig(model_provider=provider, model="gpt-3.5-turbo")
|
||||
run_config=RunConfig(model_provider=provider, model="gpt-3.5-turbo"),
|
||||
)
|
||||
print(f"Answer: {result.final_output}")
|
||||
except Exception as e2:
|
||||
print(f"Router Error: {e2}")
|
||||
print(f"Router Error: {e2}")
|
||||
|
||||
@@ -9,7 +9,8 @@ from openai import AsyncOpenAI
|
||||
|
||||
# Import from the local source
|
||||
import sys
|
||||
sys.path.insert(0, 'src')
|
||||
|
||||
sys.path.insert(0, "src")
|
||||
|
||||
from agents import Agent, Runner, RunConfig, ModelProvider, Model
|
||||
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
|
||||
@@ -25,7 +26,7 @@ if not ANTHROPIC_API_KEY:
|
||||
|
||||
class AnthropicProvider(ModelProvider):
|
||||
"""Provider for Anthropic models via OpenAI-compatible API."""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
# Note: Anthropic requires special headers, so we'll use the Hanzo Router instead
|
||||
# which handles Anthropic properly
|
||||
@@ -33,7 +34,7 @@ class AnthropicProvider(ModelProvider):
|
||||
base_url="http://localhost:4000/v1", # Use Hanzo Router
|
||||
api_key=os.getenv("HANZO_API_KEY", "sk-1234"),
|
||||
)
|
||||
|
||||
|
||||
def get_model(self, model_name: str | None) -> Model:
|
||||
# Use Claude through the router
|
||||
model = model_name or "claude-3-sonnet-20240229"
|
||||
@@ -42,66 +43,65 @@ class AnthropicProvider(ModelProvider):
|
||||
|
||||
async def test_simple_question():
|
||||
"""Test a simple math question."""
|
||||
|
||||
|
||||
# Create provider
|
||||
provider = AnthropicProvider()
|
||||
|
||||
|
||||
# Create a simple agent
|
||||
agent = Agent(
|
||||
name="MathAssistant",
|
||||
instructions="You are a helpful math assistant. Be concise.",
|
||||
)
|
||||
|
||||
|
||||
print("Testing Hanzo Agent SDK with Anthropic...")
|
||||
print(f"Question: What's 1+2?")
|
||||
print("-" * 40)
|
||||
|
||||
|
||||
try:
|
||||
# Run the agent
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
"What's 1+2?",
|
||||
run_config=RunConfig(
|
||||
model_provider=provider,
|
||||
model="claude-3-sonnet-20240229"
|
||||
)
|
||||
model_provider=provider, model="claude-3-sonnet-20240229"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
print(f"Answer: {result.final_output}")
|
||||
print("-" * 40)
|
||||
print("✅ Test successful!")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
print("\nTroubleshooting:")
|
||||
print("1. Make sure Hanzo Router is running: cd /Users/z/work/hanzo/services && docker compose -f docker-compose.mothership.yml up router")
|
||||
print(
|
||||
"1. Make sure Hanzo Router is running: cd /Users/z/work/hanzo/services && docker compose -f docker-compose.mothership.yml up router"
|
||||
)
|
||||
print("2. Ensure ANTHROPIC_API_KEY is set in your environment")
|
||||
|
||||
|
||||
async def test_direct_anthropic():
|
||||
"""Test direct Anthropic API call (without Agent SDK)."""
|
||||
import aiohttp
|
||||
|
||||
|
||||
print("\nTesting direct Anthropic API...")
|
||||
|
||||
|
||||
headers = {
|
||||
"x-api-key": ANTHROPIC_API_KEY,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
data = {
|
||||
"model": "claude-3-sonnet-20240229",
|
||||
"messages": [{"role": "user", "content": "What's 1+2?"}],
|
||||
"max_tokens": 100
|
||||
"max_tokens": 100,
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers=headers,
|
||||
json=data
|
||||
"https://api.anthropic.com/v1/messages", headers=headers, json=data
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
result = await resp.json()
|
||||
@@ -115,7 +115,7 @@ async def test_direct_anthropic():
|
||||
if __name__ == "__main__":
|
||||
# First try direct API
|
||||
asyncio.run(test_direct_anthropic())
|
||||
|
||||
|
||||
# Then try through Agent SDK
|
||||
print("\n" + "="*50 + "\n")
|
||||
asyncio.run(test_simple_question())
|
||||
print("\n" + "=" * 50 + "\n")
|
||||
asyncio.run(test_simple_question())
|
||||
|
||||
@@ -16,6 +16,7 @@ try:
|
||||
|
||||
OrigWebSearch = getattr(_resp_mod, "ResponseFunctionWebSearch", None)
|
||||
if OrigWebSearch is not None:
|
||||
|
||||
def _compat_web_search(**kwargs):
|
||||
if "action" not in kwargs:
|
||||
kwargs["action"] = {"type": "search", "query": ""}
|
||||
@@ -25,6 +26,7 @@ try:
|
||||
|
||||
OrigCompleted = getattr(_resp_mod, "ResponseCompletedEvent", None)
|
||||
if OrigCompleted is not None:
|
||||
|
||||
def _compat_completed(**kwargs):
|
||||
kwargs.setdefault("sequence_number", 0)
|
||||
return OrigCompleted(**kwargs)
|
||||
@@ -68,7 +70,9 @@ def disable_real_model_clients(monkeypatch, request):
|
||||
# If the test is marked to allow the method call, don't override it.
|
||||
if request.node.get_closest_marker("allow_call_model_methods"):
|
||||
# Provide a harmless default key so providers can instantiate without raising
|
||||
_openai_shared._default_openai_key = _openai_shared._default_openai_key or "sk-dummy"
|
||||
_openai_shared._default_openai_key = (
|
||||
_openai_shared._default_openai_key or "sk-dummy"
|
||||
)
|
||||
return
|
||||
|
||||
def failing_version(*args, **kwargs):
|
||||
@@ -82,16 +86,20 @@ def disable_real_model_clients(monkeypatch, request):
|
||||
# Compatibility shim for older test constructions of ResponseFunctionWebSearch
|
||||
try:
|
||||
import openai.types.responses as _resp_mod
|
||||
|
||||
OrigWebSearch = getattr(_resp_mod, "ResponseFunctionWebSearch", None)
|
||||
|
||||
if OrigWebSearch is not None:
|
||||
|
||||
def _compat_web_search(**kwargs):
|
||||
if "action" not in kwargs:
|
||||
kwargs["action"] = {"type": "search", "query": ""}
|
||||
return OrigWebSearch(**kwargs)
|
||||
|
||||
# Replace the constructor in module so subsequent imports use the shim
|
||||
monkeypatch.setattr(_resp_mod, "ResponseFunctionWebSearch", _compat_web_search)
|
||||
monkeypatch.setattr(
|
||||
_resp_mod, "ResponseFunctionWebSearch", _compat_web_search
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -99,6 +107,7 @@ def disable_real_model_clients(monkeypatch, request):
|
||||
def pytest_runtest_setup(item):
|
||||
# Toggle dummy key allowance depending on the test file
|
||||
import os
|
||||
|
||||
if str(item.fspath).endswith("test_config.py"):
|
||||
if item.name == "test_set_default_openai_api":
|
||||
os.environ["ALLOW_DUMMY_OPENAI_KEY"] = "1"
|
||||
|
||||
@@ -35,7 +35,9 @@ class FakeModel(Model):
|
||||
def set_next_output(self, output: list[TResponseOutputItem] | Exception):
|
||||
self.turn_outputs.append(output)
|
||||
|
||||
def add_multiple_turn_outputs(self, outputs: list[list[TResponseOutputItem] | Exception]):
|
||||
def add_multiple_turn_outputs(
|
||||
self, outputs: list[list[TResponseOutputItem] | Exception]
|
||||
):
|
||||
self.turn_outputs.extend(outputs)
|
||||
|
||||
def get_next_output(self) -> list[TResponseOutputItem] | Exception:
|
||||
@@ -105,7 +107,9 @@ class FakeModel(Model):
|
||||
)
|
||||
|
||||
|
||||
def get_response_obj(output: list[TResponseOutputItem], response_id: str | None = None) -> Response:
|
||||
def get_response_obj(
|
||||
output: list[TResponseOutputItem], response_id: str | None = None
|
||||
) -> Response:
|
||||
return Response(
|
||||
id=response_id or "123",
|
||||
created_at=123,
|
||||
|
||||
@@ -20,7 +20,9 @@ async def test_system_instructions():
|
||||
agent = agent.clone(instructions=sync_instructions)
|
||||
assert await agent.get_system_prompt(context) == "sync_123"
|
||||
|
||||
async def async_instructions(agent: Agent[None], context: RunContextWrapper[None]) -> str:
|
||||
async def async_instructions(
|
||||
agent: Agent[None], context: RunContextWrapper[None]
|
||||
) -> str:
|
||||
return "async_123"
|
||||
|
||||
agent = agent.clone(instructions=async_instructions)
|
||||
|
||||
@@ -30,7 +30,9 @@ class AgentHooksForTests(AgentHooks):
|
||||
def reset(self):
|
||||
self.events.clear()
|
||||
|
||||
async def on_start(self, context: RunContextWrapper[TContext], agent: Agent[TContext]) -> None:
|
||||
async def on_start(
|
||||
self, context: RunContextWrapper[TContext], agent: Agent[TContext]
|
||||
) -> None:
|
||||
self.events["on_start"] += 1
|
||||
|
||||
async def on_end(
|
||||
|
||||
@@ -46,21 +46,30 @@ async def test_simple_first_run():
|
||||
assert result.input == "test"
|
||||
assert len(result.new_items) == 1, "exactly one item should be generated"
|
||||
assert result.final_output == "first"
|
||||
assert len(result.raw_responses) == 1, "exactly one model response should be generated"
|
||||
assert (
|
||||
len(result.raw_responses) == 1
|
||||
), "exactly one model response should be generated"
|
||||
assert result.raw_responses[0].output == [get_text_message("first")]
|
||||
assert result.last_agent == agent
|
||||
|
||||
assert len(result.to_input_list()) == 2, "should have original input and generated item"
|
||||
assert (
|
||||
len(result.to_input_list()) == 2
|
||||
), "should have original input and generated item"
|
||||
|
||||
model.set_next_output([get_text_message("second")])
|
||||
|
||||
result = await Runner.run(
|
||||
agent, input=[get_text_input_item("message"), get_text_input_item("another_message")]
|
||||
agent,
|
||||
input=[get_text_input_item("message"), get_text_input_item("another_message")],
|
||||
)
|
||||
assert len(result.new_items) == 1, "exactly one item should be generated"
|
||||
assert result.final_output == "second"
|
||||
assert len(result.raw_responses) == 1, "exactly one model response should be generated"
|
||||
assert len(result.to_input_list()) == 3, "should have original input and generated item"
|
||||
assert (
|
||||
len(result.raw_responses) == 1
|
||||
), "exactly one model response should be generated"
|
||||
assert (
|
||||
len(result.to_input_list()) == 3
|
||||
), "should have original input and generated item"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -75,7 +84,9 @@ async def test_subsequent_runs():
|
||||
result = await Runner.run(agent, input="test")
|
||||
assert result.input == "test"
|
||||
assert len(result.new_items) == 1, "exactly one item should be generated"
|
||||
assert len(result.to_input_list()) == 2, "should have original input and generated item"
|
||||
assert (
|
||||
len(result.to_input_list()) == 2
|
||||
), "should have original input and generated item"
|
||||
|
||||
model.set_next_output([get_text_message("fourth")])
|
||||
|
||||
@@ -83,10 +94,14 @@ async def test_subsequent_runs():
|
||||
assert len(result.input) == 2, f"should have previous input but got {result.input}"
|
||||
assert len(result.new_items) == 1, "exactly one item should be generated"
|
||||
assert result.final_output == "fourth"
|
||||
assert len(result.raw_responses) == 1, "exactly one model response should be generated"
|
||||
assert (
|
||||
len(result.raw_responses) == 1
|
||||
), "exactly one model response should be generated"
|
||||
assert result.raw_responses[0].output == [get_text_message("fourth")]
|
||||
assert result.last_agent == agent
|
||||
assert len(result.to_input_list()) == 3, "should have original input and generated items"
|
||||
assert (
|
||||
len(result.to_input_list()) == 3
|
||||
), "should have original input and generated items"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -101,7 +116,10 @@ async def test_tool_call_runs():
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
# First turn: a message and tool call
|
||||
[get_text_message("a_message"), get_function_tool_call("foo", json.dumps({"a": "b"}))],
|
||||
[
|
||||
get_text_message("a_message"),
|
||||
get_function_tool_call("foo", json.dumps({"a": "b"})),
|
||||
],
|
||||
# Second turn: text message
|
||||
[get_text_message("done")],
|
||||
]
|
||||
@@ -243,7 +261,11 @@ async def test_handoff_filters():
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
[get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)],
|
||||
[
|
||||
get_text_message("1"),
|
||||
get_text_message("2"),
|
||||
get_handoff_tool_call(agent_1),
|
||||
],
|
||||
[get_text_message("last")],
|
||||
]
|
||||
)
|
||||
@@ -252,9 +274,9 @@ async def test_handoff_filters():
|
||||
|
||||
assert result.final_output == "last"
|
||||
assert len(result.raw_responses) == 2, "should have two model responses"
|
||||
assert len(result.to_input_list()) == 2, (
|
||||
"should only have 2 inputs: orig input and last message"
|
||||
)
|
||||
assert (
|
||||
len(result.to_input_list()) == 2
|
||||
), "should only have 2 inputs: orig input and last message"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -267,7 +289,9 @@ async def test_async_input_filter_fails():
|
||||
model=model,
|
||||
)
|
||||
|
||||
async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]:
|
||||
async def on_invoke_handoff(
|
||||
_ctx: RunContextWrapper[Any], _input: str
|
||||
) -> Agent[Any]:
|
||||
return agent_1
|
||||
|
||||
async def invalid_input_filter(data: HandoffInputData) -> HandoffInputData:
|
||||
@@ -291,7 +315,11 @@ async def test_async_input_filter_fails():
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
[get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)],
|
||||
[
|
||||
get_text_message("1"),
|
||||
get_text_message("2"),
|
||||
get_handoff_tool_call(agent_1),
|
||||
],
|
||||
[get_text_message("last")],
|
||||
]
|
||||
)
|
||||
@@ -308,7 +336,9 @@ async def test_invalid_input_filter_fails():
|
||||
model=model,
|
||||
)
|
||||
|
||||
async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]:
|
||||
async def on_invoke_handoff(
|
||||
_ctx: RunContextWrapper[Any], _input: str
|
||||
) -> Agent[Any]:
|
||||
return agent_1
|
||||
|
||||
def invalid_input_filter(data: HandoffInputData) -> HandoffInputData:
|
||||
@@ -332,7 +362,11 @@ async def test_invalid_input_filter_fails():
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
[get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)],
|
||||
[
|
||||
get_text_message("1"),
|
||||
get_text_message("2"),
|
||||
get_handoff_tool_call(agent_1),
|
||||
],
|
||||
[get_text_message("last")],
|
||||
]
|
||||
)
|
||||
@@ -349,7 +383,9 @@ async def test_non_callable_input_filter_causes_error():
|
||||
model=model,
|
||||
)
|
||||
|
||||
async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]:
|
||||
async def on_invoke_handoff(
|
||||
_ctx: RunContextWrapper[Any], _input: str
|
||||
) -> Agent[Any]:
|
||||
return agent_1
|
||||
|
||||
agent_2 = Agent[None](
|
||||
@@ -370,7 +406,11 @@ async def test_non_callable_input_filter_causes_error():
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
[get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)],
|
||||
[
|
||||
get_text_message("1"),
|
||||
get_text_message("2"),
|
||||
get_handoff_tool_call(agent_1),
|
||||
],
|
||||
[get_text_message("last")],
|
||||
]
|
||||
)
|
||||
@@ -420,7 +460,9 @@ async def test_handoff_on_input():
|
||||
|
||||
assert result.final_output == "last"
|
||||
|
||||
assert call_output == "test_input", "should have called the handoff with the correct input"
|
||||
assert (
|
||||
call_output == "test_input"
|
||||
), "should have called the handoff with the correct input"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -464,7 +506,9 @@ async def test_async_handoff_on_input():
|
||||
|
||||
assert result.final_output == "last"
|
||||
|
||||
assert call_output == "test_input", "should have called the handoff with the correct input"
|
||||
assert (
|
||||
call_output == "test_input"
|
||||
), "should have called the handoff with the correct input"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -473,7 +517,9 @@ async def test_wrong_params_on_input_causes_error():
|
||||
name="test",
|
||||
)
|
||||
|
||||
def _on_handoff_too_many_params(ctx: RunContextWrapper[Any], foo: Foo, bar: str) -> None:
|
||||
def _on_handoff_too_many_params(
|
||||
ctx: RunContextWrapper[Any], foo: Foo, bar: str
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(UserError):
|
||||
@@ -523,7 +569,8 @@ async def test_input_guardrail_tripwire_triggered_causes_exception():
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="test", input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)]
|
||||
name="test",
|
||||
input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)],
|
||||
)
|
||||
model = FakeModel()
|
||||
model.set_next_output([get_text_message("user_message")])
|
||||
|
||||
@@ -51,24 +51,33 @@ async def test_simple_first_run():
|
||||
assert result.input == "test"
|
||||
assert len(result.new_items) == 1, "exactly one item should be generated"
|
||||
assert result.final_output == "first"
|
||||
assert len(result.raw_responses) == 1, "exactly one model response should be generated"
|
||||
assert (
|
||||
len(result.raw_responses) == 1
|
||||
), "exactly one model response should be generated"
|
||||
assert result.raw_responses[0].output == [get_text_message("first")]
|
||||
assert result.last_agent == agent
|
||||
|
||||
assert len(result.to_input_list()) == 2, "should have original input and generated item"
|
||||
assert (
|
||||
len(result.to_input_list()) == 2
|
||||
), "should have original input and generated item"
|
||||
|
||||
model.set_next_output([get_text_message("second")])
|
||||
|
||||
result = Runner.run_streamed(
|
||||
agent, input=[get_text_input_item("message"), get_text_input_item("another_message")]
|
||||
agent,
|
||||
input=[get_text_input_item("message"), get_text_input_item("another_message")],
|
||||
)
|
||||
async for _ in result.stream_events():
|
||||
pass
|
||||
|
||||
assert len(result.new_items) == 1, "exactly one item should be generated"
|
||||
assert result.final_output == "second"
|
||||
assert len(result.raw_responses) == 1, "exactly one model response should be generated"
|
||||
assert len(result.to_input_list()) == 3, "should have original input and generated item"
|
||||
assert (
|
||||
len(result.raw_responses) == 1
|
||||
), "exactly one model response should be generated"
|
||||
assert (
|
||||
len(result.to_input_list()) == 3
|
||||
), "should have original input and generated item"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -86,7 +95,9 @@ async def test_subsequent_runs():
|
||||
|
||||
assert result.input == "test"
|
||||
assert len(result.new_items) == 1, "exactly one item should be generated"
|
||||
assert len(result.to_input_list()) == 2, "should have original input and generated item"
|
||||
assert (
|
||||
len(result.to_input_list()) == 2
|
||||
), "should have original input and generated item"
|
||||
|
||||
model.set_next_output([get_text_message("fourth")])
|
||||
|
||||
@@ -97,10 +108,14 @@ async def test_subsequent_runs():
|
||||
assert len(result.input) == 2, f"should have previous input but got {result.input}"
|
||||
assert len(result.new_items) == 1, "exactly one item should be generated"
|
||||
assert result.final_output == "fourth"
|
||||
assert len(result.raw_responses) == 1, "exactly one model response should be generated"
|
||||
assert (
|
||||
len(result.raw_responses) == 1
|
||||
), "exactly one model response should be generated"
|
||||
assert result.raw_responses[0].output == [get_text_message("fourth")]
|
||||
assert result.last_agent == agent
|
||||
assert len(result.to_input_list()) == 3, "should have original input and generated items"
|
||||
assert (
|
||||
len(result.to_input_list()) == 3
|
||||
), "should have original input and generated items"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -115,7 +130,10 @@ async def test_tool_call_runs():
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
# First turn: a message and tool call
|
||||
[get_text_message("a_message"), get_function_tool_call("foo", json.dumps({"a": "b"}))],
|
||||
[
|
||||
get_text_message("a_message"),
|
||||
get_function_tool_call("foo", json.dumps({"a": "b"})),
|
||||
],
|
||||
# Second turn: text message
|
||||
[get_text_message("done")],
|
||||
]
|
||||
@@ -263,7 +281,11 @@ async def test_handoff_filters():
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
[get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)],
|
||||
[
|
||||
get_text_message("1"),
|
||||
get_text_message("2"),
|
||||
get_handoff_tool_call(agent_1),
|
||||
],
|
||||
[get_text_message("last")],
|
||||
]
|
||||
)
|
||||
@@ -274,9 +296,9 @@ async def test_handoff_filters():
|
||||
|
||||
assert result.final_output == "last"
|
||||
assert len(result.raw_responses) == 2, "should have two model responses"
|
||||
assert len(result.to_input_list()) == 2, (
|
||||
"should only have 2 inputs: orig input and last message"
|
||||
)
|
||||
assert (
|
||||
len(result.to_input_list()) == 2
|
||||
), "should only have 2 inputs: orig input and last message"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -289,7 +311,9 @@ async def test_async_input_filter_fails():
|
||||
model=model,
|
||||
)
|
||||
|
||||
async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]:
|
||||
async def on_invoke_handoff(
|
||||
_ctx: RunContextWrapper[Any], _input: str
|
||||
) -> Agent[Any]:
|
||||
return agent_1
|
||||
|
||||
async def invalid_input_filter(data: HandoffInputData) -> HandoffInputData:
|
||||
@@ -313,7 +337,11 @@ async def test_async_input_filter_fails():
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
[get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)],
|
||||
[
|
||||
get_text_message("1"),
|
||||
get_text_message("2"),
|
||||
get_handoff_tool_call(agent_1),
|
||||
],
|
||||
[get_text_message("last")],
|
||||
]
|
||||
)
|
||||
@@ -332,7 +360,9 @@ async def test_invalid_input_filter_fails():
|
||||
model=model,
|
||||
)
|
||||
|
||||
async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]:
|
||||
async def on_invoke_handoff(
|
||||
_ctx: RunContextWrapper[Any], _input: str
|
||||
) -> Agent[Any]:
|
||||
return agent_1
|
||||
|
||||
def invalid_input_filter(data: HandoffInputData) -> HandoffInputData:
|
||||
@@ -356,7 +386,11 @@ async def test_invalid_input_filter_fails():
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
[get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)],
|
||||
[
|
||||
get_text_message("1"),
|
||||
get_text_message("2"),
|
||||
get_handoff_tool_call(agent_1),
|
||||
],
|
||||
[get_text_message("last")],
|
||||
]
|
||||
)
|
||||
@@ -375,7 +409,9 @@ async def test_non_callable_input_filter_causes_error():
|
||||
model=model,
|
||||
)
|
||||
|
||||
async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]:
|
||||
async def on_invoke_handoff(
|
||||
_ctx: RunContextWrapper[Any], _input: str
|
||||
) -> Agent[Any]:
|
||||
return agent_1
|
||||
|
||||
agent_2 = Agent[None](
|
||||
@@ -396,7 +432,11 @@ async def test_non_callable_input_filter_causes_error():
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
[get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)],
|
||||
[
|
||||
get_text_message("1"),
|
||||
get_text_message("2"),
|
||||
get_handoff_tool_call(agent_1),
|
||||
],
|
||||
[get_text_message("last")],
|
||||
]
|
||||
)
|
||||
@@ -450,7 +490,9 @@ async def test_handoff_on_input():
|
||||
|
||||
assert result.final_output == "last"
|
||||
|
||||
assert call_output == "test_input", "should have called the handoff with the correct input"
|
||||
assert (
|
||||
call_output == "test_input"
|
||||
), "should have called the handoff with the correct input"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -496,7 +538,9 @@ async def test_async_handoff_on_input():
|
||||
|
||||
assert result.final_output == "last"
|
||||
|
||||
assert call_output == "test_input", "should have called the handoff with the correct input"
|
||||
assert (
|
||||
call_output == "test_input"
|
||||
), "should have called the handoff with the correct input"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -594,7 +638,9 @@ async def test_run_output_guardrail_tripwire_triggered_causes_exception_streamed
|
||||
agent,
|
||||
input="user_message",
|
||||
run_config=RunConfig(
|
||||
output_guardrails=[OutputGuardrail(guardrail_function=guardrail_function)]
|
||||
output_guardrails=[
|
||||
OutputGuardrail(guardrail_function=guardrail_function)
|
||||
]
|
||||
),
|
||||
)
|
||||
async for _ in result.stream_events():
|
||||
@@ -678,9 +724,9 @@ async def test_streaming_events():
|
||||
f"Expected events were: {expected_item_type_map}, got {event_counts}"
|
||||
)
|
||||
|
||||
assert len(item_data) == total_expected_item_count, (
|
||||
f"should have {total_expected_item_count} run items"
|
||||
)
|
||||
assert (
|
||||
len(item_data) == total_expected_item_count
|
||||
), f"should have {total_expected_item_count} run items"
|
||||
assert len(agent_data) == 2, "should have 2 agent updated events"
|
||||
assert agent_data[0].new_agent == agent_2, "should have started with agent_2"
|
||||
assert agent_data[1].new_agent == agent_1, "should have handed off to agent_1"
|
||||
|
||||
@@ -98,9 +98,9 @@ async def test_parent_disabled_trace_disabled_agent_trace():
|
||||
traces = fetch_traces()
|
||||
assert len(traces) == 0, f"Expected 0 traces, got {len(traces)}"
|
||||
spans = fetch_ordered_spans()
|
||||
assert len(spans) == 0, (
|
||||
f"Expected no spans, got {len(spans)}, with {[x.span_data for x in spans]}"
|
||||
)
|
||||
assert (
|
||||
len(spans) == 0
|
||||
), f"Expected no spans, got {len(spans)}, with {[x.span_data for x in spans]}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -112,7 +112,9 @@ async def test_manual_disabling_works():
|
||||
),
|
||||
)
|
||||
|
||||
await Runner.run(agent, input="first_test", run_config=RunConfig(tracing_disabled=True))
|
||||
await Runner.run(
|
||||
agent, input="first_test", run_config=RunConfig(tracing_disabled=True)
|
||||
)
|
||||
|
||||
traces = fetch_traces()
|
||||
assert len(traces) == 0, f"Expected 0 traces, got {len(traces)}"
|
||||
@@ -314,7 +316,9 @@ async def test_manual_streaming_disabling_works():
|
||||
model=model,
|
||||
)
|
||||
|
||||
x = Runner.run_streamed(agent, input="first_test", run_config=RunConfig(tracing_disabled=True))
|
||||
x = Runner.run_streamed(
|
||||
agent, input="first_test", run_config=RunConfig(tracing_disabled=True)
|
||||
)
|
||||
async for _ in x.stream_events():
|
||||
pass
|
||||
|
||||
|
||||
@@ -11,12 +11,17 @@ import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
# Add agent SDK to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
import pytest
|
||||
from agents import Agent, Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.network import create_network, SemanticRouter, RuleBasedRouter, LoadBalancingRouter
|
||||
from agents.network import (
|
||||
create_network,
|
||||
SemanticRouter,
|
||||
RuleBasedRouter,
|
||||
LoadBalancingRouter,
|
||||
)
|
||||
from agents.state import InMemoryStateStore, FileStateStore
|
||||
from agents.memory import Memory, MemoryEntry, MemoryType
|
||||
from agents.orchestration import Workflow, Step
|
||||
@@ -31,7 +36,7 @@ def simple_agent():
|
||||
return Agent(
|
||||
name="TestAgent",
|
||||
instructions="You are a helpful test assistant.",
|
||||
model="gpt-3.5-turbo"
|
||||
model="gpt-3.5-turbo",
|
||||
)
|
||||
|
||||
|
||||
@@ -41,7 +46,7 @@ def math_agent():
|
||||
return Agent(
|
||||
name="MathExpert",
|
||||
instructions="You are a math expert. Answer math questions concisely.",
|
||||
model="gpt-3.5-turbo"
|
||||
model="gpt-3.5-turbo",
|
||||
)
|
||||
|
||||
|
||||
@@ -51,73 +56,63 @@ def science_agent():
|
||||
return Agent(
|
||||
name="ScienceExpert",
|
||||
instructions="You are a science expert. Answer science questions concisely.",
|
||||
model="gpt-3.5-turbo"
|
||||
model="gpt-3.5-turbo",
|
||||
)
|
||||
|
||||
|
||||
# Test 1: Multi-Agent Networks
|
||||
class TestMultiAgentNetworks:
|
||||
"""Test multi-agent network functionality."""
|
||||
|
||||
|
||||
def test_create_network(self, math_agent, science_agent):
|
||||
"""Test creating a basic network."""
|
||||
network = create_network(
|
||||
agents=[math_agent, science_agent],
|
||||
name="Test Network"
|
||||
agents=[math_agent, science_agent], name="Test Network"
|
||||
)
|
||||
|
||||
|
||||
assert network.config.name == "Test Network"
|
||||
assert len(network.nodes) == 2
|
||||
assert "MathExpert" in network.nodes
|
||||
assert "ScienceExpert" in network.nodes
|
||||
|
||||
|
||||
def test_semantic_router(self, math_agent, science_agent):
|
||||
"""Test semantic routing between agents."""
|
||||
router = SemanticRouter()
|
||||
network = create_network(
|
||||
agents=[math_agent, science_agent],
|
||||
router=router
|
||||
)
|
||||
|
||||
network = create_network(agents=[math_agent, science_agent], router=router)
|
||||
|
||||
assert isinstance(network.router, SemanticRouter)
|
||||
|
||||
|
||||
def test_rule_based_router(self, math_agent, science_agent):
|
||||
"""Test rule-based routing."""
|
||||
router = RuleBasedRouter()
|
||||
|
||||
|
||||
# Add routing rules
|
||||
router.add_rule(r".*math.*|.*calculate.*|.*number.*", "MathExpert")
|
||||
router.add_rule(r".*science.*|.*chemistry.*|.*physics.*", "ScienceExpert")
|
||||
|
||||
network = create_network(
|
||||
agents=[math_agent, science_agent],
|
||||
router=router
|
||||
)
|
||||
|
||||
|
||||
network = create_network(agents=[math_agent, science_agent], router=router)
|
||||
|
||||
assert isinstance(network.router, RuleBasedRouter)
|
||||
assert len(router.rules) == 2
|
||||
|
||||
|
||||
def test_load_balancing_router(self, math_agent, science_agent):
|
||||
"""Test load balancing router."""
|
||||
router = LoadBalancingRouter()
|
||||
network = create_network(
|
||||
agents=[math_agent, science_agent],
|
||||
router=router
|
||||
)
|
||||
|
||||
network = create_network(agents=[math_agent, science_agent], router=router)
|
||||
|
||||
assert isinstance(network.router, LoadBalancingRouter)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_agent_addition(self):
|
||||
"""Test adding agents to network dynamically."""
|
||||
network = create_network(agents=[])
|
||||
|
||||
|
||||
agent1 = Agent(name="Agent1", instructions="First agent")
|
||||
agent2 = Agent(name="Agent2", instructions="Second agent")
|
||||
|
||||
|
||||
node1 = network.add_agent(agent1, capabilities=["task1", "task2"])
|
||||
node2 = network.add_agent(agent2, capabilities=["task3", "task4"])
|
||||
|
||||
|
||||
assert len(network.nodes) == 2
|
||||
assert node1.capabilities == ["task1", "task2"]
|
||||
assert node2.capabilities == ["task3", "task4"]
|
||||
@@ -126,60 +121,51 @@ class TestMultiAgentNetworks:
|
||||
# Test 2: State Management
|
||||
class TestStateManagement:
|
||||
"""Test state management across agents."""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_in_memory_state_store(self):
|
||||
"""Test in-memory state store."""
|
||||
store = InMemoryStateStore()
|
||||
|
||||
|
||||
# Test basic operations
|
||||
await store.set("key1", "value1")
|
||||
assert await store.get("key1") == "value1"
|
||||
|
||||
|
||||
# Test namespaces
|
||||
await store.set("key2", "value2", namespace="ns1")
|
||||
assert await store.get("key2", namespace="ns1") == "value2"
|
||||
assert await store.get("key2") is None # Different namespace
|
||||
|
||||
|
||||
# Test update
|
||||
result = await store.update(
|
||||
"counter",
|
||||
lambda x: (x or 0) + 1
|
||||
)
|
||||
result = await store.update("counter", lambda x: (x or 0) + 1)
|
||||
assert result == 1
|
||||
|
||||
result = await store.update(
|
||||
"counter",
|
||||
lambda x: (x or 0) + 1
|
||||
)
|
||||
|
||||
result = await store.update("counter", lambda x: (x or 0) + 1)
|
||||
assert result == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_state_store(self, tmp_path):
|
||||
"""Test file-based state store."""
|
||||
store_path = tmp_path / "state_store.json"
|
||||
store = FileStateStore(str(store_path))
|
||||
|
||||
|
||||
# Test persistence
|
||||
await store.set("persistent_key", {"data": "test"})
|
||||
|
||||
|
||||
# Create new store instance
|
||||
store2 = FileStateStore(str(store_path))
|
||||
value = await store2.get("persistent_key")
|
||||
assert value == {"data": "test"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_sharing_in_network(self, math_agent, science_agent):
|
||||
"""Test state sharing between agents in a network."""
|
||||
store = InMemoryStateStore()
|
||||
network = create_network(
|
||||
agents=[math_agent, science_agent],
|
||||
state_store=store
|
||||
)
|
||||
|
||||
network = create_network(agents=[math_agent, science_agent], state_store=store)
|
||||
|
||||
# Set state from outside
|
||||
await store.set("shared_data", {"experiment": "quantum"})
|
||||
|
||||
|
||||
# Verify network has access to the store
|
||||
assert network.state_store is store
|
||||
data = await network.state_store.get("shared_data")
|
||||
@@ -189,7 +175,7 @@ class TestStateManagement:
|
||||
# Test 3: Memory System
|
||||
class TestMemorySystem:
|
||||
"""Test memory system with vector search."""
|
||||
|
||||
|
||||
def test_memory_entry_creation(self):
|
||||
"""Test creating memory entries."""
|
||||
entry = MemoryEntry(
|
||||
@@ -197,64 +183,61 @@ class TestMemorySystem:
|
||||
type=MemoryType.FACT,
|
||||
content="The Earth orbits the Sun",
|
||||
metadata={"category": "astronomy"},
|
||||
importance=0.9
|
||||
importance=0.9,
|
||||
)
|
||||
|
||||
|
||||
assert entry.id == "mem1"
|
||||
assert entry.type == MemoryType.FACT
|
||||
assert entry.importance == 0.9
|
||||
|
||||
|
||||
# Test serialization
|
||||
data = entry.to_dict()
|
||||
assert data["type"] == "fact"
|
||||
assert data["content"] == "The Earth orbits the Sun"
|
||||
|
||||
|
||||
# Test deserialization
|
||||
entry2 = MemoryEntry.from_dict(data)
|
||||
assert entry2.id == entry.id
|
||||
assert entry2.content == entry.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_operations(self):
|
||||
"""Test memory storage and retrieval."""
|
||||
memory = Memory(max_entries=10)
|
||||
|
||||
|
||||
# Add memories
|
||||
entry1 = await memory.add(
|
||||
content="Python is a programming language",
|
||||
type=MemoryType.FACT,
|
||||
metadata={"topic": "programming"}
|
||||
metadata={"topic": "programming"},
|
||||
)
|
||||
|
||||
|
||||
entry2 = await memory.add(
|
||||
content="The user asked about Python",
|
||||
type=MemoryType.CONVERSATION,
|
||||
metadata={"timestamp": time.time()}
|
||||
metadata={"timestamp": time.time()},
|
||||
)
|
||||
|
||||
|
||||
assert len(await memory.get_all()) == 2
|
||||
|
||||
|
||||
# Test search
|
||||
results = await memory.search("Python", limit=1)
|
||||
assert len(results) == 1
|
||||
assert "Python" in results[0].content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_compression(self):
|
||||
"""Test memory compression when limit is reached."""
|
||||
memory = Memory(max_entries=3, auto_compress=True, compress_threshold=2)
|
||||
|
||||
|
||||
# Add memories beyond limit
|
||||
for i in range(5):
|
||||
await memory.add(
|
||||
content=f"Memory {i}",
|
||||
type=MemoryType.CONVERSATION
|
||||
)
|
||||
|
||||
await memory.add(content=f"Memory {i}", type=MemoryType.CONVERSATION)
|
||||
|
||||
# Should compress old memories
|
||||
all_memories = await memory.get_all()
|
||||
assert len(all_memories) <= 3
|
||||
|
||||
|
||||
def test_memory_types(self):
|
||||
"""Test different memory types."""
|
||||
types = [
|
||||
@@ -266,14 +249,14 @@ class TestMemorySystem:
|
||||
MemoryType.CONVERSATION,
|
||||
MemoryType.FACT,
|
||||
MemoryType.PROCEDURE,
|
||||
MemoryType.REFLECTION
|
||||
MemoryType.REFLECTION,
|
||||
]
|
||||
|
||||
|
||||
for mem_type in types:
|
||||
entry = MemoryEntry(
|
||||
id=f"test_{mem_type.value}",
|
||||
type=mem_type,
|
||||
content=f"Test content for {mem_type.value}"
|
||||
content=f"Test content for {mem_type.value}",
|
||||
)
|
||||
assert entry.type == mem_type
|
||||
|
||||
@@ -281,117 +264,112 @@ class TestMemorySystem:
|
||||
# Test 4: Workflow Orchestration
|
||||
class TestWorkflowOrchestration:
|
||||
"""Test workflow orchestration features."""
|
||||
|
||||
|
||||
def test_workflow_creation(self):
|
||||
"""Test creating workflows."""
|
||||
workflow = Workflow(
|
||||
name="Test Workflow",
|
||||
description="A test workflow"
|
||||
)
|
||||
|
||||
workflow = Workflow(name="Test Workflow", description="A test workflow")
|
||||
|
||||
# Add steps
|
||||
step1 = workflow.add_step(Step.agent("Agent1", "Do task 1"))
|
||||
step2 = workflow.add_step(Step.agent("Agent2", "Do task 2"))
|
||||
|
||||
|
||||
assert len(workflow.steps) == 2
|
||||
assert workflow.entry_point == step1.id
|
||||
|
||||
|
||||
def test_parallel_steps(self):
|
||||
"""Test parallel workflow steps."""
|
||||
workflow = Workflow(name="Parallel Test")
|
||||
|
||||
|
||||
# Create parallel steps
|
||||
parallel_step = Step.parallel([
|
||||
Step.agent("Agent1", "Task 1"),
|
||||
Step.agent("Agent2", "Task 2"),
|
||||
Step.agent("Agent3", "Task 3")
|
||||
])
|
||||
|
||||
parallel_step = Step.parallel(
|
||||
[
|
||||
Step.agent("Agent1", "Task 1"),
|
||||
Step.agent("Agent2", "Task 2"),
|
||||
Step.agent("Agent3", "Task 3"),
|
||||
]
|
||||
)
|
||||
|
||||
workflow.add_step(parallel_step)
|
||||
assert parallel_step.type.value == "parallel"
|
||||
assert len(parallel_step.config["steps"]) == 3
|
||||
|
||||
|
||||
def test_conditional_steps(self):
|
||||
"""Test conditional workflow steps."""
|
||||
workflow = Workflow(name="Conditional Test")
|
||||
|
||||
|
||||
# Create conditional step
|
||||
def check_condition(data):
|
||||
return data.get("value", 0) > 5
|
||||
|
||||
|
||||
true_step = Step.agent("HighValueAgent", "Handle high value")
|
||||
false_step = Step.agent("LowValueAgent", "Handle low value")
|
||||
|
||||
|
||||
conditional = Step.conditional(
|
||||
condition=check_condition,
|
||||
if_true=true_step,
|
||||
if_false=false_step
|
||||
condition=check_condition, if_true=true_step, if_false=false_step
|
||||
)
|
||||
|
||||
|
||||
workflow.add_step(conditional)
|
||||
assert conditional.type.value == "conditional"
|
||||
|
||||
|
||||
def test_loop_steps(self):
|
||||
"""Test loop workflow steps."""
|
||||
workflow = Workflow(name="Loop Test")
|
||||
|
||||
|
||||
# Create loop step
|
||||
body_step = Step.agent("ProcessAgent", "Process item")
|
||||
|
||||
|
||||
loop = Step.loop(
|
||||
over="items", # Path to array in context
|
||||
body=body_step,
|
||||
max_iterations=10
|
||||
over="items", body=body_step, max_iterations=10 # Path to array in context
|
||||
)
|
||||
|
||||
|
||||
workflow.add_step(loop)
|
||||
assert loop.type.value == "loop"
|
||||
assert loop.config["max_iterations"] == 10
|
||||
|
||||
|
||||
def test_workflow_validation(self):
|
||||
"""Test workflow validation."""
|
||||
workflow = Workflow(name="Validation Test")
|
||||
|
||||
|
||||
# Empty workflow should have validation errors
|
||||
errors = workflow.validate()
|
||||
assert len(errors) > 0
|
||||
assert "No entry point defined" in errors
|
||||
|
||||
|
||||
# Add steps and validate
|
||||
workflow.add_step(Step.agent("Agent1", "Task"))
|
||||
errors = workflow.validate()
|
||||
assert len(errors) == 0
|
||||
|
||||
|
||||
def test_workflow_execution_order(self):
|
||||
"""Test workflow execution order calculation."""
|
||||
workflow = Workflow(name="Order Test")
|
||||
|
||||
|
||||
step1 = workflow.add_step(Step.agent("Agent1", "Task 1"))
|
||||
step2 = workflow.add_step(Step.agent("Agent2", "Task 2"))
|
||||
step3 = workflow.add_step(Step.agent("Agent3", "Task 3"))
|
||||
|
||||
|
||||
# Add dependencies
|
||||
step2.depends_on = [step1.id]
|
||||
step3.depends_on = [step1.id]
|
||||
|
||||
|
||||
# Get execution order
|
||||
order = workflow.get_execution_order()
|
||||
|
||||
|
||||
# Step 1 should be first, steps 2 and 3 can be parallel
|
||||
assert len(order) == 2
|
||||
assert step1.id in order[0]
|
||||
assert step2.id in order[1] and step3.id in order[1]
|
||||
|
||||
|
||||
def test_workflow_serialization(self):
|
||||
"""Test workflow serialization."""
|
||||
workflow = Workflow(name="Serialization Test")
|
||||
workflow.add_step(Step.agent("TestAgent", "Test task"))
|
||||
|
||||
|
||||
# Convert to dict
|
||||
data = workflow.to_dict()
|
||||
assert data["name"] == "Serialization Test"
|
||||
assert len(data["steps"]) == 1
|
||||
|
||||
|
||||
# Convert to Mermaid diagram
|
||||
mermaid = workflow.to_mermaid()
|
||||
assert "graph TD" in mermaid
|
||||
@@ -401,48 +379,48 @@ class TestWorkflowOrchestration:
|
||||
# Test 5: Tool Integration
|
||||
class TestToolIntegration:
|
||||
"""Test tool integration with agents."""
|
||||
|
||||
|
||||
def test_tool_creation(self):
|
||||
"""Test creating tools for agents."""
|
||||
|
||||
|
||||
@tool
|
||||
def calculate_sum(a: int, b: int) -> int:
|
||||
"""Calculate the sum of two numbers.
|
||||
|
||||
|
||||
Args:
|
||||
a: First number
|
||||
b: Second number
|
||||
|
||||
|
||||
Returns:
|
||||
The sum of a and b
|
||||
"""
|
||||
return a + b
|
||||
|
||||
|
||||
agent = Agent(
|
||||
name="CalculatorAgent",
|
||||
instructions="You can calculate sums.",
|
||||
tools=[calculate_sum]
|
||||
tools=[calculate_sum],
|
||||
)
|
||||
|
||||
|
||||
assert len(agent.tools) == 1
|
||||
assert agent.tools[0].name == "calculate_sum"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_with_tools(self):
|
||||
"""Test agent using tools."""
|
||||
|
||||
|
||||
@tool
|
||||
def get_current_time() -> str:
|
||||
"""Get the current time."""
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
agent = Agent(
|
||||
name="TimeAgent",
|
||||
instructions="You can tell the time.",
|
||||
tools=[get_current_time],
|
||||
model="gpt-3.5-turbo"
|
||||
model="gpt-3.5-turbo",
|
||||
)
|
||||
|
||||
|
||||
# This would require actual API call
|
||||
# Just verify agent is configured correctly
|
||||
assert agent.tools[0].name == "get_current_time"
|
||||
@@ -451,7 +429,7 @@ class TestToolIntegration:
|
||||
# Test 6: Error Handling
|
||||
class TestErrorHandling:
|
||||
"""Test error handling and edge cases."""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_turns_exceeded(self, simple_agent):
|
||||
"""Test max turns limit."""
|
||||
@@ -459,107 +437,97 @@ class TestErrorHandling:
|
||||
# Just test the configuration
|
||||
config = RunConfig(model="gpt-3.5-turbo")
|
||||
assert config.model == "gpt-3.5-turbo"
|
||||
|
||||
|
||||
def test_invalid_workflow(self):
|
||||
"""Test invalid workflow configurations."""
|
||||
workflow = Workflow(name="Invalid Test")
|
||||
|
||||
|
||||
# Add circular dependency
|
||||
step1 = workflow.add_step(Step.agent("Agent1", "Task 1"))
|
||||
step2 = workflow.add_step(Step.agent("Agent2", "Task 2"))
|
||||
|
||||
|
||||
step1.depends_on = [step2.id]
|
||||
step2.depends_on = [step1.id]
|
||||
|
||||
|
||||
# Should detect circular dependency
|
||||
with pytest.raises(Exception):
|
||||
workflow.get_execution_order()
|
||||
|
||||
|
||||
def test_network_with_no_agents(self):
|
||||
"""Test network with no agents."""
|
||||
network = create_network(agents=[])
|
||||
assert len(network.nodes) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_store_error_handling(self):
|
||||
"""Test state store error handling."""
|
||||
store = InMemoryStateStore()
|
||||
|
||||
|
||||
# Test getting non-existent key
|
||||
value = await store.get("non_existent")
|
||||
assert value is None
|
||||
|
||||
|
||||
# Test update on non-existent key
|
||||
result = await store.update(
|
||||
"new_key",
|
||||
lambda x: (x or 0) + 1
|
||||
)
|
||||
result = await store.update("new_key", lambda x: (x or 0) + 1)
|
||||
assert result == 1
|
||||
|
||||
|
||||
# Test 7: Integration Tests
|
||||
class TestIntegration:
|
||||
"""Integration tests combining multiple features."""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_with_state_and_memory(self, math_agent, science_agent):
|
||||
"""Test network with both state and memory."""
|
||||
# Create shared state
|
||||
state_store = InMemoryStateStore()
|
||||
await state_store.set("experiment_count", 0)
|
||||
|
||||
|
||||
# Create network
|
||||
network = create_network(
|
||||
agents=[math_agent, science_agent],
|
||||
state_store=state_store
|
||||
agents=[math_agent, science_agent], state_store=state_store
|
||||
)
|
||||
|
||||
|
||||
# Create memory for each agent
|
||||
math_memory = Memory()
|
||||
science_memory = Memory()
|
||||
|
||||
|
||||
# Add some memories
|
||||
await math_memory.add(
|
||||
content="User prefers simple explanations",
|
||||
type=MemoryType.FACT
|
||||
content="User prefers simple explanations", type=MemoryType.FACT
|
||||
)
|
||||
|
||||
|
||||
await science_memory.add(
|
||||
content="User is interested in physics",
|
||||
type=MemoryType.FACT
|
||||
content="User is interested in physics", type=MemoryType.FACT
|
||||
)
|
||||
|
||||
|
||||
# Verify integration
|
||||
assert network.state_store is state_store
|
||||
assert len(await math_memory.get_all()) == 1
|
||||
assert len(await science_memory.get_all()) == 1
|
||||
|
||||
|
||||
def test_workflow_with_network(self, math_agent, science_agent):
|
||||
"""Test workflow using network agents."""
|
||||
# Create network
|
||||
network = create_network(
|
||||
agents=[math_agent, science_agent]
|
||||
)
|
||||
|
||||
network = create_network(agents=[math_agent, science_agent])
|
||||
|
||||
# Create workflow
|
||||
workflow = Workflow(name="Math and Science Workflow")
|
||||
|
||||
|
||||
# Add steps using network agents
|
||||
math_step = workflow.add_step(
|
||||
Step.agent("MathExpert", "Calculate 2+2")
|
||||
)
|
||||
|
||||
math_step = workflow.add_step(Step.agent("MathExpert", "Calculate 2+2"))
|
||||
|
||||
science_step = workflow.add_step(
|
||||
Step.agent("ScienceExpert", "Explain photosynthesis")
|
||||
)
|
||||
|
||||
|
||||
# Make science step depend on math step
|
||||
science_step.depends_on = [math_step.id]
|
||||
|
||||
|
||||
# Validate workflow
|
||||
errors = workflow.validate()
|
||||
assert len(errors) == 0
|
||||
|
||||
|
||||
# Check execution order
|
||||
order = workflow.get_execution_order()
|
||||
assert len(order) == 2
|
||||
@@ -571,6 +539,6 @@ class TestIntegration:
|
||||
if __name__ == "__main__":
|
||||
print("🧪 Running Hanzo Agent SDK Tests")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
# Run tests with pytest
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
|
||||
@@ -127,10 +127,18 @@ class LoggingAsyncComputer(AsyncComputer):
|
||||
@pytest.mark.parametrize(
|
||||
"action,expected_call",
|
||||
[
|
||||
(ActionClick(type="click", x=10, y=21, button="left"), ("click", (10, 21, "left"))),
|
||||
(ActionDoubleClick(type="double_click", x=42, y=47), ("double_click", (42, 47))),
|
||||
(
|
||||
ActionDrag(type="drag", path=[ActionDragPath(x=1, y=2), ActionDragPath(x=3, y=4)]),
|
||||
ActionClick(type="click", x=10, y=21, button="left"),
|
||||
("click", (10, 21, "left")),
|
||||
),
|
||||
(
|
||||
ActionDoubleClick(type="double_click", x=42, y=47),
|
||||
("double_click", (42, 47)),
|
||||
),
|
||||
(
|
||||
ActionDrag(
|
||||
type="drag", path=[ActionDragPath(x=1, y=2), ActionDragPath(x=3, y=4)]
|
||||
),
|
||||
("drag", (((1, 2), (3, 4)),)),
|
||||
),
|
||||
(ActionKeypress(type="keypress", keys=["a", "b"]), ("keypress", (["a", "b"],))),
|
||||
@@ -172,13 +180,24 @@ async def test_get_screenshot_sync_executes_action_and_takes_screenshot(
|
||||
@pytest.mark.parametrize(
|
||||
"action,expected_call",
|
||||
[
|
||||
(ActionClick(type="click", x=2, y=3, button="right"), ("click", (2, 3, "right"))),
|
||||
(ActionDoubleClick(type="double_click", x=12, y=13), ("double_click", (12, 13))),
|
||||
(
|
||||
ActionDrag(type="drag", path=[ActionDragPath(x=5, y=6), ActionDragPath(x=6, y=7)]),
|
||||
ActionClick(type="click", x=2, y=3, button="right"),
|
||||
("click", (2, 3, "right")),
|
||||
),
|
||||
(
|
||||
ActionDoubleClick(type="double_click", x=12, y=13),
|
||||
("double_click", (12, 13)),
|
||||
),
|
||||
(
|
||||
ActionDrag(
|
||||
type="drag", path=[ActionDragPath(x=5, y=6), ActionDragPath(x=6, y=7)]
|
||||
),
|
||||
("drag", (((5, 6), (6, 7)),)),
|
||||
),
|
||||
(ActionKeypress(type="keypress", keys=["ctrl", "c"]), ("keypress", (["ctrl", "c"],))),
|
||||
(
|
||||
ActionKeypress(type="keypress", keys=["ctrl", "c"]),
|
||||
("keypress", (["ctrl", "c"],)),
|
||||
),
|
||||
(ActionMove(type="move", x=8, y=9), ("move", (8, 9))),
|
||||
(ActionScreenshot(type="screenshot"), ("screenshot", ())),
|
||||
(
|
||||
|
||||
@@ -3,7 +3,11 @@ import os
|
||||
import openai
|
||||
import pytest
|
||||
|
||||
from agents import set_default_openai_api, set_default_openai_client, set_default_openai_key
|
||||
from agents import (
|
||||
set_default_openai_api,
|
||||
set_default_openai_client,
|
||||
set_default_openai_key,
|
||||
)
|
||||
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
|
||||
from agents.models.openai_provider import OpenAIProvider
|
||||
from agents.models.openai_responses import OpenAIResponsesModel
|
||||
@@ -49,16 +53,16 @@ def test_resp_set_default_openai_client():
|
||||
|
||||
|
||||
def test_set_default_openai_api():
|
||||
assert isinstance(OpenAIProvider().get_model("gpt-4"), OpenAIResponsesModel), (
|
||||
"Default should be responses"
|
||||
)
|
||||
assert isinstance(
|
||||
OpenAIProvider().get_model("gpt-4"), OpenAIResponsesModel
|
||||
), "Default should be responses"
|
||||
|
||||
set_default_openai_api("chat_completions")
|
||||
assert isinstance(OpenAIProvider().get_model("gpt-4"), OpenAIChatCompletionsModel), (
|
||||
"Should be chat completions model"
|
||||
)
|
||||
assert isinstance(
|
||||
OpenAIProvider().get_model("gpt-4"), OpenAIChatCompletionsModel
|
||||
), "Should be chat completions model"
|
||||
|
||||
set_default_openai_api("responses")
|
||||
assert isinstance(OpenAIProvider().get_model("gpt-4"), OpenAIResponsesModel), (
|
||||
"Should be responses model"
|
||||
)
|
||||
assert isinstance(
|
||||
OpenAIProvider().get_model("gpt-4"), OpenAIResponsesModel
|
||||
), "Should be responses model"
|
||||
|
||||
@@ -87,17 +87,26 @@ def test_auto_detection() -> None:
|
||||
doc = generate_func_documentation(func_foo_google)
|
||||
assert doc.name == "func_foo_google"
|
||||
assert doc.description == "This is func_foo."
|
||||
assert doc.param_descriptions == {"a": "The first argument.", "b": "The second argument."}
|
||||
assert doc.param_descriptions == {
|
||||
"a": "The first argument.",
|
||||
"b": "The second argument.",
|
||||
}
|
||||
|
||||
doc = generate_func_documentation(func_foo_numpy)
|
||||
assert doc.name == "func_foo_numpy"
|
||||
assert doc.description == "This is func_foo."
|
||||
assert doc.param_descriptions == {"a": "The first argument.", "b": "The second argument."}
|
||||
assert doc.param_descriptions == {
|
||||
"a": "The first argument.",
|
||||
"b": "The second argument.",
|
||||
}
|
||||
|
||||
doc = generate_func_documentation(func_foo_sphinx)
|
||||
assert doc.name == "func_foo_sphinx"
|
||||
assert doc.description == "This is func_foo."
|
||||
assert doc.param_descriptions == {"a": "The first argument.", "b": "The second argument."}
|
||||
assert doc.param_descriptions == {
|
||||
"a": "The first argument.",
|
||||
"b": "The second argument.",
|
||||
}
|
||||
|
||||
|
||||
def test_instance_method() -> None:
|
||||
@@ -105,11 +114,17 @@ def test_instance_method() -> None:
|
||||
doc = generate_func_documentation(bar.func_bar)
|
||||
assert doc.name == "func_bar"
|
||||
assert doc.description == "This is func_bar."
|
||||
assert doc.param_descriptions == {"a": "The first argument.", "b": "The second argument."}
|
||||
assert doc.param_descriptions == {
|
||||
"a": "The first argument.",
|
||||
"b": "The second argument.",
|
||||
}
|
||||
|
||||
|
||||
def test_classmethod() -> None:
|
||||
doc = generate_func_documentation(Bar.func_baz)
|
||||
assert doc.name == "func_baz"
|
||||
assert doc.description == "This is func_baz."
|
||||
assert doc.param_descriptions == {"a": "The first argument.", "b": "The second argument."}
|
||||
assert doc.param_descriptions == {
|
||||
"a": "The first argument.",
|
||||
"b": "The second argument.",
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@ def _get_message_output_run_item(content: str) -> MessageOutputItem:
|
||||
agent=fake_agent(),
|
||||
raw_item=ResponseOutputMessage(
|
||||
id="1",
|
||||
content=[ResponseOutputText(text=content, annotations=[], type="output_text")],
|
||||
content=[
|
||||
ResponseOutputText(text=content, annotations=[], type="output_text")
|
||||
],
|
||||
role="assistant",
|
||||
status="completed",
|
||||
type="message",
|
||||
@@ -78,13 +80,17 @@ def _get_handoff_output_run_item(content: str) -> HandoffOutputItem:
|
||||
|
||||
|
||||
def test_empty_data():
|
||||
handoff_input_data = HandoffInputData(input_history=(), pre_handoff_items=(), new_items=())
|
||||
handoff_input_data = HandoffInputData(
|
||||
input_history=(), pre_handoff_items=(), new_items=()
|
||||
)
|
||||
filtered_data = remove_all_tools(handoff_input_data)
|
||||
assert filtered_data == handoff_input_data
|
||||
|
||||
|
||||
def test_str_historyonly():
|
||||
handoff_input_data = HandoffInputData(input_history="Hello", pre_handoff_items=(), new_items=())
|
||||
handoff_input_data = HandoffInputData(
|
||||
input_history="Hello", pre_handoff_items=(), new_items=()
|
||||
)
|
||||
filtered_data = remove_all_tools(handoff_input_data)
|
||||
assert filtered_data == handoff_input_data
|
||||
|
||||
|
||||
@@ -125,7 +125,11 @@ def test_varargs_function():
|
||||
func_schema.params_pydantic_model(**{"numbers": [1.1, 2.2]})
|
||||
|
||||
# 'flag' can be omitted because it has a default
|
||||
valid_input_no_flag = {"x": 7, "numbers": [9.9], "kwargs": {"some_key": "some_value"}}
|
||||
valid_input_no_flag = {
|
||||
"x": 7,
|
||||
"numbers": [9.9],
|
||||
"kwargs": {"some_key": "some_value"},
|
||||
}
|
||||
parsed2 = func_schema.params_pydantic_model(**valid_input_no_flag)
|
||||
args2, kwargs_dict2 = func_schema.to_call_args(parsed2)
|
||||
result2 = varargs_function(*args2, **kwargs_dict2)
|
||||
@@ -188,12 +192,21 @@ def test_complex_args_and_docs_function():
|
||||
func_schema = function_schema(complex_args_and_docs_function)
|
||||
|
||||
assert isinstance(func_schema.params_json_schema, dict)
|
||||
assert func_schema.params_json_schema.get("title") == "complex_args_and_docs_function_args"
|
||||
assert (
|
||||
func_schema.params_json_schema.get("title")
|
||||
== "complex_args_and_docs_function_args"
|
||||
)
|
||||
|
||||
# Check docstring is parsed correctly
|
||||
properties = func_schema.params_json_schema.get("properties", {})
|
||||
assert properties.get("model").get("description") == "A model with an inner and foo field"
|
||||
assert properties.get("some_flag").get("description") == "An optional flag with a default of 0"
|
||||
assert (
|
||||
properties.get("model").get("description")
|
||||
== "A model with an inner and foo field"
|
||||
)
|
||||
assert (
|
||||
properties.get("some_flag").get("description")
|
||||
== "An optional flag with a default of 0"
|
||||
)
|
||||
|
||||
# Valid input
|
||||
model = OuterModel(inner=InnerModel(a=1, b="hello"), foo=Foo(a=2, b="world"))
|
||||
@@ -350,10 +363,14 @@ def enum_and_literal_function(a: MyEnum, b: Literal["a", "b", "c"]) -> str:
|
||||
def test_enum_and_literal_function():
|
||||
func_schema = function_schema(enum_and_literal_function)
|
||||
assert isinstance(func_schema.params_json_schema, dict)
|
||||
assert func_schema.params_json_schema.get("title") == "enum_and_literal_function_args"
|
||||
assert (
|
||||
func_schema.params_json_schema.get("title") == "enum_and_literal_function_args"
|
||||
)
|
||||
|
||||
# Check that the enum values are included in the JSON schema
|
||||
assert func_schema.params_json_schema.get("$defs", {}).get("MyEnum", {}).get("enum") == [
|
||||
assert func_schema.params_json_schema.get("$defs", {}).get("MyEnum", {}).get(
|
||||
"enum"
|
||||
) == [
|
||||
"foo",
|
||||
"bar",
|
||||
"baz",
|
||||
@@ -366,7 +383,9 @@ def test_enum_and_literal_function():
|
||||
)
|
||||
|
||||
# Check that the literal values are included in the JSON schema
|
||||
assert func_schema.params_json_schema.get("properties", {}).get("b", {}).get("enum") == [
|
||||
assert func_schema.params_json_schema.get("properties", {}).get("b", {}).get(
|
||||
"enum"
|
||||
) == [
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
|
||||
@@ -214,7 +214,9 @@ async def test_sync_custom_error_function_works():
|
||||
def my_func(a: int, b: int = 5):
|
||||
raise ValueError("test")
|
||||
|
||||
def custom_sync_error_function(ctx: RunContextWrapper[Any], error: Exception) -> str:
|
||||
def custom_sync_error_function(
|
||||
ctx: RunContextWrapper[Any], error: Exception
|
||||
) -> str:
|
||||
return f"error_{error.__class__.__name__}"
|
||||
|
||||
tool = function_tool(my_func, failure_error_function=custom_sync_error_function)
|
||||
@@ -238,7 +240,9 @@ async def test_async_custom_error_function_works():
|
||||
async def my_func(a: int, b: int = 5):
|
||||
raise ValueError("test")
|
||||
|
||||
def custom_sync_error_function(ctx: RunContextWrapper[Any], error: Exception) -> str:
|
||||
def custom_sync_error_function(
|
||||
ctx: RunContextWrapper[Any], error: Exception
|
||||
) -> str:
|
||||
return f"error_{error.__class__.__name__}"
|
||||
|
||||
tool = function_tool(my_func, failure_error_function=custom_sync_error_function)
|
||||
|
||||
@@ -70,7 +70,9 @@ async def test_async_no_context_invocation():
|
||||
|
||||
|
||||
@function_tool
|
||||
async def async_with_context(ctx: RunContextWrapper[DummyContext], prefix: str, num: int) -> str:
|
||||
async def async_with_context(
|
||||
ctx: RunContextWrapper[DummyContext], prefix: str, num: int
|
||||
) -> str:
|
||||
await asyncio.sleep(0)
|
||||
return f"{prefix}-{num}-{ctx.context.data}"
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ from agents.guardrail import input_guardrail, output_guardrail
|
||||
|
||||
def get_sync_guardrail(triggers: bool, output_info: Any | None = None):
|
||||
def sync_guardrail(
|
||||
context: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem]
|
||||
context: RunContextWrapper[Any],
|
||||
agent: Agent[Any],
|
||||
input: str | list[TResponseInputItem],
|
||||
):
|
||||
return GuardrailFunctionOutput(
|
||||
output_info=output_info,
|
||||
@@ -56,7 +58,9 @@ async def test_sync_input_guardrail():
|
||||
|
||||
def get_async_input_guardrail(triggers: bool, output_info: Any | None = None):
|
||||
async def async_guardrail(
|
||||
context: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem]
|
||||
context: RunContextWrapper[Any],
|
||||
agent: Agent[Any],
|
||||
input: str | list[TResponseInputItem],
|
||||
):
|
||||
return GuardrailFunctionOutput(
|
||||
output_info=output_info,
|
||||
@@ -68,14 +72,18 @@ def get_async_input_guardrail(triggers: bool, output_info: Any | None = None):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_input_guardrail():
|
||||
guardrail = InputGuardrail(guardrail_function=get_async_input_guardrail(triggers=False))
|
||||
guardrail = InputGuardrail(
|
||||
guardrail_function=get_async_input_guardrail(triggers=False)
|
||||
)
|
||||
result = await guardrail.run(
|
||||
agent=Agent(name="test"), input="test", context=RunContextWrapper(context=None)
|
||||
)
|
||||
assert not result.output.tripwire_triggered
|
||||
assert result.output.output_info is None
|
||||
|
||||
guardrail = InputGuardrail(guardrail_function=get_async_input_guardrail(triggers=True))
|
||||
guardrail = InputGuardrail(
|
||||
guardrail_function=get_async_input_guardrail(triggers=True)
|
||||
)
|
||||
result = await guardrail.run(
|
||||
agent=Agent(name="test"), input="test", context=RunContextWrapper(context=None)
|
||||
)
|
||||
@@ -98,12 +106,16 @@ async def test_invalid_input_guardrail_raises_user_error():
|
||||
# Purposely ignoring type error
|
||||
guardrail = InputGuardrail(guardrail_function="foo") # type: ignore
|
||||
await guardrail.run(
|
||||
agent=Agent(name="test"), input="test", context=RunContextWrapper(context=None)
|
||||
agent=Agent(name="test"),
|
||||
input="test",
|
||||
context=RunContextWrapper(context=None),
|
||||
)
|
||||
|
||||
|
||||
def get_sync_output_guardrail(triggers: bool, output_info: Any | None = None):
|
||||
def sync_guardrail(context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any):
|
||||
def sync_guardrail(
|
||||
context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any
|
||||
):
|
||||
return GuardrailFunctionOutput(
|
||||
output_info=output_info,
|
||||
tripwire_triggered=triggers,
|
||||
@@ -114,16 +126,24 @@ def get_sync_output_guardrail(triggers: bool, output_info: Any | None = None):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_output_guardrail():
|
||||
guardrail = OutputGuardrail(guardrail_function=get_sync_output_guardrail(triggers=False))
|
||||
guardrail = OutputGuardrail(
|
||||
guardrail_function=get_sync_output_guardrail(triggers=False)
|
||||
)
|
||||
result = await guardrail.run(
|
||||
agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None)
|
||||
agent=Agent(name="test"),
|
||||
agent_output="test",
|
||||
context=RunContextWrapper(context=None),
|
||||
)
|
||||
assert not result.output.tripwire_triggered
|
||||
assert result.output.output_info is None
|
||||
|
||||
guardrail = OutputGuardrail(guardrail_function=get_sync_output_guardrail(triggers=True))
|
||||
guardrail = OutputGuardrail(
|
||||
guardrail_function=get_sync_output_guardrail(triggers=True)
|
||||
)
|
||||
result = await guardrail.run(
|
||||
agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None)
|
||||
agent=Agent(name="test"),
|
||||
agent_output="test",
|
||||
context=RunContextWrapper(context=None),
|
||||
)
|
||||
assert result.output.tripwire_triggered
|
||||
assert result.output.output_info is None
|
||||
@@ -132,7 +152,9 @@ async def test_sync_output_guardrail():
|
||||
guardrail_function=get_sync_output_guardrail(triggers=True, output_info="test")
|
||||
)
|
||||
result = await guardrail.run(
|
||||
agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None)
|
||||
agent=Agent(name="test"),
|
||||
agent_output="test",
|
||||
context=RunContextWrapper(context=None),
|
||||
)
|
||||
assert result.output.tripwire_triggered
|
||||
assert result.output.output_info == "test"
|
||||
@@ -152,16 +174,24 @@ def get_async_output_guardrail(triggers: bool, output_info: Any | None = None):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_output_guardrail():
|
||||
guardrail = OutputGuardrail(guardrail_function=get_async_output_guardrail(triggers=False))
|
||||
guardrail = OutputGuardrail(
|
||||
guardrail_function=get_async_output_guardrail(triggers=False)
|
||||
)
|
||||
result = await guardrail.run(
|
||||
agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None)
|
||||
agent=Agent(name="test"),
|
||||
agent_output="test",
|
||||
context=RunContextWrapper(context=None),
|
||||
)
|
||||
assert not result.output.tripwire_triggered
|
||||
assert result.output.output_info is None
|
||||
|
||||
guardrail = OutputGuardrail(guardrail_function=get_async_output_guardrail(triggers=True))
|
||||
guardrail = OutputGuardrail(
|
||||
guardrail_function=get_async_output_guardrail(triggers=True)
|
||||
)
|
||||
result = await guardrail.run(
|
||||
agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None)
|
||||
agent=Agent(name="test"),
|
||||
agent_output="test",
|
||||
context=RunContextWrapper(context=None),
|
||||
)
|
||||
assert result.output.tripwire_triggered
|
||||
assert result.output.output_info is None
|
||||
@@ -170,7 +200,9 @@ async def test_async_output_guardrail():
|
||||
guardrail_function=get_async_output_guardrail(triggers=True, output_info="test")
|
||||
)
|
||||
result = await guardrail.run(
|
||||
agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None)
|
||||
agent=Agent(name="test"),
|
||||
agent_output="test",
|
||||
context=RunContextWrapper(context=None),
|
||||
)
|
||||
assert result.output.tripwire_triggered
|
||||
assert result.output.output_info == "test"
|
||||
@@ -182,13 +214,17 @@ async def test_invalid_output_guardrail_raises_user_error():
|
||||
# Purposely ignoring type error
|
||||
guardrail = OutputGuardrail(guardrail_function="foo") # type: ignore
|
||||
await guardrail.run(
|
||||
agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None)
|
||||
agent=Agent(name="test"),
|
||||
agent_output="test",
|
||||
context=RunContextWrapper(context=None),
|
||||
)
|
||||
|
||||
|
||||
@input_guardrail
|
||||
def decorated_input_guardrail(
|
||||
context: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem]
|
||||
context: RunContextWrapper[Any],
|
||||
agent: Agent[Any],
|
||||
input: str | list[TResponseInputItem],
|
||||
) -> GuardrailFunctionOutput:
|
||||
return GuardrailFunctionOutput(
|
||||
output_info="test_1",
|
||||
@@ -198,7 +234,9 @@ def decorated_input_guardrail(
|
||||
|
||||
@input_guardrail(name="Custom name")
|
||||
def decorated_named_input_guardrail(
|
||||
context: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem]
|
||||
context: RunContextWrapper[Any],
|
||||
agent: Agent[Any],
|
||||
input: str | list[TResponseInputItem],
|
||||
) -> GuardrailFunctionOutput:
|
||||
return GuardrailFunctionOutput(
|
||||
output_info="test_2",
|
||||
@@ -248,14 +286,18 @@ def decorated_named_output_guardrail(
|
||||
async def test_output_guardrail_decorators():
|
||||
guardrail = decorated_output_guardrail
|
||||
result = await guardrail.run(
|
||||
agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None)
|
||||
agent=Agent(name="test"),
|
||||
agent_output="test",
|
||||
context=RunContextWrapper(context=None),
|
||||
)
|
||||
assert not result.output.tripwire_triggered
|
||||
assert result.output.output_info == "test_3"
|
||||
|
||||
guardrail = decorated_named_output_guardrail
|
||||
result = await guardrail.run(
|
||||
agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None)
|
||||
agent=Agent(name="test"),
|
||||
agent_output="test",
|
||||
context=RunContextWrapper(context=None),
|
||||
)
|
||||
assert not result.output.tripwire_triggered
|
||||
assert result.output.output_info == "test_4"
|
||||
|
||||
@@ -25,7 +25,9 @@ def message_item(content: str, agent: Agent[Any]) -> MessageOutputItem:
|
||||
status="completed",
|
||||
role="assistant",
|
||||
type="message",
|
||||
content=[ResponseOutputText(text=content, type="output_text", annotations=[])],
|
||||
content=[
|
||||
ResponseOutputText(text=content, type="output_text", annotations=[])
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -68,8 +70,12 @@ def test_multiple_handoffs_setup():
|
||||
assert handoff_objects[0].tool_name == Handoff.default_tool_name(agent_1)
|
||||
assert handoff_objects[1].tool_name == Handoff.default_tool_name(agent_2)
|
||||
|
||||
assert handoff_objects[0].tool_description == Handoff.default_tool_description(agent_1)
|
||||
assert handoff_objects[1].tool_description == Handoff.default_tool_description(agent_2)
|
||||
assert handoff_objects[0].tool_description == Handoff.default_tool_description(
|
||||
agent_1
|
||||
)
|
||||
assert handoff_objects[1].tool_description == Handoff.default_tool_description(
|
||||
agent_2
|
||||
)
|
||||
|
||||
assert handoff_objects[0].agent_name == agent_1.name
|
||||
assert handoff_objects[1].agent_name == agent_2.name
|
||||
|
||||
@@ -4,21 +4,38 @@ from openai.types.responses.response_computer_tool_call import (
|
||||
ActionScreenshot,
|
||||
ResponseComputerToolCall,
|
||||
)
|
||||
from openai.types.responses.response_computer_tool_call_param import ResponseComputerToolCallParam
|
||||
from openai.types.responses.response_file_search_tool_call import ResponseFileSearchToolCall
|
||||
from openai.types.responses.response_computer_tool_call_param import (
|
||||
ResponseComputerToolCallParam,
|
||||
)
|
||||
from openai.types.responses.response_file_search_tool_call import (
|
||||
ResponseFileSearchToolCall,
|
||||
)
|
||||
from openai.types.responses.response_file_search_tool_call_param import (
|
||||
ResponseFileSearchToolCallParam,
|
||||
)
|
||||
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
|
||||
from openai.types.responses.response_function_tool_call_param import ResponseFunctionToolCallParam
|
||||
from openai.types.responses.response_function_web_search import ResponseFunctionWebSearch
|
||||
from openai.types.responses.response_function_web_search_param import ResponseFunctionWebSearchParam
|
||||
from openai.types.responses.response_function_tool_call_param import (
|
||||
ResponseFunctionToolCallParam,
|
||||
)
|
||||
from openai.types.responses.response_function_web_search import (
|
||||
ResponseFunctionWebSearch,
|
||||
)
|
||||
from openai.types.responses.response_function_web_search_param import (
|
||||
ResponseFunctionWebSearchParam,
|
||||
)
|
||||
from openai.types.responses.response_output_message import ResponseOutputMessage
|
||||
from openai.types.responses.response_output_message_param import ResponseOutputMessageParam
|
||||
from openai.types.responses.response_output_message_param import (
|
||||
ResponseOutputMessageParam,
|
||||
)
|
||||
from openai.types.responses.response_output_refusal import ResponseOutputRefusal
|
||||
from openai.types.responses.response_output_text import ResponseOutputText
|
||||
from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary
|
||||
from openai.types.responses.response_reasoning_item_param import ResponseReasoningItemParam
|
||||
from openai.types.responses.response_reasoning_item import (
|
||||
ResponseReasoningItem,
|
||||
Summary,
|
||||
)
|
||||
from openai.types.responses.response_reasoning_item_param import (
|
||||
ResponseReasoningItemParam,
|
||||
)
|
||||
|
||||
from agents import (
|
||||
Agent,
|
||||
@@ -59,7 +76,9 @@ def test_extract_last_content_of_text_message() -> None:
|
||||
|
||||
def test_extract_last_content_of_refusal_message() -> None:
|
||||
# Build a message whose last content entry is a refusal.
|
||||
content1 = ResponseOutputText(annotations=[], text="Before refusal", type="output_text")
|
||||
content1 = ResponseOutputText(
|
||||
annotations=[], text="Before refusal", type="output_text"
|
||||
)
|
||||
refusal = ResponseOutputRefusal(refusal="I cannot do that", type="refusal")
|
||||
message = make_message([content1, refusal])
|
||||
# Helpers should extract the refusal string when last content is a refusal.
|
||||
@@ -85,7 +104,9 @@ def test_extract_last_text_returns_text_only() -> None:
|
||||
message = make_message([first_text, second_text])
|
||||
assert ItemHelpers.extract_last_text(message) == "part2"
|
||||
# Whereas when last content is a refusal, extract_last_text returns None.
|
||||
message2 = make_message([first_text, ResponseOutputRefusal(refusal="no", type="refusal")])
|
||||
message2 = make_message(
|
||||
[first_text, ResponseOutputRefusal(refusal="no", type="refusal")]
|
||||
)
|
||||
assert ItemHelpers.extract_last_text(message2) is None
|
||||
|
||||
|
||||
@@ -124,15 +145,23 @@ def test_text_message_outputs_across_list_of_runitems() -> None:
|
||||
that only MessageOutputItem instances contribute any text. The non-message
|
||||
(ReasoningItem) should be ignored by Helpers.text_message_outputs.
|
||||
"""
|
||||
message1 = make_message([ResponseOutputText(annotations=[], text="foo", type="output_text")])
|
||||
message2 = make_message([ResponseOutputText(annotations=[], text="bar", type="output_text")])
|
||||
message1 = make_message(
|
||||
[ResponseOutputText(annotations=[], text="foo", type="output_text")]
|
||||
)
|
||||
message2 = make_message(
|
||||
[ResponseOutputText(annotations=[], text="bar", type="output_text")]
|
||||
)
|
||||
item1: RunItem = MessageOutputItem(agent=Agent(name="test"), raw_item=message1)
|
||||
item2: RunItem = MessageOutputItem(agent=Agent(name="test"), raw_item=message2)
|
||||
# Create a non-message run item of a different type, e.g., a reasoning trace.
|
||||
reasoning = ResponseReasoningItem(id="rid", summary=[], type="reasoning")
|
||||
non_message_item: RunItem = ReasoningItem(agent=Agent(name="test"), raw_item=reasoning)
|
||||
non_message_item: RunItem = ReasoningItem(
|
||||
agent=Agent(name="test"), raw_item=reasoning
|
||||
)
|
||||
# Confirm only the message outputs are concatenated.
|
||||
assert ItemHelpers.text_message_outputs([item1, non_message_item, item2]) == "foobar"
|
||||
assert (
|
||||
ItemHelpers.text_message_outputs([item1, non_message_item, item2]) == "foobar"
|
||||
)
|
||||
|
||||
|
||||
def test_tool_call_output_item_constructs_function_call_output_dict():
|
||||
@@ -235,11 +264,11 @@ def test_to_input_items_for_web_search_call() -> None:
|
||||
input_items = resp.to_input_items()
|
||||
assert isinstance(input_items, list) and len(input_items) == 1
|
||||
expected: ResponseFunctionWebSearchParam = {
|
||||
"id": "w1",
|
||||
"status": "completed",
|
||||
"type": "web_search_call",
|
||||
"action": {"type": "search", "query": ""},
|
||||
}
|
||||
"id": "w1",
|
||||
"status": "completed",
|
||||
"type": "web_search_call",
|
||||
"action": {"type": "search", "query": ""},
|
||||
}
|
||||
assert input_items[0] == expected
|
||||
|
||||
|
||||
|
||||
@@ -24,11 +24,26 @@ async def test_non_streamed_max_turns():
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
[get_text_message("1"), get_function_tool_call("some_function", func_output)],
|
||||
[get_text_message("2"), get_function_tool_call("some_function", func_output)],
|
||||
[get_text_message("3"), get_function_tool_call("some_function", func_output)],
|
||||
[get_text_message("4"), get_function_tool_call("some_function", func_output)],
|
||||
[get_text_message("5"), get_function_tool_call("some_function", func_output)],
|
||||
[
|
||||
get_text_message("1"),
|
||||
get_function_tool_call("some_function", func_output),
|
||||
],
|
||||
[
|
||||
get_text_message("2"),
|
||||
get_function_tool_call("some_function", func_output),
|
||||
],
|
||||
[
|
||||
get_text_message("3"),
|
||||
get_function_tool_call("some_function", func_output),
|
||||
],
|
||||
[
|
||||
get_text_message("4"),
|
||||
get_function_tool_call("some_function", func_output),
|
||||
],
|
||||
[
|
||||
get_text_message("5"),
|
||||
get_function_tool_call("some_function", func_output),
|
||||
],
|
||||
]
|
||||
)
|
||||
with pytest.raises(MaxTurnsExceeded):
|
||||
|
||||
@@ -56,7 +56,9 @@ async def test_get_response_with_text_message(monkeypatch) -> None:
|
||||
async def patched_fetch_response(self, *args, **kwargs):
|
||||
return chat
|
||||
|
||||
monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response)
|
||||
monkeypatch.setattr(
|
||||
OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response
|
||||
)
|
||||
model = OpenAIProvider(use_responses=False).get_model("gpt-4")
|
||||
resp: ModelResponse = await model.get_response(
|
||||
system_instructions=None,
|
||||
@@ -104,7 +106,9 @@ async def test_get_response_with_refusal(monkeypatch) -> None:
|
||||
async def patched_fetch_response(self, *args, **kwargs):
|
||||
return chat
|
||||
|
||||
monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response)
|
||||
monkeypatch.setattr(
|
||||
OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response
|
||||
)
|
||||
model = OpenAIProvider(use_responses=False).get_model("gpt-4")
|
||||
resp: ModelResponse = await model.get_response(
|
||||
system_instructions=None,
|
||||
@@ -153,7 +157,9 @@ async def test_get_response_with_tool_call(monkeypatch) -> None:
|
||||
async def patched_fetch_response(self, *args, **kwargs):
|
||||
return chat
|
||||
|
||||
monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response)
|
||||
monkeypatch.setattr(
|
||||
OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response
|
||||
)
|
||||
model = OpenAIProvider(use_responses=False).get_model("gpt-4")
|
||||
resp: ModelResponse = await model.get_response(
|
||||
system_instructions=None,
|
||||
|
||||
@@ -171,7 +171,7 @@ def test_items_to_messages_with_output_message_and_function_call():
|
||||
"type": "function_call",
|
||||
}
|
||||
items: list[TResponseInputItem] = [
|
||||
resp_msg.model_dump(), # type:ignore
|
||||
resp_msg.model_dump(), # type: ignore
|
||||
func_item,
|
||||
]
|
||||
messages = _Converter.items_to_messages(items)
|
||||
@@ -212,7 +212,9 @@ def test_convert_tool_choice_handles_standard_and_named_options() -> None:
|
||||
assert tool_choice_dict["function"]["name"] == "mytool"
|
||||
|
||||
|
||||
def test_convert_response_format_returns_not_given_for_plain_text_and_dict_for_schemas() -> None:
|
||||
def test_convert_response_format_returns_not_given_for_plain_text_and_dict_for_schemas() -> (
|
||||
None
|
||||
):
|
||||
"""
|
||||
The `_Converter.convert_response_format` method should return NOT_GIVEN
|
||||
when no output schema is provided or if the output schema indicates
|
||||
@@ -223,7 +225,8 @@ def test_convert_response_format_returns_not_given_for_plain_text_and_dict_for_s
|
||||
# when output is plain text (schema None or output_type str), do not include response_format
|
||||
assert _Converter.convert_response_format(None).__class__.__name__ == "NotGiven"
|
||||
assert (
|
||||
_Converter.convert_response_format(AgentOutputSchema(str)).__class__.__name__ == "NotGiven"
|
||||
_Converter.convert_response_format(AgentOutputSchema(str)).__class__.__name__
|
||||
== "NotGiven"
|
||||
)
|
||||
# For e.g. integer output, we expect a response_format dict
|
||||
schema = AgentOutputSchema(int)
|
||||
|
||||
@@ -68,7 +68,9 @@ async def test_stream_response_yields_events_for_text_content(monkeypatch) -> No
|
||||
)
|
||||
return resp, fake_stream()
|
||||
|
||||
monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response)
|
||||
monkeypatch.setattr(
|
||||
OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response
|
||||
)
|
||||
model = OpenAIProvider(use_responses=False).get_model("gpt-4")
|
||||
output_events = []
|
||||
async for event in model.stream_response(
|
||||
@@ -157,7 +159,9 @@ async def test_stream_response_yields_events_for_refusal_content(monkeypatch) ->
|
||||
)
|
||||
return resp, fake_stream()
|
||||
|
||||
monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response)
|
||||
monkeypatch.setattr(
|
||||
OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response
|
||||
)
|
||||
model = OpenAIProvider(use_responses=False).get_model("gpt-4")
|
||||
output_events = []
|
||||
async for event in model.stream_response(
|
||||
@@ -244,7 +248,9 @@ async def test_stream_response_yields_events_for_tool_call(monkeypatch) -> None:
|
||||
)
|
||||
return resp, fake_stream()
|
||||
|
||||
monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response)
|
||||
monkeypatch.setattr(
|
||||
OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response
|
||||
)
|
||||
model = OpenAIProvider(use_responses=False).get_model("gpt-4")
|
||||
output_events = []
|
||||
async for event in model.stream_response(
|
||||
|
||||
@@ -55,8 +55,12 @@ def test_convert_tool_choice_standard_values():
|
||||
assert Converter.convert_tool_choice("none") == "none"
|
||||
# Special tool types are represented as dicts of type only.
|
||||
assert Converter.convert_tool_choice("file_search") == {"type": "file_search"}
|
||||
assert Converter.convert_tool_choice("web_search_preview") == {"type": "web_search_preview"}
|
||||
assert Converter.convert_tool_choice("computer_use_preview") == {"type": "computer_use_preview"}
|
||||
assert Converter.convert_tool_choice("web_search_preview") == {
|
||||
"type": "web_search_preview"
|
||||
}
|
||||
assert Converter.convert_tool_choice("computer_use_preview") == {
|
||||
"type": "computer_use_preview"
|
||||
}
|
||||
# Arbitrary string should be interpreted as a function name.
|
||||
assert Converter.convert_tool_choice("my_function") == {
|
||||
"type": "function",
|
||||
@@ -169,11 +173,15 @@ def test_convert_tools_basic_types_and_includes():
|
||||
assert file_params.get("max_num_results") == file_tool.max_num_results
|
||||
assert file_params.get("vector_store_ids") == file_tool.vector_store_ids
|
||||
# Verify web search tool contains user_location and search_context_size
|
||||
web_params = next(ct for ct in converted.tools if ct["type"] == "web_search_preview")
|
||||
web_params = next(
|
||||
ct for ct in converted.tools if ct["type"] == "web_search_preview"
|
||||
)
|
||||
assert web_params.get("user_location") == web_tool.user_location
|
||||
assert web_params.get("search_context_size") == web_tool.search_context_size
|
||||
# Verify computer tool contains environment and computed dimensions
|
||||
comp_params = next(ct for ct in converted.tools if ct["type"] == "computer_use_preview")
|
||||
comp_params = next(
|
||||
ct for ct in converted.tools if ct["type"] == "computer_use_preview"
|
||||
)
|
||||
assert comp_params.get("environment") == "mac"
|
||||
assert comp_params.get("display_width") == 800
|
||||
assert comp_params.get("display_height") == 600
|
||||
|
||||
@@ -4,17 +4,28 @@ import pytest
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from agents import Agent, AgentOutputSchema, ModelBehaviorError, Runner, UserError, _utils
|
||||
from agents import (
|
||||
Agent,
|
||||
AgentOutputSchema,
|
||||
ModelBehaviorError,
|
||||
Runner,
|
||||
UserError,
|
||||
_utils,
|
||||
)
|
||||
from agents.agent_output import _WRAPPER_DICT_KEY
|
||||
|
||||
|
||||
def test_plain_text_output():
|
||||
agent = Agent(name="test")
|
||||
output_schema = Runner._get_output_schema(agent)
|
||||
assert not output_schema, "Shouldn't have an output tool config without an output type"
|
||||
assert (
|
||||
not output_schema
|
||||
), "Shouldn't have an output tool config without an output type"
|
||||
|
||||
agent = Agent(name="test", output_type=str)
|
||||
assert not output_schema, "Shouldn't have an output tool config with str output type"
|
||||
assert (
|
||||
not output_schema
|
||||
), "Shouldn't have an output tool config with str output type"
|
||||
|
||||
|
||||
class Foo(BaseModel):
|
||||
@@ -24,7 +35,9 @@ class Foo(BaseModel):
|
||||
def test_structured_output_pydantic():
|
||||
agent = Agent(name="test", output_type=Foo)
|
||||
output_schema = Runner._get_output_schema(agent)
|
||||
assert output_schema, "Should have an output tool config with a structured output type"
|
||||
assert (
|
||||
output_schema
|
||||
), "Should have an output tool config with a structured output type"
|
||||
|
||||
assert output_schema.output_type == Foo, "Should have the correct output type"
|
||||
assert not output_schema._is_wrapped, "Pydantic objects should not be wrapped"
|
||||
@@ -43,7 +56,9 @@ class Bar(TypedDict):
|
||||
def test_structured_output_typed_dict():
|
||||
agent = Agent(name="test", output_type=Bar)
|
||||
output_schema = Runner._get_output_schema(agent)
|
||||
assert output_schema, "Should have an output tool config with a structured output type"
|
||||
assert (
|
||||
output_schema
|
||||
), "Should have an output tool config with a structured output type"
|
||||
assert output_schema.output_type == Bar, "Should have the correct output type"
|
||||
assert not output_schema._is_wrapped, "TypedDicts should not be wrapped"
|
||||
|
||||
@@ -55,7 +70,9 @@ def test_structured_output_typed_dict():
|
||||
def test_structured_output_list():
|
||||
agent = Agent(name="test", output_type=list[str])
|
||||
output_schema = Runner._get_output_schema(agent)
|
||||
assert output_schema, "Should have an output tool config with a structured output type"
|
||||
assert (
|
||||
output_schema
|
||||
), "Should have an output tool config with a structured output type"
|
||||
assert output_schema.output_type == list[str], "Should have the correct output type"
|
||||
assert output_schema._is_wrapped, "Lists should be wrapped"
|
||||
|
||||
@@ -68,14 +85,18 @@ def test_structured_output_list():
|
||||
def test_bad_json_raises_error(mocker):
|
||||
agent = Agent(name="test", output_type=Foo)
|
||||
output_schema = Runner._get_output_schema(agent)
|
||||
assert output_schema, "Should have an output tool config with a structured output type"
|
||||
assert (
|
||||
output_schema
|
||||
), "Should have an output tool config with a structured output type"
|
||||
|
||||
with pytest.raises(ModelBehaviorError):
|
||||
output_schema.validate_json("not valid json")
|
||||
|
||||
agent = Agent(name="test", output_type=list[str])
|
||||
output_schema = Runner._get_output_schema(agent)
|
||||
assert output_schema, "Should have an output tool config with a structured output type"
|
||||
assert (
|
||||
output_schema
|
||||
), "Should have an output tool config with a structured output type"
|
||||
|
||||
mock_validate_json = mocker.patch.object(_utils, "validate_json")
|
||||
mock_validate_json.return_value = ["foo"]
|
||||
|
||||
@@ -49,7 +49,9 @@ def get_function_tool(
|
||||
)
|
||||
|
||||
|
||||
def get_function_tool_call(name: str, arguments: str | None = None) -> ResponseOutputItem:
|
||||
def get_function_tool_call(
|
||||
name: str, arguments: str | None = None
|
||||
) -> ResponseOutputItem:
|
||||
return ResponseFunctionToolCall(
|
||||
id="1",
|
||||
call_id="2",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user