1421 lines
49 KiB
Python
1421 lines
49 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Literal, cast
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from openai.types.responses import ResponseTextDeltaEvent
|
|
from pydantic import BaseModel
|
|
|
|
from agents import (
|
|
Agent,
|
|
AgentBase,
|
|
AgentToolStreamEvent,
|
|
AgentUpdatedStreamEvent,
|
|
GuardrailFunctionOutput,
|
|
InputGuardrailTripwireTriggered,
|
|
ItemHelpers,
|
|
ModelSettings,
|
|
OutputGuardrailTripwireTriggered,
|
|
RawResponsesStreamEvent,
|
|
RunContextWrapper,
|
|
Runner,
|
|
input_guardrail,
|
|
output_guardrail,
|
|
)
|
|
from agents.agent import ToolsToFinalOutputResult
|
|
from agents.items import TResponseInputItem
|
|
from agents.tool import FunctionToolResult, function_tool
|
|
from examples.financial_research_agent.agents.verifier_agent import (
|
|
VerificationIssue,
|
|
VerificationResult,
|
|
)
|
|
from examples.financial_research_agent.agents.writer_agent import FinancialReportData
|
|
from examples.financial_research_agent.manager import (
|
|
FinancialResearchManager,
|
|
FinancialSearchEvidence,
|
|
FinancialSource,
|
|
_extract_financial_sources,
|
|
)
|
|
from examples.sandbox.basic import _import_docker_from_env
|
|
from examples.sandbox.docker.docker_runner import (
|
|
_format_tool_call,
|
|
_format_tool_output,
|
|
)
|
|
from examples.sandbox.sandbox_agents_as_tools import (
|
|
PricingPacketReview,
|
|
RolloutRiskReview,
|
|
_structured_tool_output_extractor,
|
|
)
|
|
from examples.tools.web_search_filters import _normalized_source_urls
|
|
from examples.web_search_utils import extract_url_citations, extract_web_search_source_urls
|
|
|
|
from .fake_model import FakeModel
|
|
from .test_responses import (
|
|
get_final_output_message,
|
|
get_function_tool_call,
|
|
get_handoff_tool_call,
|
|
get_text_input_item,
|
|
get_text_message,
|
|
)
|
|
|
|
|
|
def test_web_search_source_urls_reject_decoded_reserved_delimiters() -> None:
|
|
assert (
|
|
_normalized_source_urls(
|
|
["https://developers.openai.com/api/docs/models/finding-the-right-model%3F.pls"]
|
|
)
|
|
== []
|
|
)
|
|
|
|
|
|
def test_web_search_source_urls_are_canonical_and_domain_scoped() -> None:
|
|
assert _normalized_source_urls(
|
|
[
|
|
"https://developers.openai.com/api/docs/models/gpt-5.6-sol?utm_source=openai",
|
|
"https://developers.openai.com/api/docs/models/gpt-5.6-sol#pricing",
|
|
"https://subdomain.developers.openai.com/api/docs/models/gpt-5.6-terra/",
|
|
"https://developers.openai.com/assets/logo.svg",
|
|
"https://user@developers.openai.com/api/docs/models/gpt-5.6-sol",
|
|
"https://example.com/api/docs/models/gpt-5.6-sol",
|
|
]
|
|
) == [
|
|
"https://developers.openai.com/api/docs/models/gpt-5.6-sol",
|
|
"https://subdomain.developers.openai.com/api/docs/models/gpt-5.6-terra",
|
|
]
|
|
|
|
|
|
def test_web_search_metadata_distinguishes_citations_from_retrieved_sources() -> None:
|
|
items = [
|
|
{
|
|
"raw_item": {
|
|
"type": "web_search_call",
|
|
"action": {
|
|
"type": "search",
|
|
"sources": [
|
|
{
|
|
"type": "url",
|
|
"url": "https://developers.openai.com/api/docs/models/gpt-5.6-sol",
|
|
},
|
|
{
|
|
"type": "url",
|
|
"url": "https://developers.openai.com/api/docs/models/gpt-5.6-terra",
|
|
},
|
|
],
|
|
},
|
|
}
|
|
},
|
|
{
|
|
"raw_item": {
|
|
"type": "message",
|
|
"content": [
|
|
{
|
|
"type": "output_text",
|
|
"text": "Use Sol for the most demanding work.",
|
|
"annotations": [
|
|
{
|
|
"type": "url_citation",
|
|
"title": "GPT-5.6 Sol",
|
|
"url": (
|
|
"https://developers.openai.com/api/docs/models/gpt-5.6-sol"
|
|
),
|
|
}
|
|
],
|
|
}
|
|
],
|
|
}
|
|
},
|
|
]
|
|
|
|
assert extract_web_search_source_urls(items) == [
|
|
"https://developers.openai.com/api/docs/models/gpt-5.6-sol",
|
|
"https://developers.openai.com/api/docs/models/gpt-5.6-terra",
|
|
]
|
|
assert [(citation.title, citation.url) for citation in extract_url_citations(items)] == [
|
|
(
|
|
"GPT-5.6 Sol",
|
|
"https://developers.openai.com/api/docs/models/gpt-5.6-sol",
|
|
)
|
|
]
|
|
|
|
|
|
def test_financial_search_evidence_preserves_citations_and_retrieved_sources() -> None:
|
|
sources = _extract_financial_sources(
|
|
[
|
|
{
|
|
"raw_item": {
|
|
"type": "message",
|
|
"content": [
|
|
{
|
|
"type": "output_text",
|
|
"annotations": [
|
|
{
|
|
"type": "url_citation",
|
|
"title": "Annual report",
|
|
"url": "https://example.com/annual-report",
|
|
}
|
|
],
|
|
}
|
|
],
|
|
}
|
|
},
|
|
{
|
|
"raw_item": {
|
|
"type": "web_search_call",
|
|
"action": {
|
|
"sources": [
|
|
{"type": "url", "url": "https://example.com/annual-report"},
|
|
{"type": "url", "url": "https://example.com/earnings"},
|
|
]
|
|
},
|
|
}
|
|
},
|
|
]
|
|
)
|
|
|
|
assert sources == [
|
|
FinancialSource(title="Annual report", url="https://example.com/annual-report"),
|
|
FinancialSource(
|
|
title="https://example.com/earnings",
|
|
url="https://example.com/earnings",
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_financial_report_revises_once_after_failed_verification(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = object.__new__(FinancialResearchManager)
|
|
original_report = FinancialReportData(
|
|
short_summary="Original",
|
|
markdown_report="Unsupported claim",
|
|
follow_up_questions=[],
|
|
)
|
|
revised_report = FinancialReportData(
|
|
short_summary="Revised",
|
|
markdown_report="Supported claim",
|
|
follow_up_questions=[],
|
|
)
|
|
rejected = VerificationResult(
|
|
verified=False,
|
|
issues=[
|
|
VerificationIssue(
|
|
claim="Unsupported claim",
|
|
category="unsupported",
|
|
explanation="No supplied evidence supports it.",
|
|
source_urls=[],
|
|
)
|
|
],
|
|
)
|
|
accepted = VerificationResult(verified=True, issues=[])
|
|
write_report = AsyncMock(return_value=original_report)
|
|
verify_report = AsyncMock(side_effect=[rejected, accepted])
|
|
revise_report = AsyncMock(return_value=revised_report)
|
|
monkeypatch.setattr(manager, "_write_report", write_report)
|
|
monkeypatch.setattr(manager, "_verify_report", verify_report)
|
|
monkeypatch.setattr(manager, "_revise_report", revise_report)
|
|
|
|
report, verification = await manager._produce_verified_report("query", [])
|
|
|
|
assert report == revised_report
|
|
assert verification == accepted
|
|
write_report.assert_awaited_once_with("query", [])
|
|
revise_report.assert_awaited_once_with("query", original_report, [], rejected)
|
|
assert verify_report.await_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_financial_report_fails_after_second_rejected_verification(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = object.__new__(FinancialResearchManager)
|
|
report = FinancialReportData(
|
|
short_summary="Summary",
|
|
markdown_report="Unsupported claim",
|
|
follow_up_questions=[],
|
|
)
|
|
rejected = VerificationResult(
|
|
verified=False,
|
|
issues=[
|
|
VerificationIssue(
|
|
claim="Unsupported claim",
|
|
category="unsupported",
|
|
explanation="No supplied evidence supports it.",
|
|
source_urls=[],
|
|
)
|
|
],
|
|
)
|
|
monkeypatch.setattr(manager, "_write_report", AsyncMock(return_value=report))
|
|
monkeypatch.setattr(manager, "_verify_report", AsyncMock(return_value=rejected))
|
|
monkeypatch.setattr(manager, "_revise_report", AsyncMock(return_value=report))
|
|
|
|
with pytest.raises(RuntimeError, match="failed evidence verification after one revision"):
|
|
await manager._produce_verified_report("query", [])
|
|
|
|
|
|
def test_financial_report_input_includes_cutoff_and_evidence() -> None:
|
|
manager = object.__new__(FinancialResearchManager)
|
|
manager.research_cutoff = "2026-07-11"
|
|
evidence = FinancialSearchEvidence(
|
|
query="company annual report",
|
|
reason="Ground annual metrics",
|
|
summary="Revenue increased.",
|
|
sources=[FinancialSource(title="Annual report", url="https://example.com/report")],
|
|
retrieved_at="2026-07-11",
|
|
)
|
|
|
|
payload = json.loads(manager._report_input("Analyze the company", [evidence]))
|
|
|
|
assert payload == {
|
|
"original_query": "Analyze the company",
|
|
"research_cutoff": "2026-07-11",
|
|
"evidence": [evidence.model_dump(mode="json")],
|
|
}
|
|
|
|
|
|
def test_sandbox_basic_direct_run_imports_external_docker_sdk(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
sdk_dir = tmp_path / "sdk"
|
|
docker_package = sdk_dir / "docker"
|
|
docker_package.mkdir(parents=True)
|
|
docker_package.joinpath("__init__.py").write_text(
|
|
"def from_env():\n return 'external docker sdk'\n"
|
|
)
|
|
|
|
script_dir = Path("examples/sandbox").resolve()
|
|
monkeypatch.setattr(sys, "path", [str(script_dir), str(sdk_dir)])
|
|
for module_name in list(sys.modules):
|
|
if module_name == "docker" or module_name.startswith("docker."):
|
|
monkeypatch.delitem(sys.modules, module_name, raising=False)
|
|
|
|
docker_from_env = _import_docker_from_env()
|
|
|
|
assert docker_from_env() == "external docker sdk"
|
|
assert sys.path == [str(script_dir), str(sdk_dir)]
|
|
|
|
|
|
@dataclass
|
|
class EvaluationFeedback:
|
|
feedback: str
|
|
score: Literal["pass", "needs_improvement"]
|
|
|
|
|
|
@dataclass
|
|
class OutlineCheckerOutput:
|
|
good_quality: bool
|
|
is_scifi: bool
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_llm_as_judge_loop_handles_dataclass_feedback() -> None:
|
|
"""Mimics the llm_as_a_judge example: loop until the evaluator passes the outline."""
|
|
outline_model = FakeModel()
|
|
outline_model.add_multiple_turn_outputs(
|
|
[
|
|
[get_text_message("Outline v1")],
|
|
[get_text_message("Outline v2")],
|
|
]
|
|
)
|
|
|
|
judge_model = FakeModel()
|
|
judge_model.add_multiple_turn_outputs(
|
|
[
|
|
[
|
|
get_final_output_message(
|
|
json.dumps(
|
|
{
|
|
"response": {
|
|
"feedback": "Add more suspense",
|
|
"score": "needs_improvement",
|
|
}
|
|
}
|
|
)
|
|
)
|
|
],
|
|
[
|
|
get_final_output_message(
|
|
json.dumps({"response": {"feedback": "Looks good", "score": "pass"}})
|
|
)
|
|
],
|
|
]
|
|
)
|
|
|
|
outline_agent = Agent(name="outline", model=outline_model)
|
|
judge_agent = Agent(name="judge", model=judge_model, output_type=EvaluationFeedback)
|
|
|
|
conversation: list[TResponseInputItem] = [get_text_input_item("Tell me a space story")]
|
|
latest_outline: str | None = None
|
|
|
|
for expected_outline, expected_score in [
|
|
("Outline v1", "needs_improvement"),
|
|
("Outline v2", "pass"),
|
|
]:
|
|
outline_result = await Runner.run(outline_agent, conversation)
|
|
latest_outline = ItemHelpers.text_message_outputs(outline_result.new_items)
|
|
assert latest_outline == expected_outline
|
|
|
|
conversation = outline_result.to_input_list()
|
|
|
|
judge_result = await Runner.run(judge_agent, conversation)
|
|
feedback = judge_result.final_output
|
|
assert isinstance(feedback, EvaluationFeedback)
|
|
assert feedback.score == expected_score
|
|
|
|
if feedback.score == "pass":
|
|
break
|
|
|
|
conversation.append({"content": f"Feedback: {feedback.feedback}", "role": "user"})
|
|
|
|
assert latest_outline == "Outline v2"
|
|
assert len(conversation) == 4
|
|
assert judge_model.last_turn_args["input"] == conversation
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parallel_translation_flow_reuses_runner_outputs() -> None:
|
|
"""Covers the parallelization example by feeding multiple translations into a picker agent."""
|
|
translation_model = FakeModel()
|
|
translation_model.add_multiple_turn_outputs(
|
|
[
|
|
[get_text_message("Uno")],
|
|
[get_text_message("Dos")],
|
|
[get_text_message("Tres")],
|
|
]
|
|
)
|
|
spanish_agent = Agent(name="spanish_agent", model=translation_model)
|
|
|
|
picker_model = FakeModel()
|
|
picker_model.set_next_output([get_text_message("Pick: Dos")])
|
|
picker_agent = Agent(name="picker", model=picker_model)
|
|
|
|
translations: list[str] = []
|
|
for _ in range(3):
|
|
result = await Runner.run(spanish_agent, input="Hello")
|
|
translations.append(ItemHelpers.text_message_outputs(result.new_items))
|
|
|
|
combined = "\n\n".join(translations)
|
|
picker_result = await Runner.run(
|
|
picker_agent,
|
|
input=f"Input: Hello\n\nTranslations:\n{combined}",
|
|
)
|
|
|
|
assert translations == ["Uno", "Dos", "Tres"]
|
|
assert picker_result.final_output == "Pick: Dos"
|
|
assert picker_model.last_turn_args["input"] == [
|
|
{"content": f"Input: Hello\n\nTranslations:\n{combined}", "role": "user"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deterministic_story_flow_stops_when_checker_blocks() -> None:
|
|
"""Mimics deterministic flow: stop early when quality gate fails."""
|
|
outline_model = FakeModel()
|
|
outline_model.set_next_output([get_text_message("Outline v1")])
|
|
checker_model = FakeModel()
|
|
checker_model.set_next_output(
|
|
[
|
|
get_final_output_message(
|
|
json.dumps({"response": {"good_quality": False, "is_scifi": True}})
|
|
)
|
|
]
|
|
)
|
|
story_model = FakeModel()
|
|
story_model.set_next_output(RuntimeError("story should not run"))
|
|
|
|
outline_agent = Agent(name="outline", model=outline_model)
|
|
checker_agent = Agent(
|
|
name="checker",
|
|
model=checker_model,
|
|
output_type=OutlineCheckerOutput,
|
|
)
|
|
story_agent = Agent(name="story", model=story_model)
|
|
|
|
inputs: list[TResponseInputItem] = [get_text_input_item("Sci-fi please")]
|
|
outline_result = await Runner.run(outline_agent, inputs)
|
|
inputs = outline_result.to_input_list()
|
|
|
|
checker_result = await Runner.run(checker_agent, inputs)
|
|
decision = checker_result.final_output
|
|
|
|
assert isinstance(decision, OutlineCheckerOutput)
|
|
assert decision.good_quality is False
|
|
assert decision.is_scifi is True
|
|
if decision.good_quality and decision.is_scifi:
|
|
await Runner.run(story_agent, outline_result.final_output)
|
|
assert story_model.first_turn_args is None, "story agent should never be invoked when gated"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deterministic_story_flow_runs_story_on_pass() -> None:
|
|
"""Mimics deterministic flow: run full path when checker approves."""
|
|
outline_model = FakeModel()
|
|
outline_model.set_next_output([get_text_message("Outline ready")])
|
|
checker_model = FakeModel()
|
|
checker_model.set_next_output(
|
|
[
|
|
get_final_output_message(
|
|
json.dumps({"response": {"good_quality": True, "is_scifi": True}})
|
|
)
|
|
]
|
|
)
|
|
story_model = FakeModel()
|
|
story_model.set_next_output([get_text_message("Final story")])
|
|
|
|
outline_agent = Agent(name="outline", model=outline_model)
|
|
checker_agent = Agent(
|
|
name="checker",
|
|
model=checker_model,
|
|
output_type=OutlineCheckerOutput,
|
|
)
|
|
story_agent = Agent(name="story", model=story_model)
|
|
|
|
inputs: list[TResponseInputItem] = [get_text_input_item("Sci-fi please")]
|
|
outline_result = await Runner.run(outline_agent, inputs)
|
|
inputs = outline_result.to_input_list()
|
|
|
|
checker_result = await Runner.run(checker_agent, inputs)
|
|
decision = checker_result.final_output
|
|
assert isinstance(decision, OutlineCheckerOutput)
|
|
assert decision.good_quality is True
|
|
assert decision.is_scifi is True
|
|
|
|
story_result = await Runner.run(story_agent, outline_result.final_output)
|
|
assert story_result.final_output == "Final story"
|
|
assert story_model.last_turn_args["input"] == [{"content": "Outline ready", "role": "user"}]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_routing_stream_emits_text_and_updates_inputs() -> None:
|
|
"""Mimics routing example stream: text deltas flow through and input history updates."""
|
|
model = FakeModel()
|
|
model.set_next_output([get_text_message("Bonjour")])
|
|
triage_agent = Agent(name="triage_agent", model=model)
|
|
|
|
streamed = Runner.run_streamed(triage_agent, input="Salut")
|
|
|
|
deltas: list[str] = []
|
|
async for event in streamed.stream_events():
|
|
if isinstance(event, RawResponsesStreamEvent) and isinstance(
|
|
event.data, ResponseTextDeltaEvent
|
|
):
|
|
deltas.append(event.data.delta)
|
|
|
|
assert "".join(deltas) == "Bonjour"
|
|
assert streamed.final_output == "Bonjour"
|
|
assert len(streamed.new_items) == 1
|
|
input_list = streamed.to_input_list()
|
|
assert len(input_list) == 2
|
|
assert input_list[0] == {"content": "Salut", "role": "user"}
|
|
assistant_item = input_list[1]
|
|
assert isinstance(assistant_item, dict)
|
|
assert assistant_item.get("role") == "assistant"
|
|
assert assistant_item.get("type") == "message"
|
|
content: Any = assistant_item.get("content")
|
|
assert isinstance(content, list)
|
|
first_content = content[0]
|
|
assert isinstance(first_content, dict)
|
|
assert first_content.get("text") == "Bonjour"
|
|
|
|
|
|
class MathHomeworkOutput(BaseModel):
|
|
reasoning: str
|
|
is_math_homework: bool
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_input_guardrail_agent_trips_and_returns_info() -> None:
|
|
"""Mimics math guardrail example: guardrail agent runs and trips before main agent completes."""
|
|
guardrail_model = FakeModel()
|
|
guardrail_model.set_next_output(
|
|
[
|
|
get_final_output_message(
|
|
json.dumps({"reasoning": "math detected", "is_math_homework": True})
|
|
)
|
|
]
|
|
)
|
|
guardrail_agent = Agent(name="guardrail", model=guardrail_model, output_type=MathHomeworkOutput)
|
|
|
|
@input_guardrail
|
|
async def math_guardrail(
|
|
context: RunContextWrapper[None], agent: Agent, input: str | list[TResponseInputItem]
|
|
) -> GuardrailFunctionOutput:
|
|
result = await Runner.run(guardrail_agent, input, context=context.context)
|
|
output = result.final_output_as(MathHomeworkOutput)
|
|
return GuardrailFunctionOutput(
|
|
output_info=output, tripwire_triggered=output.is_math_homework
|
|
)
|
|
|
|
main_model = FakeModel()
|
|
main_model.set_next_output([get_text_message("Should not run")])
|
|
main_agent = Agent(name="main", model=main_model, input_guardrails=[math_guardrail])
|
|
|
|
with pytest.raises(InputGuardrailTripwireTriggered) as excinfo:
|
|
await Runner.run(main_agent, "Solve 2x+5=11")
|
|
|
|
guardrail_result = excinfo.value.guardrail_result
|
|
assert isinstance(guardrail_result.output.output_info, MathHomeworkOutput)
|
|
assert guardrail_result.output.output_info.is_math_homework is True
|
|
assert guardrail_result.output.output_info.reasoning == "math detected"
|
|
|
|
|
|
class MessageOutput(BaseModel):
|
|
reasoning: str
|
|
response: str
|
|
user_name: str | None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_output_guardrail_blocks_sensitive_data() -> None:
|
|
"""Mimics sensitive data guardrail example: trips when phone number is present."""
|
|
|
|
@output_guardrail
|
|
async def sensitive_data_check(
|
|
context: RunContextWrapper, agent: Agent, output: MessageOutput
|
|
) -> GuardrailFunctionOutput:
|
|
contains_phone = "650" in output.response or "650" in output.reasoning
|
|
return GuardrailFunctionOutput(
|
|
output_info={"contains_phone": contains_phone},
|
|
tripwire_triggered=contains_phone,
|
|
)
|
|
|
|
model = FakeModel()
|
|
model.set_next_output(
|
|
[
|
|
get_final_output_message(
|
|
json.dumps(
|
|
{
|
|
"reasoning": "User shared phone 650-123-4567",
|
|
"response": "Thanks!",
|
|
"user_name": None,
|
|
}
|
|
)
|
|
)
|
|
]
|
|
)
|
|
agent = Agent(
|
|
name="Assistant",
|
|
model=model,
|
|
output_type=MessageOutput,
|
|
output_guardrails=[sensitive_data_check],
|
|
)
|
|
|
|
with pytest.raises(OutputGuardrailTripwireTriggered) as excinfo:
|
|
await Runner.run(agent, "My phone number is 650-123-4567.")
|
|
|
|
guardrail_output = excinfo.value.guardrail_result.output.output_info
|
|
assert isinstance(guardrail_output, dict)
|
|
assert guardrail_output["contains_phone"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_guardrail_style_cancel_after_threshold() -> None:
|
|
"""Mimics streaming guardrail example: stop streaming once threshold is reached."""
|
|
model = FakeModel()
|
|
model.set_next_output(
|
|
[
|
|
get_text_message("Chunk1 "),
|
|
get_text_message("Chunk2 "),
|
|
get_text_message("Chunk3"),
|
|
]
|
|
)
|
|
agent = Agent(name="talkative", model=model)
|
|
|
|
streamed = Runner.run_streamed(agent, input="Start")
|
|
|
|
deltas: list[str] = []
|
|
async for event in streamed.stream_events():
|
|
if isinstance(event, RawResponsesStreamEvent) and isinstance(
|
|
event.data, ResponseTextDeltaEvent
|
|
):
|
|
deltas.append(event.data.delta)
|
|
if len("".join(deltas)) >= len("Chunk1 Chunk2 "):
|
|
streamed.cancel(mode="immediate")
|
|
|
|
collected = "".join(deltas)
|
|
assert "Chunk1" in collected
|
|
assert "Chunk3" not in collected
|
|
assert streamed.final_output is None
|
|
assert streamed.is_complete is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_cancel_after_turn_allows_turn_completion() -> None:
|
|
"""Ensure cancel(after_turn) lets the current turn finish and final_output is populated."""
|
|
model = FakeModel()
|
|
model.set_next_output([get_text_message("Hello"), get_text_message("World")])
|
|
agent = Agent(name="talkative", model=model)
|
|
|
|
streamed = Runner.run_streamed(agent, input="Hi")
|
|
|
|
deltas: list[str] = []
|
|
async for event in streamed.stream_events():
|
|
if isinstance(event, RawResponsesStreamEvent) and isinstance(
|
|
event.data, ResponseTextDeltaEvent
|
|
):
|
|
deltas.append(event.data.delta)
|
|
streamed.cancel(mode="after_turn")
|
|
|
|
assert "".join(deltas).startswith("Hello")
|
|
assert streamed.final_output == "World"
|
|
assert streamed.is_complete is True
|
|
assert len(streamed.new_items) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_handoff_emits_agent_updated_event() -> None:
|
|
"""Mimics routing handoff stream: emits AgentUpdatedStreamEvent and switches agent."""
|
|
delegate_model = FakeModel()
|
|
delegate_model.set_next_output([get_text_message("delegate reply")])
|
|
delegate_agent = Agent(name="delegate", model=delegate_model)
|
|
|
|
triage_model = FakeModel()
|
|
triage_model.set_next_output(
|
|
[
|
|
get_text_message("triage summary"),
|
|
get_handoff_tool_call(delegate_agent),
|
|
]
|
|
)
|
|
triage_agent = Agent(name="triage", model=triage_model, handoffs=[delegate_agent])
|
|
|
|
streamed = Runner.run_streamed(triage_agent, input="Help me")
|
|
|
|
agent_updates: list[AgentUpdatedStreamEvent] = []
|
|
async for event in streamed.stream_events():
|
|
if isinstance(event, AgentUpdatedStreamEvent):
|
|
agent_updates.append(event)
|
|
|
|
assert streamed.final_output == "delegate reply"
|
|
assert streamed.last_agent == delegate_agent
|
|
assert len(agent_updates) >= 1
|
|
assert any(update.new_agent == delegate_agent for update in agent_updates)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agent_as_tool_streaming_example_collects_events() -> None:
|
|
"""Mimics agents_as_tools_streaming example: on_stream receives nested streaming events."""
|
|
billing_agent = Agent(name="billing")
|
|
|
|
received: list[AgentToolStreamEvent] = []
|
|
|
|
async def on_stream(event: AgentToolStreamEvent) -> None:
|
|
received.append(event)
|
|
|
|
billing_tool = billing_agent.as_tool(
|
|
tool_name="billing_agent",
|
|
tool_description="Answer billing questions",
|
|
on_stream=on_stream,
|
|
)
|
|
|
|
async def fake_invoke(ctx, input: str) -> str:
|
|
event_payload: AgentToolStreamEvent = {
|
|
"event": RawResponsesStreamEvent(data=cast(Any, {"type": "output_text_delta"})),
|
|
"agent": billing_agent,
|
|
"tool_call": ctx.tool_call,
|
|
}
|
|
await on_stream(event_payload)
|
|
return "Billing: $100"
|
|
|
|
billing_tool.on_invoke_tool = fake_invoke
|
|
|
|
main_model = FakeModel()
|
|
main_model.add_multiple_turn_outputs(
|
|
[
|
|
[get_function_tool_call("billing_agent", json.dumps({"input": "Need bill"}))],
|
|
[get_text_message("Final answer")],
|
|
]
|
|
)
|
|
|
|
main_agent = Agent(
|
|
name="support",
|
|
model=main_model,
|
|
tools=[billing_tool],
|
|
model_settings=ModelSettings(tool_choice="required"),
|
|
)
|
|
|
|
result = await Runner.run(main_agent, "How much is my bill?")
|
|
|
|
assert result.final_output == "Final answer"
|
|
assert received, "on_stream should capture nested streaming events"
|
|
assert all(event["agent"] == billing_agent for event in received)
|
|
assert all(
|
|
event["tool_call"] and event["tool_call"].name == "billing_agent" for event in received
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sandbox_agents_as_tools_example_serializes_structured_reviews() -> None:
|
|
pricing_model = FakeModel()
|
|
pricing_model.set_next_output(
|
|
[
|
|
get_final_output_message(
|
|
json.dumps(
|
|
{
|
|
"requested_discount_percent": 15,
|
|
"requested_term_months": 24,
|
|
"pricing_risk": "medium",
|
|
"summary": "Discount ask is above target band.",
|
|
"recommended_next_step": "Trade discount for a stronger give-get.",
|
|
"evidence_files": ["pricing_summary.md", "commercial_notes.md"],
|
|
}
|
|
)
|
|
)
|
|
]
|
|
)
|
|
rollout_model = FakeModel()
|
|
rollout_model.set_next_output(
|
|
[
|
|
get_final_output_message(
|
|
json.dumps(
|
|
{
|
|
"rollout_risk": "medium",
|
|
"summary": "Launch timing is compressed.",
|
|
"blockers": [
|
|
"Regional admin training is incomplete.",
|
|
"SSO migration lands in week 2.",
|
|
],
|
|
"recommended_next_step": "Require a phased rollout plan.",
|
|
"evidence_files": ["rollout_plan.md", "support_history.md"],
|
|
}
|
|
)
|
|
)
|
|
]
|
|
)
|
|
orchestrator_model = FakeModel()
|
|
orchestrator_model.add_multiple_turn_outputs(
|
|
[
|
|
[
|
|
get_function_tool_call(
|
|
"review_pricing_packet",
|
|
json.dumps({"input": "Review pricing"}),
|
|
call_id="outer_pricing",
|
|
),
|
|
get_function_tool_call(
|
|
"review_rollout_risk",
|
|
json.dumps({"input": "Review rollout"}),
|
|
call_id="outer_rollout",
|
|
),
|
|
get_function_tool_call(
|
|
"get_discount_approval_rule",
|
|
json.dumps({"discount_percent": 15}),
|
|
call_id="outer_approval",
|
|
),
|
|
],
|
|
[get_text_message("Recommendation complete")],
|
|
]
|
|
)
|
|
|
|
@function_tool
|
|
def get_discount_approval_rule(discount_percent: int) -> str:
|
|
if discount_percent <= 10:
|
|
return "AE"
|
|
if discount_percent <= 15:
|
|
return "RSD"
|
|
return "Finance + RSD"
|
|
|
|
pricing_agent = Agent(
|
|
name="pricing",
|
|
model=pricing_model,
|
|
output_type=PricingPacketReview,
|
|
)
|
|
rollout_agent = Agent(
|
|
name="rollout",
|
|
model=rollout_model,
|
|
output_type=RolloutRiskReview,
|
|
)
|
|
orchestrator = Agent(
|
|
name="orchestrator",
|
|
model=orchestrator_model,
|
|
tools=[
|
|
pricing_agent.as_tool(
|
|
"review_pricing_packet",
|
|
"Pricing review",
|
|
custom_output_extractor=_structured_tool_output_extractor,
|
|
),
|
|
rollout_agent.as_tool(
|
|
"review_rollout_risk",
|
|
"Rollout review",
|
|
custom_output_extractor=_structured_tool_output_extractor,
|
|
),
|
|
get_discount_approval_rule,
|
|
],
|
|
model_settings=ModelSettings(tool_choice="required"),
|
|
)
|
|
|
|
result = await Runner.run(orchestrator, "Review the renewal")
|
|
|
|
assert result.final_output == "Recommendation complete"
|
|
outer_second_turn_input = cast(
|
|
list[dict[str, Any]],
|
|
orchestrator_model.last_turn_args["input"],
|
|
)
|
|
outer_tool_outputs = [
|
|
item for item in outer_second_turn_input if item.get("type") == "function_call_output"
|
|
]
|
|
assert outer_tool_outputs == [
|
|
{
|
|
"call_id": "outer_pricing",
|
|
"output": json.dumps(
|
|
{
|
|
"evidence_files": ["pricing_summary.md", "commercial_notes.md"],
|
|
"pricing_risk": "medium",
|
|
"recommended_next_step": "Trade discount for a stronger give-get.",
|
|
"requested_discount_percent": 15,
|
|
"requested_term_months": 24,
|
|
"summary": "Discount ask is above target band.",
|
|
},
|
|
sort_keys=True,
|
|
),
|
|
"type": "function_call_output",
|
|
},
|
|
{
|
|
"call_id": "outer_rollout",
|
|
"output": json.dumps(
|
|
{
|
|
"blockers": [
|
|
"Regional admin training is incomplete.",
|
|
"SSO migration lands in week 2.",
|
|
],
|
|
"evidence_files": ["rollout_plan.md", "support_history.md"],
|
|
"recommended_next_step": "Require a phased rollout plan.",
|
|
"rollout_risk": "medium",
|
|
"summary": "Launch timing is compressed.",
|
|
},
|
|
sort_keys=True,
|
|
),
|
|
"type": "function_call_output",
|
|
},
|
|
{
|
|
"call_id": "outer_approval",
|
|
"output": "RSD",
|
|
"type": "function_call_output",
|
|
},
|
|
]
|
|
|
|
|
|
def test_docker_runner_formats_tool_calls_without_dumping_run_item() -> None:
|
|
assert (
|
|
_format_tool_call(
|
|
{
|
|
"type": "function_call",
|
|
"name": "read_file",
|
|
"arguments": json.dumps({"path": "README.md"}),
|
|
}
|
|
)
|
|
== '[tool call] read_file: {"path": "README.md"}'
|
|
)
|
|
|
|
assert (
|
|
_format_tool_call(
|
|
{
|
|
"type": "shell_call",
|
|
"action": {
|
|
"commands": ["find . -maxdepth 2 -type f", "cat README.md"],
|
|
},
|
|
}
|
|
)
|
|
== "[tool call] shell: find . -maxdepth 2 -type f; cat README.md"
|
|
)
|
|
|
|
|
|
def test_docker_runner_formats_tool_output_as_readable_block() -> None:
|
|
assert _format_tool_output("$ ls\nREADME.md\nsrc\n") == "[tool output]\n$ ls\nREADME.md\nsrc\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forcing_tool_use_behaviors_align_with_example() -> None:
|
|
"""Mimics forcing_tool_use example: default vs first_tool vs custom behaviors."""
|
|
|
|
@function_tool
|
|
def get_weather(city: str) -> str:
|
|
return f"{city}: Sunny"
|
|
|
|
# default: run_llm_again -> model responds after tool call
|
|
default_model = FakeModel()
|
|
default_model.add_multiple_turn_outputs(
|
|
[
|
|
[
|
|
get_text_message("Tool call coming"),
|
|
get_function_tool_call("get_weather", json.dumps({"city": "Tokyo"})),
|
|
],
|
|
[get_text_message("Done after tool")],
|
|
]
|
|
)
|
|
|
|
default_agent = Agent(
|
|
name="default",
|
|
model=default_model,
|
|
tools=[get_weather],
|
|
tool_use_behavior="run_llm_again",
|
|
model_settings=ModelSettings(tool_choice=None),
|
|
)
|
|
|
|
default_result = await Runner.run(default_agent, "Weather?")
|
|
assert default_result.final_output == "Done after tool"
|
|
assert len(default_result.raw_responses) == 2
|
|
|
|
# first_tool: stop_on_first_tool -> final output from first tool result
|
|
first_model = FakeModel()
|
|
first_model.set_next_output(
|
|
[
|
|
get_text_message("Tool call coming"),
|
|
get_function_tool_call("get_weather", json.dumps({"city": "Paris"})),
|
|
]
|
|
)
|
|
|
|
first_agent = Agent(
|
|
name="first",
|
|
model=first_model,
|
|
tools=[get_weather],
|
|
tool_use_behavior="stop_on_first_tool",
|
|
model_settings=ModelSettings(tool_choice="required"),
|
|
)
|
|
|
|
first_result = await Runner.run(first_agent, "Weather?")
|
|
assert first_result.final_output == "Paris: Sunny"
|
|
assert len(first_result.raw_responses) == 1
|
|
|
|
# custom: uses custom tool_use_behavior to format output, still with required tool choice
|
|
async def custom_tool_use_behavior(
|
|
context: RunContextWrapper[Any], results: list[FunctionToolResult]
|
|
) -> ToolsToFinalOutputResult:
|
|
return ToolsToFinalOutputResult(
|
|
is_final_output=True, final_output=f"Custom:{results[0].output}"
|
|
)
|
|
|
|
custom_model = FakeModel()
|
|
custom_model.set_next_output(
|
|
[
|
|
get_text_message("Tool call coming"),
|
|
get_function_tool_call("get_weather", json.dumps({"city": "Berlin"})),
|
|
]
|
|
)
|
|
|
|
custom_agent = Agent(
|
|
name="custom",
|
|
model=custom_model,
|
|
tools=[get_weather],
|
|
tool_use_behavior=custom_tool_use_behavior,
|
|
model_settings=ModelSettings(tool_choice="required"),
|
|
)
|
|
|
|
custom_result = await Runner.run(custom_agent, "Weather?")
|
|
assert custom_result.final_output == "Custom:Berlin: Sunny"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_routing_multi_turn_continues_with_handoff_agent() -> None:
|
|
"""Mimics routing example multi-turn: first handoff, then continue with delegated agent."""
|
|
delegate_model = FakeModel()
|
|
delegate_model.set_next_output([get_text_message("Bonjour")])
|
|
delegate_agent = Agent(name="delegate", model=delegate_model)
|
|
|
|
triage_model = FakeModel()
|
|
triage_model.add_multiple_turn_outputs(
|
|
[
|
|
[get_handoff_tool_call(delegate_agent)],
|
|
[get_text_message("handoff completed")],
|
|
]
|
|
)
|
|
triage_agent = Agent(name="triage", model=triage_model, handoffs=[delegate_agent])
|
|
|
|
first_result = await Runner.run(triage_agent, "Help me in French")
|
|
assert first_result.final_output == "Bonjour"
|
|
assert first_result.last_agent == delegate_agent
|
|
|
|
# Next user turn continues with delegate.
|
|
delegate_model.set_next_output([get_text_message("Encore?")])
|
|
follow_up_input = first_result.to_input_list()
|
|
follow_up_input.append({"role": "user", "content": "Encore!"})
|
|
|
|
second_result = await Runner.run(delegate_agent, follow_up_input)
|
|
assert second_result.final_output == "Encore?"
|
|
assert delegate_model.last_turn_args["input"] == follow_up_input
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agents_as_tools_conditional_enabling_matches_preference() -> None:
|
|
"""Mimics agents_as_tools_conditional example: only enabled tools are invoked per preference."""
|
|
|
|
class AppContext(BaseModel):
|
|
language_preference: str
|
|
|
|
def french_spanish_enabled(ctx: RunContextWrapper[AppContext], _agent: AgentBase) -> bool:
|
|
return ctx.context.language_preference in ["french_spanish", "european"]
|
|
|
|
def european_enabled(ctx: RunContextWrapper[AppContext], _agent: AgentBase) -> bool:
|
|
return ctx.context.language_preference == "european"
|
|
|
|
scenarios = [
|
|
("spanish_only", {"respond_spanish"}),
|
|
("french_spanish", {"respond_spanish", "respond_french"}),
|
|
("european", {"respond_spanish", "respond_french", "respond_italian"}),
|
|
]
|
|
|
|
for preference, expected_tools in scenarios:
|
|
spanish_model = FakeModel()
|
|
spanish_model.set_next_output([get_text_message("ES hola")])
|
|
spanish_agent = Agent(name="spanish", model=spanish_model)
|
|
|
|
french_model = FakeModel()
|
|
french_model.set_next_output([get_text_message("FR bonjour")])
|
|
french_agent = Agent(name="french", model=french_model)
|
|
|
|
italian_model = FakeModel()
|
|
italian_model.set_next_output([get_text_message("IT ciao")])
|
|
italian_agent = Agent(name="italian", model=italian_model)
|
|
|
|
orchestrator_model = FakeModel()
|
|
# Build tool calls only for expected tools to avoid missing-tool errors.
|
|
tool_calls = [
|
|
get_function_tool_call(tool_name, json.dumps({"input": "Hi"}))
|
|
for tool_name in sorted(expected_tools)
|
|
]
|
|
orchestrator_model.add_multiple_turn_outputs([tool_calls, [get_text_message("Done")]])
|
|
|
|
context = AppContext(language_preference=preference)
|
|
|
|
orchestrator = Agent(
|
|
name="orchestrator",
|
|
model=orchestrator_model,
|
|
tools=[
|
|
spanish_agent.as_tool(
|
|
tool_name="respond_spanish",
|
|
tool_description="Spanish",
|
|
is_enabled=True,
|
|
),
|
|
french_agent.as_tool(
|
|
tool_name="respond_french",
|
|
tool_description="French",
|
|
is_enabled=french_spanish_enabled,
|
|
),
|
|
italian_agent.as_tool(
|
|
tool_name="respond_italian",
|
|
tool_description="Italian",
|
|
is_enabled=european_enabled,
|
|
),
|
|
],
|
|
model_settings=ModelSettings(tool_choice="required"),
|
|
)
|
|
|
|
result = await Runner.run(orchestrator, "Hello", context=context)
|
|
|
|
assert result.final_output == "Done"
|
|
assert (
|
|
spanish_model.first_turn_args is not None
|
|
if "respond_spanish" in expected_tools
|
|
else spanish_model.first_turn_args is None
|
|
)
|
|
assert (
|
|
french_model.first_turn_args is not None
|
|
if "respond_french" in expected_tools
|
|
else french_model.first_turn_args is None
|
|
)
|
|
assert (
|
|
italian_model.first_turn_args is not None
|
|
if "respond_italian" in expected_tools
|
|
else italian_model.first_turn_args is None
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agents_as_tools_orchestrator_runs_multiple_translations() -> None:
|
|
"""Orchestrator calls multiple translation agent tools then summarizes."""
|
|
spanish_model = FakeModel()
|
|
spanish_model.set_next_output([get_text_message("ES hola")])
|
|
spanish_agent = Agent(name="spanish", model=spanish_model)
|
|
|
|
french_model = FakeModel()
|
|
french_model.set_next_output([get_text_message("FR bonjour")])
|
|
french_agent = Agent(name="french", model=french_model)
|
|
|
|
orchestrator_model = FakeModel()
|
|
orchestrator_model.add_multiple_turn_outputs(
|
|
[
|
|
[get_function_tool_call("translate_to_spanish", json.dumps({"input": "Hi"}))],
|
|
[get_function_tool_call("translate_to_french", json.dumps({"input": "Hi"}))],
|
|
[get_text_message("Summary complete")],
|
|
]
|
|
)
|
|
|
|
orchestrator = Agent(
|
|
name="orchestrator",
|
|
model=orchestrator_model,
|
|
tools=[
|
|
spanish_agent.as_tool("translate_to_spanish", "Spanish"),
|
|
french_agent.as_tool("translate_to_french", "French"),
|
|
],
|
|
)
|
|
|
|
result = await Runner.run(orchestrator, "Hi")
|
|
|
|
assert result.final_output == "Summary complete"
|
|
assert spanish_model.last_turn_args["input"] == [{"content": "Hi", "role": "user"}]
|
|
assert french_model.last_turn_args["input"] == [{"content": "Hi", "role": "user"}]
|
|
assert len(result.raw_responses) == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agents_as_tools_subagent_cancellation_preserves_parent_final_output() -> None:
|
|
"""A cancelled nested subagent should not drop sibling outputs from the parent turn."""
|
|
|
|
async def _cancel_tool() -> str:
|
|
raise asyncio.CancelledError("tool-cancelled")
|
|
|
|
success_model = FakeModel()
|
|
success_model.set_next_output([get_text_message("Status: ok")])
|
|
success_agent = Agent(name="status", model=success_model)
|
|
|
|
observability_model = FakeModel()
|
|
observability_model.set_next_output(
|
|
[get_function_tool_call("cancel_tool", "{}", call_id="inner_cancel")]
|
|
)
|
|
observability_agent = Agent(
|
|
name="observability",
|
|
model=observability_model,
|
|
tools=[function_tool(_cancel_tool, name_override="cancel_tool")],
|
|
model_settings=ModelSettings(tool_choice="required"),
|
|
)
|
|
|
|
orchestrator_model = FakeModel()
|
|
orchestrator_model.add_multiple_turn_outputs(
|
|
[
|
|
[
|
|
get_function_tool_call(
|
|
"status_agent",
|
|
json.dumps({"input": "Hi"}),
|
|
call_id="outer_status",
|
|
),
|
|
get_function_tool_call(
|
|
"observability_agent",
|
|
json.dumps({"input": "Hi"}),
|
|
call_id="outer_observability",
|
|
),
|
|
],
|
|
[get_text_message("Summary complete")],
|
|
]
|
|
)
|
|
|
|
orchestrator = Agent(
|
|
name="orchestrator",
|
|
model=orchestrator_model,
|
|
tools=[
|
|
success_agent.as_tool("status_agent", "Status"),
|
|
observability_agent.as_tool("observability_agent", "Observability"),
|
|
],
|
|
model_settings=ModelSettings(tool_choice="required"),
|
|
)
|
|
|
|
result = await Runner.run(orchestrator, "Hi")
|
|
|
|
assert result.final_output == "Summary complete"
|
|
assert len(result.raw_responses) == 2
|
|
assert success_model.last_turn_args["input"] == [{"content": "Hi", "role": "user"}]
|
|
assert observability_model.first_turn_args is not None
|
|
assert observability_model.first_turn_args["input"] == [{"content": "Hi", "role": "user"}]
|
|
|
|
second_turn_input = cast(list[dict[str, Any]], orchestrator_model.last_turn_args["input"])
|
|
tool_outputs = [
|
|
item for item in second_turn_input if item.get("type") == "function_call_output"
|
|
]
|
|
assert len(tool_outputs) == 2
|
|
assert tool_outputs[0] == {
|
|
"call_id": "outer_status",
|
|
"output": "Status: ok",
|
|
"type": "function_call_output",
|
|
}
|
|
assert tool_outputs[1]["call_id"] == "outer_observability"
|
|
assert tool_outputs[1]["type"] == "function_call_output"
|
|
assert tool_outputs[1]["output"].startswith(
|
|
"An error occurred while running the tool. Please try again. Error:"
|
|
)
|
|
assert "cancel" in tool_outputs[1]["output"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agents_as_tools_streaming_subagent_cancellation_preserves_parent_output() -> None:
|
|
"""A streaming nested subagent should retain sibling outputs after cancellation."""
|
|
|
|
async def _ok_tool() -> str:
|
|
return "Investigation: ok"
|
|
|
|
async def _cancel_tool() -> str:
|
|
raise asyncio.CancelledError("tool-cancelled")
|
|
|
|
received_events: list[AgentToolStreamEvent] = []
|
|
|
|
async def on_stream(event: AgentToolStreamEvent) -> None:
|
|
received_events.append(event)
|
|
|
|
status_model = FakeModel()
|
|
status_model.set_next_output([get_text_message("Status: ok")])
|
|
status_agent = Agent(name="status", model=status_model)
|
|
|
|
observability_model = FakeModel()
|
|
observability_model.add_multiple_turn_outputs(
|
|
[
|
|
[
|
|
get_function_tool_call("ok_tool", "{}", call_id="inner_ok"),
|
|
get_function_tool_call("cancel_tool", "{}", call_id="inner_cancel"),
|
|
],
|
|
[get_text_message("Nested summary")],
|
|
]
|
|
)
|
|
observability_agent = Agent(
|
|
name="observability",
|
|
model=observability_model,
|
|
tools=[
|
|
function_tool(_ok_tool, name_override="ok_tool"),
|
|
function_tool(_cancel_tool, name_override="cancel_tool"),
|
|
],
|
|
model_settings=ModelSettings(tool_choice="required"),
|
|
)
|
|
|
|
orchestrator_model = FakeModel()
|
|
orchestrator_model.add_multiple_turn_outputs(
|
|
[
|
|
[
|
|
get_function_tool_call(
|
|
"status_agent",
|
|
json.dumps({"input": "Hi"}),
|
|
call_id="outer_status",
|
|
),
|
|
get_function_tool_call(
|
|
"observability_agent",
|
|
json.dumps({"input": "Hi"}),
|
|
call_id="outer_observability",
|
|
),
|
|
],
|
|
[get_text_message("Summary complete")],
|
|
]
|
|
)
|
|
|
|
orchestrator = Agent(
|
|
name="orchestrator",
|
|
model=orchestrator_model,
|
|
tools=[
|
|
status_agent.as_tool("status_agent", "Status"),
|
|
observability_agent.as_tool(
|
|
"observability_agent",
|
|
"Observability",
|
|
on_stream=on_stream,
|
|
),
|
|
],
|
|
model_settings=ModelSettings(tool_choice="required"),
|
|
)
|
|
|
|
result = await Runner.run(orchestrator, "Hi")
|
|
|
|
assert result.final_output == "Summary complete"
|
|
assert len(result.raw_responses) == 2
|
|
assert received_events, "on_stream should confirm the nested streaming path ran"
|
|
assert status_model.last_turn_args["input"] == [{"content": "Hi", "role": "user"}]
|
|
assert observability_model.last_turn_args is not None
|
|
|
|
nested_second_turn_input = cast(
|
|
list[dict[str, Any]],
|
|
observability_model.last_turn_args["input"],
|
|
)
|
|
nested_tool_outputs = [
|
|
item for item in nested_second_turn_input if item.get("type") == "function_call_output"
|
|
]
|
|
assert nested_tool_outputs == [
|
|
{
|
|
"call_id": "inner_ok",
|
|
"output": "Investigation: ok",
|
|
"type": "function_call_output",
|
|
},
|
|
{
|
|
"call_id": "inner_cancel",
|
|
"output": (
|
|
"An error occurred while running the tool. Please try again. Error: tool-cancelled"
|
|
),
|
|
"type": "function_call_output",
|
|
},
|
|
]
|
|
|
|
outer_second_turn_input = cast(
|
|
list[dict[str, Any]],
|
|
orchestrator_model.last_turn_args["input"],
|
|
)
|
|
outer_tool_outputs = [
|
|
item for item in outer_second_turn_input if item.get("type") == "function_call_output"
|
|
]
|
|
assert outer_tool_outputs == [
|
|
{
|
|
"call_id": "outer_status",
|
|
"output": "Status: ok",
|
|
"type": "function_call_output",
|
|
},
|
|
{
|
|
"call_id": "outer_observability",
|
|
"output": "Nested summary",
|
|
"type": "function_call_output",
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agents_as_tools_failure_error_function_none_reraises_cancelled_error() -> None:
|
|
"""Explicit None should preserve cancellation semantics for nested agent tools."""
|
|
|
|
async def _cancel_tool() -> str:
|
|
raise asyncio.CancelledError("tool-cancelled")
|
|
|
|
status_model = FakeModel()
|
|
status_model.set_next_output([get_text_message("Status: ok")])
|
|
status_agent = Agent(name="status", model=status_model)
|
|
|
|
observability_model = FakeModel()
|
|
observability_model.set_next_output(
|
|
[get_function_tool_call("cancel_tool", "{}", call_id="inner_cancel")]
|
|
)
|
|
observability_agent = Agent(
|
|
name="observability",
|
|
model=observability_model,
|
|
tools=[
|
|
function_tool(_cancel_tool, name_override="cancel_tool", failure_error_function=None)
|
|
],
|
|
model_settings=ModelSettings(tool_choice="required"),
|
|
)
|
|
|
|
orchestrator_model = FakeModel()
|
|
orchestrator_model.set_next_output(
|
|
[
|
|
get_function_tool_call(
|
|
"status_agent",
|
|
json.dumps({"input": "Hi"}),
|
|
call_id="outer_status",
|
|
),
|
|
get_function_tool_call(
|
|
"observability_agent",
|
|
json.dumps({"input": "Hi"}),
|
|
call_id="outer_observability",
|
|
),
|
|
]
|
|
)
|
|
|
|
orchestrator = Agent(
|
|
name="orchestrator",
|
|
model=orchestrator_model,
|
|
tools=[
|
|
status_agent.as_tool("status_agent", "Status"),
|
|
observability_agent.as_tool(
|
|
"observability_agent",
|
|
"Observability",
|
|
failure_error_function=None,
|
|
),
|
|
],
|
|
model_settings=ModelSettings(tool_choice="required"),
|
|
)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await Runner.run(orchestrator, "Hi")
|