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:
zooqueen
2026-02-21 23:32:32 -08:00
co-authored by Hanzo Dev
parent 964a72bd24
commit b144973bfc
530 changed files with 14144 additions and 5783 deletions
+6 -2
View File
@@ -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
+122 -108
View File
@@ -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())
+35 -36
View File
@@ -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")
+3 -1
View File
@@ -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"):
+3 -1
View File
@@ -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 -1
View File
@@ -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")
+51 -18
View File
@@ -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()
),
+3 -1
View File
@@ -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(
+8 -3
View File
@@ -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
+3 -1
View File
@@ -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] = {
+34 -12
View File
@@ -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)
+12 -6
View File
@@ -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
+4 -1
View File
@@ -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
+3 -1
View File
@@ -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",
]
]
+62 -70
View File
@@ -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(
+36 -36
View File
@@ -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]
+95 -72
View File
@@ -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
+7 -5
View File
@@ -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),
)
+2 -1
View File
@@ -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",
]
]
+93 -75
View File
@@ -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
+17 -17
View File
@@ -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,
}
}
+80 -71
View File
@@ -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}
)
+13 -6
View File
@@ -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
+9 -3
View File
@@ -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():
+40 -13
View File
@@ -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
]
+1 -1
View File
@@ -17,4 +17,4 @@ __all__ = [
"StateSerializer",
"JSONSerializer",
"PickleSerializer",
]
]
+13 -13
View File
@@ -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)
+11 -11
View File
@@ -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)
+88 -71
View File
@@ -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
+3 -1
View File
@@ -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."""
+33 -19
View File
@@ -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
+6 -2
View File
@@ -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)
+70 -66
View File
@@ -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
+8 -2
View File
@@ -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
+6 -2
View File
@@ -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)
+6 -2
View File
@@ -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"
)
+45 -43
View File
@@ -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())
+12 -10
View File
@@ -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())
+10 -11
View File
@@ -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}")
+25 -25
View File
@@ -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())
+11 -2
View File
@@ -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"
+6 -2
View File
@@ -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,
+3 -1
View File
@@ -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)
+3 -1
View File
@@ -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(
+70 -23
View File
@@ -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"
+9 -5
View File
@@ -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
+139 -171
View File
@@ -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"])
+26 -7
View File
@@ -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", ())),
(
+14 -10
View File
@@ -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"
+20 -5
View File
@@ -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
+26 -7
View File
@@ -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",
+6 -2
View File
@@ -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}"
+63 -21
View File
@@ -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"
+9 -3
View File
@@ -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
+48 -19
View File
@@ -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
+20 -5
View File
@@ -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
+29 -8
View File
@@ -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"]
+3 -1
View File
@@ -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