Files
openai--openai-agents-python/tests/test_agent_as_tool.py
T
2026-07-13 12:39:17 +08:00

2750 lines
79 KiB
Python

from __future__ import annotations
import asyncio
import contextlib
import dataclasses
import json
from typing import Any, cast
import pytest
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
from pydantic import BaseModel, Field
from agents import (
Agent,
AgentBase,
AgentToolStreamEvent,
FunctionTool,
MessageOutputItem,
ModelBehaviorError,
ModelResponse,
RunConfig,
RunContextWrapper,
RunHooks,
Runner,
RunResult,
RunResultStreaming,
Session,
SessionSettings,
ToolApprovalItem,
ToolCallOutputItem,
TResponseInputItem,
Usage,
tool_namespace,
)
from agents.agent_tool_input import StructuredToolInputBuilderOptions
from agents.agent_tool_state import (
get_agent_tool_state_scope,
record_agent_tool_run_result,
set_agent_tool_state_scope,
)
from agents.run_context import _ApprovalRecord
from agents.run_state import _build_agent_map
from agents.stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent
from agents.tool_context import ToolContext
from tests.fake_model import FakeModel
from tests.mcp.helpers import FakeMCPServer
from tests.test_responses import get_function_tool_call, get_text_message
from tests.utils.hitl import make_function_tool_call
class BoolCtx(BaseModel):
enable_tools: bool
@pytest.mark.asyncio
async def test_agent_as_tool_is_enabled_bool():
"""Test that agent.as_tool() respects static boolean is_enabled parameter."""
# Create a simple agent
agent = Agent(
name="test_agent",
instructions="You are a test agent that says hello.",
)
# Create tool with is_enabled=False
disabled_tool = agent.as_tool(
tool_name="disabled_agent_tool",
tool_description="A disabled agent tool",
is_enabled=False,
)
# Create tool with is_enabled=True (default)
enabled_tool = agent.as_tool(
tool_name="enabled_agent_tool",
tool_description="An enabled agent tool",
is_enabled=True,
)
# Create another tool with default is_enabled (should be True)
default_tool = agent.as_tool(
tool_name="default_agent_tool",
tool_description="A default agent tool",
)
# Create test agent that uses these tools
orchestrator = Agent(
name="orchestrator",
instructions="You orchestrate other agents.",
tools=[disabled_tool, enabled_tool, default_tool],
)
# Test with any context
context = RunContextWrapper(BoolCtx(enable_tools=True))
# Get all tools - should filter out the disabled one
tools = await orchestrator.get_all_tools(context)
tool_names = [tool.name for tool in tools]
assert "enabled_agent_tool" in tool_names
assert "default_agent_tool" in tool_names
assert "disabled_agent_tool" not in tool_names
@pytest.mark.asyncio
async def test_agent_as_tool_is_enabled_callable():
"""Test that agent.as_tool() respects callable is_enabled parameter."""
# Create a simple agent
agent = Agent(
name="test_agent",
instructions="You are a test agent that says hello.",
)
# Create tool with callable is_enabled
async def cond_enabled(ctx: RunContextWrapper[BoolCtx], agent: AgentBase) -> bool:
return ctx.context.enable_tools
conditional_tool = agent.as_tool(
tool_name="conditional_agent_tool",
tool_description="A conditionally enabled agent tool",
is_enabled=cond_enabled,
)
# Create tool with lambda is_enabled
lambda_tool = agent.as_tool(
tool_name="lambda_agent_tool",
tool_description="A lambda enabled agent tool",
is_enabled=lambda ctx, agent: ctx.context.enable_tools,
)
# Create test agent that uses these tools
orchestrator = Agent(
name="orchestrator",
instructions="You orchestrate other agents.",
tools=[conditional_tool, lambda_tool],
)
# Test with enable_tools=False
context_disabled = RunContextWrapper(BoolCtx(enable_tools=False))
tools_disabled = await orchestrator.get_all_tools(context_disabled)
assert len(tools_disabled) == 0
# Test with enable_tools=True
context_enabled = RunContextWrapper(BoolCtx(enable_tools=True))
tools_enabled = await orchestrator.get_all_tools(context_enabled)
tool_names = [tool.name for tool in tools_enabled]
assert len(tools_enabled) == 2
assert "conditional_agent_tool" in tool_names
assert "lambda_agent_tool" in tool_names
@pytest.mark.asyncio
async def test_agent_as_tool_is_enabled_mixed():
"""Test agent.as_tool() with mixed enabled/disabled tools."""
# Create a simple agent
agent = Agent(
name="test_agent",
instructions="You are a test agent that says hello.",
)
# Create various tools with different is_enabled configurations
always_enabled = agent.as_tool(
tool_name="always_enabled",
tool_description="Always enabled tool",
is_enabled=True,
)
always_disabled = agent.as_tool(
tool_name="always_disabled",
tool_description="Always disabled tool",
is_enabled=False,
)
conditionally_enabled = agent.as_tool(
tool_name="conditionally_enabled",
tool_description="Conditionally enabled tool",
is_enabled=lambda ctx, agent: ctx.context.enable_tools,
)
default_enabled = agent.as_tool(
tool_name="default_enabled",
tool_description="Default enabled tool",
)
# Create test agent that uses these tools
orchestrator = Agent(
name="orchestrator",
instructions="You orchestrate other agents.",
tools=[always_enabled, always_disabled, conditionally_enabled, default_enabled],
)
# Test with enable_tools=False
context_disabled = RunContextWrapper(BoolCtx(enable_tools=False))
tools_disabled = await orchestrator.get_all_tools(context_disabled)
tool_names_disabled = [tool.name for tool in tools_disabled]
assert len(tools_disabled) == 2
assert "always_enabled" in tool_names_disabled
assert "default_enabled" in tool_names_disabled
assert "always_disabled" not in tool_names_disabled
assert "conditionally_enabled" not in tool_names_disabled
# Test with enable_tools=True
context_enabled = RunContextWrapper(BoolCtx(enable_tools=True))
tools_enabled = await orchestrator.get_all_tools(context_enabled)
tool_names_enabled = [tool.name for tool in tools_enabled]
assert len(tools_enabled) == 3
assert "always_enabled" in tool_names_enabled
assert "default_enabled" in tool_names_enabled
assert "conditionally_enabled" in tool_names_enabled
assert "always_disabled" not in tool_names_enabled
@pytest.mark.asyncio
async def test_agent_as_tool_is_enabled_preserves_other_params():
"""Test that is_enabled parameter doesn't interfere with other agent.as_tool() parameters."""
# Create a simple agent
agent = Agent(
name="test_agent",
instructions="You are a test agent that returns a greeting.",
)
# Custom output extractor
async def custom_extractor(result):
return f"CUSTOM: {result.new_items[-1].text if result.new_items else 'No output'}"
# Create tool with all parameters including is_enabled
tool = agent.as_tool(
tool_name="custom_tool_name",
tool_description="A custom tool with all parameters",
custom_output_extractor=custom_extractor,
is_enabled=True,
)
# Verify the tool was created with correct properties
assert tool.name == "custom_tool_name"
assert isinstance(tool, FunctionTool)
assert tool.description == "A custom tool with all parameters"
assert tool.is_enabled is True
# Verify tool is included when enabled
orchestrator = Agent(
name="orchestrator",
instructions="You orchestrate other agents.",
tools=[tool],
)
context = RunContextWrapper(BoolCtx(enable_tools=True))
tools = await orchestrator.get_all_tools(context)
assert len(tools) == 1
assert tools[0].name == "custom_tool_name"
@pytest.mark.asyncio
async def test_agent_as_tool_returns_final_output(monkeypatch: pytest.MonkeyPatch) -> None:
"""Agent tool should return final_output when no custom extractor is provided."""
agent = Agent(name="storyteller")
result = type(
"DummyResult",
(),
{"final_output": "Hello world"},
)()
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
assert starting_agent is agent
assert input == "hello"
return result
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
tool = agent.as_tool(
tool_name="story_tool",
tool_description="Tell a short story",
is_enabled=True,
)
assert isinstance(tool, FunctionTool)
tool_context = ToolContext(
context=None,
tool_name="story_tool",
tool_call_id="call_1",
tool_arguments='{"input": "hello"}',
)
output = await tool.on_invoke_tool(tool_context, '{"input": "hello"}')
assert output == "Hello world"
@pytest.mark.asyncio
async def test_agent_as_tool_custom_output_extractor(monkeypatch: pytest.MonkeyPatch) -> None:
"""Custom output extractors should receive the RunResult from Runner.run."""
agent = Agent(name="summarizer")
message = ResponseOutputMessage(
id="msg_2",
role="assistant",
status="completed",
type="message",
content=[
ResponseOutputText(
annotations=[],
text="Original text",
type="output_text",
logprobs=[],
)
],
)
class DummySession(Session):
session_id = "sess_123"
session_settings = SessionSettings()
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
return []
async def add_items(self, items: list[TResponseInputItem]) -> None:
return None
async def pop_item(self) -> TResponseInputItem | None:
return None
async def clear_session(self) -> None:
return None
dummy_session = DummySession()
class DummyResult:
def __init__(self, items: list[MessageOutputItem]) -> None:
self.new_items = items
run_result = DummyResult([MessageOutputItem(agent=agent, raw_item=message)])
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
assert starting_agent is agent
assert input == "summarize this"
assert isinstance(context, ToolContext)
assert context.tool_call_id == "call_2"
assert context.tool_name == "summary_tool"
assert max_turns == 7
assert hooks is hooks_obj
assert run_config is run_config_obj
assert previous_response_id == "resp_1"
assert conversation_id == "conv_1"
assert session is dummy_session
return run_result
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
async def extractor(result) -> str:
assert result is run_result
return "custom output"
hooks_obj = RunHooks[Any]()
run_config_obj = RunConfig(model="gpt-4.1-mini")
tool = agent.as_tool(
tool_name="summary_tool",
tool_description="Summarize input",
custom_output_extractor=extractor,
is_enabled=True,
run_config=run_config_obj,
max_turns=7,
hooks=hooks_obj,
previous_response_id="resp_1",
conversation_id="conv_1",
session=dummy_session,
)
assert isinstance(tool, FunctionTool)
tool_context = ToolContext(
context=None,
tool_name="summary_tool",
tool_call_id="call_2",
tool_arguments='{"input": "summarize this"}',
)
output = await tool.on_invoke_tool(tool_context, '{"input": "summarize this"}')
assert output == "custom output"
@pytest.mark.asyncio
async def test_agent_as_tool_fallback_uses_current_run_items_only(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = Agent(name="summarizer")
message = ResponseOutputMessage(
id="msg_current",
role="assistant",
status="completed",
type="message",
content=[
ResponseOutputText(
annotations=[],
text="Current run summary",
type="output_text",
logprobs=[],
)
],
)
class DummyResult:
def __init__(self) -> None:
self.final_output = ""
self.new_items = [
ToolCallOutputItem(
agent=agent,
raw_item={
"call_id": "call_current",
"output": "Current tool output",
"type": "function_call_output",
},
output="Current tool output",
),
MessageOutputItem(agent=agent, raw_item=message),
]
def to_input_list(self) -> list[dict[str, Any]]:
return [
{
"call_id": "call_old",
"output": "Old output from prior history",
"type": "function_call_output",
}
]
run_result = DummyResult()
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
del (
cls,
starting_agent,
input,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
)
return run_result
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
tool = agent.as_tool(
tool_name="summary_tool",
tool_description="Summarize current run output",
)
tool_context = ToolContext(
context=None,
tool_name="summary_tool",
tool_call_id="call_1",
tool_arguments='{"input": "hello"}',
)
output = await tool.on_invoke_tool(tool_context, '{"input": "hello"}')
assert output == "Current run summary"
@pytest.mark.asyncio
async def test_agent_as_tool_fallback_returns_most_recent_current_run_output(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = Agent(name="summarizer")
older_message = ResponseOutputMessage(
id="msg_older",
role="assistant",
status="completed",
type="message",
content=[
ResponseOutputText(
annotations=[],
text="Older message output",
type="output_text",
logprobs=[],
)
],
)
class DummyResult:
def __init__(self) -> None:
self.final_output = ""
self.new_items = [
MessageOutputItem(agent=agent, raw_item=older_message),
ToolCallOutputItem(
agent=agent,
raw_item={
"call_id": "call_current",
"output": "Newest tool output",
"type": "function_call_output",
},
output="Newest tool output",
),
]
run_result = DummyResult()
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
del (
cls,
starting_agent,
input,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
)
return run_result
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
tool = agent.as_tool(
tool_name="summary_tool",
tool_description="Summarize current run output",
)
tool_context = ToolContext(
context=None,
tool_name="summary_tool",
tool_call_id="call_1",
tool_arguments='{"input": "hello"}',
)
output = await tool.on_invoke_tool(tool_context, '{"input": "hello"}')
assert output == "Newest tool output"
@pytest.mark.asyncio
async def test_agent_as_tool_extractor_can_access_agent_tool_invocation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = Agent(name="nested_agent")
run_result = RunResult(
input="hello",
new_items=[],
raw_responses=[],
final_output="done",
input_guardrail_results=[],
output_guardrail_results=[],
tool_input_guardrail_results=[],
tool_output_guardrail_results=[],
context_wrapper=ToolContext(
context=None,
tool_name="nested_tool",
tool_call_id="call_abc_123",
tool_arguments='{"input": "hello"}',
),
_last_agent=agent,
)
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
del cls, starting_agent, input, context, max_turns, hooks, run_config
del previous_response_id, conversation_id, session
return run_result
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
received_tool_call_id: str | None = None
async def extractor(result: RunResult | RunResultStreaming) -> str:
nonlocal received_tool_call_id
invocation = result.agent_tool_invocation
assert invocation is not None
received_tool_call_id = invocation.tool_call_id
assert invocation.tool_name == "nested_tool"
assert invocation.tool_arguments == '{"input": "hello"}'
return "extracted"
tool = agent.as_tool(
tool_name="nested_tool",
tool_description="A nested agent tool",
custom_output_extractor=extractor,
)
parent_tool_context = ToolContext(
context=None,
tool_name="nested_tool",
tool_call_id="call_abc_123",
tool_arguments='{"input": "hello"}',
)
output = await tool.on_invoke_tool(parent_tool_context, '{"input": "hello"}')
assert output == "extracted"
assert received_tool_call_id == "call_abc_123"
@pytest.mark.asyncio
async def test_agent_as_tool_inherits_parent_run_config_when_not_set(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = Agent(name="inherits_config_agent")
parent_run_config = RunConfig(model="gpt-4.1-mini")
class DummyResult:
def __init__(self) -> None:
self.final_output = "ok"
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
assert starting_agent is agent
assert input == "hello"
assert isinstance(context, ToolContext)
assert run_config is parent_run_config
assert context.run_config is parent_run_config
return DummyResult()
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
tool = agent.as_tool(
tool_name="inherits_config_tool",
tool_description="inherit config",
)
tool_context = ToolContext(
context=None,
tool_name="inherits_config_tool",
tool_call_id="call_inherit",
tool_arguments='{"input":"hello"}',
run_config=parent_run_config,
)
output = await tool.on_invoke_tool(tool_context, '{"input":"hello"}')
assert output == "ok"
@pytest.mark.asyncio
async def test_agent_as_tool_explicit_run_config_overrides_parent_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = Agent(name="override_config_agent")
parent_run_config = RunConfig(model="gpt-4.1-mini")
explicit_run_config = RunConfig(model="gpt-4.1")
class DummyResult:
def __init__(self) -> None:
self.final_output = "ok"
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
assert starting_agent is agent
assert input == "hello"
assert isinstance(context, ToolContext)
assert run_config is explicit_run_config
assert context.run_config is explicit_run_config
return DummyResult()
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
tool = agent.as_tool(
tool_name="override_config_tool",
tool_description="override config",
run_config=explicit_run_config,
)
tool_context = ToolContext(
context=None,
tool_name="override_config_tool",
tool_call_id="call_override",
tool_arguments='{"input":"hello"}',
run_config=parent_run_config,
)
output = await tool.on_invoke_tool(tool_context, '{"input":"hello"}')
assert output == "ok"
@pytest.mark.asyncio
async def test_agent_as_tool_inherits_trace_include_sensitive_data_setting(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = Agent(name="trace_config_agent")
parent_run_config = RunConfig(trace_include_sensitive_data=False)
class DummyResult:
def __init__(self) -> None:
self.final_output = "ok"
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
assert starting_agent is agent
assert input == "hello"
assert isinstance(context, ToolContext)
assert run_config is parent_run_config
assert run_config.trace_include_sensitive_data is False
return DummyResult()
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
tool = agent.as_tool(
tool_name="trace_config_tool",
tool_description="inherits trace config",
)
tool_context = ToolContext(
context=None,
tool_name="trace_config_tool",
tool_call_id="call_trace",
tool_arguments='{"input":"hello"}',
run_config=parent_run_config,
)
output = await tool.on_invoke_tool(tool_context, '{"input":"hello"}')
assert output == "ok"
@pytest.mark.asyncio
async def test_agent_as_tool_structured_input_sets_tool_input(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Structured agent tools should capture input data and pass JSON to the nested run."""
class TranslationInput(BaseModel):
text: str
source: str
target: str
agent = Agent(name="translator")
tool = agent.as_tool(
tool_name="translate",
tool_description="Translate text",
parameters=TranslationInput,
)
captured: dict[str, Any] = {}
class DummyResult:
def __init__(self) -> None:
self.final_output = "ok"
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
captured["input"] = input
captured["context"] = context
return DummyResult()
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
run_context = RunContextWrapper({"locale": "en-US"})
args = {"text": "hola", "source": "es", "target": "en"}
tool_context = ToolContext(
context=run_context.context,
usage=run_context.usage,
tool_name="translate",
tool_call_id="call_structured",
tool_arguments=json.dumps(args),
)
await tool.on_invoke_tool(tool_context, json.dumps(args))
called_input = captured["input"]
assert isinstance(called_input, str)
assert json.loads(called_input) == args
nested_context = captured["context"]
assert isinstance(nested_context, ToolContext)
assert nested_context.context is run_context.context
assert nested_context.usage is run_context.usage
assert nested_context.tool_input == args
assert run_context.tool_input is None
@pytest.mark.asyncio
async def test_agent_as_tool_clears_stale_tool_input_for_plain_tools(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Non-structured agent tools should not inherit stale tool input."""
agent = Agent(name="plain_agent")
tool = agent.as_tool(
tool_name="plain_tool",
tool_description="Plain tool",
)
run_context = RunContextWrapper({"locale": "en-US"})
run_context.tool_input = {"text": "bonjour"}
tool_context = ToolContext(
context=run_context.context,
usage=run_context.usage,
tool_name="plain_tool",
tool_call_id="call_plain",
tool_arguments='{"input": "hello"}',
)
tool_context.tool_input = run_context.tool_input
class DummyResult:
def __init__(self) -> None:
self.final_output = "ok"
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
assert isinstance(context, ToolContext)
assert context.tool_input is None
return DummyResult()
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
await tool.on_invoke_tool(tool_context, '{"input": "hello"}')
assert run_context.tool_input == {"text": "bonjour"}
@pytest.mark.asyncio
async def test_agent_as_tool_includes_schema_summary_with_descriptions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Schema descriptions should be summarized for structured inputs."""
class TranslationInput(BaseModel):
text: str = Field(description="Text to translate")
target: str = Field(description="Target language")
agent = Agent(name="summary_agent")
tool = agent.as_tool(
tool_name="summarize_schema",
tool_description="Summary tool",
parameters=TranslationInput,
)
captured: dict[str, Any] = {}
class DummyResult:
def __init__(self) -> None:
self.final_output = "ok"
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
captured["input"] = input
return DummyResult()
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
args = {"text": "hola", "target": "en"}
tool_context = ToolContext(
context=None,
tool_name="summarize_schema",
tool_call_id="call_summary",
tool_arguments=json.dumps(args),
)
await tool.on_invoke_tool(tool_context, json.dumps(args))
called_input = captured["input"]
assert isinstance(called_input, str)
assert "Input Schema Summary:" in called_input
assert "text (string, required)" in called_input
assert "Text to translate" in called_input
assert "target (string, required)" in called_input
assert "Target language" in called_input
assert '"text": "hola"' in called_input
assert '"target": "en"' in called_input
@pytest.mark.asyncio
async def test_agent_as_tool_supports_custom_input_builder(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Custom input builders should supply nested input items."""
class TranslationInput(BaseModel):
text: str
agent = Agent(name="builder_agent")
builder_calls: list[StructuredToolInputBuilderOptions] = []
custom_items = [{"role": "user", "content": "custom input"}]
async def builder(options: StructuredToolInputBuilderOptions):
builder_calls.append(options)
return custom_items
tool = agent.as_tool(
tool_name="builder_tool",
tool_description="Builder tool",
parameters=TranslationInput,
input_builder=builder,
)
class DummyResult:
def __init__(self) -> None:
self.final_output = "ok"
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
assert input == custom_items
return DummyResult()
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
args = {"text": "hola"}
tool_context = ToolContext(
context=None,
tool_name="builder_tool",
tool_call_id="call_builder",
tool_arguments=json.dumps(args),
)
await tool.on_invoke_tool(tool_context, json.dumps(args))
assert builder_calls
assert builder_calls[0]["params"] == args
assert builder_calls[0]["summary"] is None
assert builder_calls[0]["json_schema"] is None
@pytest.mark.asyncio
async def test_agent_as_tool_rejects_invalid_builder_output() -> None:
"""Invalid builder output should surface as a tool error."""
agent = Agent(name="invalid_builder_agent")
def builder(_options):
return 123
tool = agent.as_tool(
tool_name="invalid_builder_tool",
tool_description="Invalid builder tool",
input_builder=builder,
)
tool_context = ToolContext(
context=None,
tool_name="invalid_builder_tool",
tool_call_id="call_invalid_builder",
tool_arguments='{"input": "hi"}',
)
result = await tool.on_invoke_tool(tool_context, '{"input": "hi"}')
assert "Agent tool called with invalid input" in result
@pytest.mark.asyncio
async def test_agent_as_tool_includes_json_schema_when_requested(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""include_input_schema should embed the full JSON schema."""
class TranslationInput(BaseModel):
text: str = Field(description="Text to translate")
target: str = Field(description="Target language")
agent = Agent(name="schema_agent")
tool = agent.as_tool(
tool_name="schema_tool",
tool_description="Schema tool",
parameters=TranslationInput,
include_input_schema=True,
)
captured: dict[str, Any] = {}
class DummyResult:
def __init__(self) -> None:
self.final_output = "ok"
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
captured["input"] = input
return DummyResult()
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
args = {"text": "hola", "target": "en"}
tool_context = ToolContext(
context=None,
tool_name="schema_tool",
tool_call_id="call_schema",
tool_arguments=json.dumps(args),
)
await tool.on_invoke_tool(tool_context, json.dumps(args))
called_input = captured["input"]
assert isinstance(called_input, str)
assert "Input JSON Schema:" in called_input
assert '"properties"' in called_input
assert '"text"' in called_input
assert '"target"' in called_input
@pytest.mark.asyncio
async def test_agent_as_tool_ignores_input_schema_without_parameters(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""include_input_schema should be ignored when no parameters are provided."""
agent = Agent(name="default_schema_agent")
tool = agent.as_tool(
tool_name="default_schema_tool",
tool_description="Default schema tool",
include_input_schema=True,
)
captured: dict[str, Any] = {}
class DummyResult:
def __init__(self) -> None:
self.final_output = "ok"
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
captured["input"] = input
return DummyResult()
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
tool_context = ToolContext(
context=None,
tool_name="default_schema_tool",
tool_call_id="call_default_schema",
tool_arguments='{"input": "hello"}',
)
await tool.on_invoke_tool(tool_context, '{"input": "hello"}')
assert captured["input"] == "hello"
assert "properties" in tool.params_json_schema
@pytest.mark.asyncio
async def test_agent_as_tool_rejected_nested_approval_resumes_run(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Rejected nested approvals should resume the pending run with rejection applied."""
agent = Agent(name="outer")
tool_call = make_function_tool_call(
"outer_tool",
call_id="outer-1",
arguments='{"input": "hello"}',
)
tool_context = ToolContext(
context=None,
tool_name="outer_tool",
tool_call_id="outer-1",
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
inner_call = make_function_tool_call("inner_tool", call_id="inner-1")
approval_item = ToolApprovalItem(agent=agent, raw_item=inner_call)
class DummyState:
def __init__(self, nested_context: ToolContext) -> None:
self._context = nested_context
class DummyPendingResult:
def __init__(self) -> None:
self.interruptions = [approval_item]
self.final_output = None
def to_state(self) -> DummyState:
return resume_state
class DummyResumedResult:
def __init__(self) -> None:
self.interruptions: list[ToolApprovalItem] = []
self.final_output = "rejected"
nested_context = ToolContext(
context=None,
tool_name=tool_call.name,
tool_call_id=tool_call.call_id,
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
resume_state = DummyState(nested_context)
pending_result = DummyPendingResult()
record_agent_tool_run_result(tool_call, cast(Any, pending_result))
tool_context.reject_tool(approval_item)
resumed_result = DummyResumedResult()
run_inputs: list[Any] = []
async def run_resume(cls, /, starting_agent, input, **kwargs) -> DummyResumedResult:
run_inputs.append(input)
assert input is resume_state
assert input._context is not None
assert input._context.is_tool_approved("inner_tool", "inner-1") is False
return resumed_result
monkeypatch.setattr(Runner, "run", classmethod(run_resume))
async def extractor(result: Any) -> str:
assert result is resumed_result
return "from_resume"
tool = agent.as_tool(
tool_name="outer_tool",
tool_description="Outer agent tool",
custom_output_extractor=extractor,
is_enabled=True,
)
output = await tool.on_invoke_tool(tool_context, tool_call.arguments)
assert output == "from_resume"
assert run_inputs == [resume_state]
@pytest.mark.asyncio
async def test_agent_as_tool_namespaced_nested_always_approve_stays_permanent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Permanent namespaced approvals should carry into nested resumed runs."""
agent = Agent(name="outer")
tool_call = make_function_tool_call(
"outer_tool",
call_id="outer-1",
arguments='{"input": "hello"}',
)
tool_context = ToolContext(
context=None,
tool_name="outer_tool",
tool_call_id="outer-1",
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
inner_call = cast(
Any,
{
"type": "function_call",
"name": "lookup_account",
"namespace": "billing",
"call_id": "inner-1",
"arguments": "{}",
},
)
approval_item = ToolApprovalItem(agent=agent, raw_item=inner_call)
class DummyState:
def __init__(self, nested_context: ToolContext) -> None:
self._context = nested_context
class DummyPendingResult:
def __init__(self) -> None:
self.interruptions = [approval_item]
self.final_output = None
def to_state(self) -> DummyState:
return resume_state
class DummyResumedResult:
def __init__(self) -> None:
self.interruptions: list[ToolApprovalItem] = []
self.final_output = "approved"
nested_context = ToolContext(
context=None,
tool_name=tool_call.name,
tool_call_id=tool_call.call_id,
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
resume_state = DummyState(nested_context)
pending_result = DummyPendingResult()
record_agent_tool_run_result(tool_call, cast(Any, pending_result))
tool_context.approve_tool(approval_item, always_approve=True)
resumed_result = DummyResumedResult()
run_inputs: list[Any] = []
async def run_resume(cls, /, starting_agent, input, **kwargs) -> DummyResumedResult:
run_inputs.append(input)
assert input is resume_state
assert input._context is not None
assert input._context.is_tool_approved("billing.lookup_account", "inner-1") is True
assert input._context.is_tool_approved("billing.lookup_account", "inner-2") is True
return resumed_result
monkeypatch.setattr(Runner, "run", classmethod(run_resume))
tool = agent.as_tool(
tool_name="outer_tool",
tool_description="Outer agent tool",
is_enabled=True,
)
output = await tool.on_invoke_tool(tool_context, tool_call.arguments)
assert output == "approved"
assert run_inputs == [resume_state]
@pytest.mark.asyncio
async def test_agent_as_tool_deferred_same_name_legacy_nested_always_approve_stays_permanent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Legacy deferred approval keys should remain permanent in nested resumed runs."""
agent = Agent(name="outer")
tool_call = make_function_tool_call(
"outer_tool",
call_id="outer-1",
arguments='{"input": "hello"}',
)
tool_context = ToolContext(
context=None,
tool_name="outer_tool",
tool_call_id="outer-1",
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
inner_call = cast(
Any,
{
"type": "function_call",
"name": "get_weather",
"namespace": "get_weather",
"call_id": "inner-1",
"arguments": "{}",
},
)
approval_item = ToolApprovalItem(
agent=agent,
raw_item=inner_call,
tool_lookup_key=("deferred_top_level", "get_weather"),
)
class DummyState:
def __init__(self, nested_context: ToolContext) -> None:
self._context = nested_context
class DummyPendingResult:
def __init__(self) -> None:
self.interruptions = [approval_item]
self.final_output = None
def to_state(self) -> DummyState:
return resume_state
class DummyResumedResult:
def __init__(self) -> None:
self.interruptions: list[ToolApprovalItem] = []
self.final_output = "approved"
nested_context = ToolContext(
context=None,
tool_name=tool_call.name,
tool_call_id=tool_call.call_id,
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
tool_context._approvals["get_weather.get_weather"] = _ApprovalRecord(
approved=True,
rejected=[],
)
resume_state = DummyState(nested_context)
pending_result = DummyPendingResult()
record_agent_tool_run_result(tool_call, cast(Any, pending_result))
resumed_result = DummyResumedResult()
run_inputs: list[Any] = []
async def run_resume(cls, /, starting_agent, input, **kwargs) -> DummyResumedResult:
run_inputs.append(input)
assert input is resume_state
assert input._context is not None
followup_item = ToolApprovalItem(
agent=agent,
raw_item={
"type": "function_call",
"name": "get_weather",
"namespace": "get_weather",
"call_id": "inner-2",
"arguments": "{}",
},
tool_lookup_key=("deferred_top_level", "get_weather"),
)
assert (
input._context.get_approval_status(
"get_weather",
"inner-1",
tool_namespace="get_weather",
existing_pending=approval_item,
)
is True
)
assert (
input._context.get_approval_status(
"get_weather",
"inner-2",
tool_namespace="get_weather",
existing_pending=followup_item,
)
is True
)
return resumed_result
monkeypatch.setattr(Runner, "run", classmethod(run_resume))
tool = agent.as_tool(
tool_name="outer_tool",
tool_description="Outer agent tool",
is_enabled=True,
)
output = await tool.on_invoke_tool(tool_context, tool_call.arguments)
assert output == "approved"
assert run_inputs == [resume_state]
@pytest.mark.asyncio
async def test_agent_as_tool_preserves_scope_for_nested_tool_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Nested ToolContext instances should inherit the parent tool-state scope."""
class DummyResult:
def __init__(self) -> None:
self.final_output = "ok"
self.interruptions: list[ToolApprovalItem] = []
scope_id = "resume-scope"
agent = Agent(name="scope-agent")
tool = agent.as_tool(tool_name="scope_tool", tool_description="Scope tool")
async def fake_run(cls, /, starting_agent, input, **kwargs) -> DummyResult:
del cls, starting_agent, input
nested_context = kwargs.get("context")
assert isinstance(nested_context, ToolContext)
assert get_agent_tool_state_scope(nested_context) == scope_id
return DummyResult()
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
tool_context = ToolContext(
context=None,
tool_name="scope_tool",
tool_call_id="scope-call",
tool_arguments='{"input":"hello"}',
)
set_agent_tool_state_scope(tool_context, scope_id)
output = await tool.on_invoke_tool(tool_context, '{"input":"hello"}')
assert output == "ok"
@pytest.mark.asyncio
async def test_agent_as_tool_preserves_namespace_for_nested_tool_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Nested ToolContext instances should preserve the parent tool namespace."""
class DummyResult:
def __init__(self) -> None:
self.final_output = "ok"
self.interruptions: list[ToolApprovalItem] = []
agent = Agent(name="namespace-agent")
tool = tool_namespace(
name="billing",
description="Billing tools",
tools=[agent.as_tool(tool_name="lookup_account", tool_description="Lookup account")],
)[0]
async def fake_run(cls, /, starting_agent, input, **kwargs) -> DummyResult:
del cls, starting_agent, input
nested_context = kwargs.get("context")
assert isinstance(nested_context, ToolContext)
assert nested_context.tool_namespace == "billing"
assert nested_context.qualified_tool_name == "billing.lookup_account"
return DummyResult()
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
tool_call = make_function_tool_call(
"lookup_account",
call_id="lookup-call",
arguments='{"input":"hello"}',
namespace="billing",
)
tool_context = ToolContext(
context=None,
tool_name="lookup_account",
tool_call_id="lookup-call",
tool_arguments=tool_call.arguments,
tool_call=tool_call,
tool_namespace="billing",
)
output = await tool.on_invoke_tool(tool_context, tool_call.arguments)
assert output == "ok"
@pytest.mark.asyncio
async def test_agent_as_tool_preserves_scope_for_nested_run_context_wrapper(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Nested RunContextWrapper instances should inherit the parent tool-state scope."""
class Params(BaseModel):
text: str
class DummyResult:
def __init__(self) -> None:
self.final_output = "ok"
self.interruptions: list[ToolApprovalItem] = []
scope_id = "resume-scope-wrapper"
agent = Agent(name="scope-agent-wrapper")
tool = agent.as_tool(
tool_name="scope_tool_wrapper",
tool_description="Scope tool wrapper",
parameters=Params,
)
async def fake_run(cls, /, starting_agent, input, **kwargs) -> DummyResult:
del cls, starting_agent, input
nested_context = kwargs.get("context")
assert isinstance(nested_context, RunContextWrapper)
assert get_agent_tool_state_scope(nested_context) == scope_id
return DummyResult()
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
parent_context = RunContextWrapper(context={"key": "value"})
set_agent_tool_state_scope(parent_context, scope_id)
output = await tool.on_invoke_tool(cast(Any, parent_context), '{"text":"hello"}')
assert output == "ok"
@pytest.mark.asyncio
async def test_agent_as_tool_streams_events_with_on_stream(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = Agent(name="streamer")
stream_events = [
RawResponsesStreamEvent(data=cast(Any, {"type": "response_started"})),
RawResponsesStreamEvent(data=cast(Any, {"type": "output_text_delta", "delta": "hi"})),
]
class DummyStreamingResult:
def __init__(self) -> None:
self.final_output = "streamed output"
self.current_agent = agent
async def stream_events(self):
for ev in stream_events:
yield ev
run_calls: list[dict[str, Any]] = []
def fake_run_streamed(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
auto_previous_response_id=False,
conversation_id,
session,
):
run_calls.append(
{
"starting_agent": starting_agent,
"input": input,
"context": context,
"max_turns": max_turns,
"hooks": hooks,
"run_config": run_config,
"previous_response_id": previous_response_id,
"conversation_id": conversation_id,
"session": session,
}
)
return DummyStreamingResult()
async def unexpected_run(*args: Any, **kwargs: Any) -> None:
raise AssertionError("Runner.run should not be called when on_stream is provided.")
monkeypatch.setattr(Runner, "run_streamed", classmethod(fake_run_streamed))
monkeypatch.setattr(Runner, "run", classmethod(unexpected_run))
received_events: list[AgentToolStreamEvent] = []
async def on_stream(payload: AgentToolStreamEvent) -> None:
received_events.append(payload)
tool_call = ResponseFunctionToolCall(
id="call_123",
arguments='{"input": "run streaming"}',
call_id="call-123",
name="stream_tool",
type="function_call",
)
tool = agent.as_tool(
tool_name="stream_tool",
tool_description="Streams events",
on_stream=on_stream,
)
tool_context = ToolContext(
context=None,
tool_name="stream_tool",
tool_call_id=tool_call.call_id,
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
output = await tool.on_invoke_tool(tool_context, '{"input": "run streaming"}')
assert output == "streamed output"
assert len(received_events) == len(stream_events)
assert received_events[0]["agent"] is agent
assert received_events[0]["tool_call"] is tool_call
assert received_events[0]["event"] == stream_events[0]
assert run_calls[0]["input"] == "run streaming"
@pytest.mark.asyncio
async def test_agent_as_tool_streaming_updates_agent_on_handoff(
monkeypatch: pytest.MonkeyPatch,
) -> None:
first_agent = Agent(name="primary")
handed_off_agent = Agent(name="delegate")
events = [
AgentUpdatedStreamEvent(new_agent=first_agent),
RawResponsesStreamEvent(data=cast(Any, {"type": "response_started"})),
AgentUpdatedStreamEvent(new_agent=handed_off_agent),
RawResponsesStreamEvent(data=cast(Any, {"type": "output_text_delta", "delta": "hello"})),
]
class DummyStreamingResult:
def __init__(self) -> None:
self.final_output = "delegated output"
self.current_agent = first_agent
async def stream_events(self):
for ev in events:
yield ev
def fake_run_streamed(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
auto_previous_response_id=False,
conversation_id,
session,
):
return DummyStreamingResult()
monkeypatch.setattr(Runner, "run_streamed", classmethod(fake_run_streamed))
monkeypatch.setattr(
Runner,
"run",
classmethod(lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("no run"))),
)
seen_agents: list[Agent[Any]] = []
async def on_stream(payload: AgentToolStreamEvent) -> None:
seen_agents.append(payload["agent"])
tool = first_agent.as_tool(
tool_name="delegate_tool",
tool_description="Streams handoff events",
on_stream=on_stream,
)
tool_call = ResponseFunctionToolCall(
id="call_delegate",
arguments='{"input": "handoff"}',
call_id="call-delegate",
name="delegate_tool",
type="function_call",
)
tool_context = ToolContext(
context=None,
tool_name="delegate_tool",
tool_call_id=tool_call.call_id,
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
output = await tool.on_invoke_tool(tool_context, '{"input": "handoff"}')
assert output == "delegated output"
assert seen_agents == [first_agent, first_agent, handed_off_agent, handed_off_agent]
@pytest.mark.asyncio
async def test_agent_as_tool_streaming_works_with_custom_extractor(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = Agent(name="streamer")
stream_events = [RawResponsesStreamEvent(data=cast(Any, {"type": "response_started"}))]
streamed_instance = RunResultStreaming(
input="stream please",
new_items=[],
raw_responses=[],
final_output="raw output",
input_guardrail_results=[],
output_guardrail_results=[],
tool_input_guardrail_results=[],
tool_output_guardrail_results=[],
context_wrapper=ToolContext(
context=None,
tool_name="stream_tool",
tool_call_id="call-abc",
tool_arguments='{"input": "stream please"}',
),
current_agent=agent,
current_turn=0,
max_turns=1,
_current_agent_output_schema=None,
trace=None,
)
streamed_instance._event_queue.put_nowait(stream_events[0])
streamed_instance.is_complete = True
def fake_run_streamed(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
auto_previous_response_id=False,
conversation_id,
session,
):
return streamed_instance
async def unexpected_run(*args: Any, **kwargs: Any) -> None:
raise AssertionError("Runner.run should not be called when on_stream is provided.")
monkeypatch.setattr(Runner, "run_streamed", classmethod(fake_run_streamed))
monkeypatch.setattr(Runner, "run", classmethod(unexpected_run))
received: list[Any] = []
async def extractor(result) -> str:
received.append(result)
return "custom value"
callbacks: list[Any] = []
async def on_stream(payload: AgentToolStreamEvent) -> None:
callbacks.append(payload["event"])
tool_call = ResponseFunctionToolCall(
id="call_abc",
arguments='{"input": "stream please"}',
call_id="call-abc",
name="stream_tool",
type="function_call",
)
tool = agent.as_tool(
tool_name="stream_tool",
tool_description="Streams events",
custom_output_extractor=extractor,
on_stream=on_stream,
)
tool_context = ToolContext(
context=None,
tool_name="stream_tool",
tool_call_id=tool_call.call_id,
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
output = await tool.on_invoke_tool(tool_context, '{"input": "stream please"}')
assert output == "custom value"
assert received == [streamed_instance]
assert callbacks == stream_events
@pytest.mark.asyncio
async def test_agent_as_tool_streaming_settles_multi_segment_text_output() -> None:
agent = Agent(
name="streamer",
model=FakeModel(
initial_output=[
ResponseOutputMessage(
id="msg_multi_segment",
role="assistant",
status="completed",
type="message",
content=[
ResponseOutputText(
annotations=[],
text="first ",
type="output_text",
logprobs=[],
),
ResponseOutputText(
annotations=[],
text="second",
type="output_text",
logprobs=[],
),
],
)
]
),
)
async def on_stream(payload: AgentToolStreamEvent) -> None:
del payload
tool_call = ResponseFunctionToolCall(
id="call_settle_text",
arguments='{"input": "go"}',
call_id="call-settle-text",
name="stream_tool",
type="function_call",
)
tool = agent.as_tool(
tool_name="stream_tool",
tool_description="Streams events",
on_stream=on_stream,
)
tool_context = ToolContext(
context=None,
tool_name="stream_tool",
tool_call_id=tool_call.call_id,
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
output = await tool.on_invoke_tool(tool_context, '{"input": "go"}')
assert output == "first second"
@pytest.mark.asyncio
async def test_agent_as_tool_streaming_settles_multi_segment_structured_output() -> None:
class StructuredOutput(BaseModel):
answer: str
agent = Agent(
name="streamer",
model=FakeModel(
initial_output=[
ResponseOutputMessage(
id="msg_multi_segment_structured",
role="assistant",
status="completed",
type="message",
content=[
ResponseOutputText(
annotations=[],
text='{"answer":"str',
type="output_text",
logprobs=[],
),
ResponseOutputText(
annotations=[],
text='uctured"}',
type="output_text",
logprobs=[],
),
],
)
]
),
output_type=StructuredOutput,
)
async def on_stream(payload: AgentToolStreamEvent) -> None:
del payload
tool_call = ResponseFunctionToolCall(
id="call_settle_structured",
arguments='{"input": "go"}',
call_id="call-settle-structured",
name="stream_tool",
type="function_call",
)
tool = agent.as_tool(
tool_name="stream_tool",
tool_description="Streams events",
on_stream=on_stream,
)
tool_context = ToolContext(
context=None,
tool_name="stream_tool",
tool_call_id=tool_call.call_id,
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
output = await tool.on_invoke_tool(tool_context, '{"input": "go"}')
assert output == StructuredOutput(answer="structured")
@pytest.mark.asyncio
@pytest.mark.parametrize(
("server", "tool_name"),
[
pytest.param(
"cancelled",
"cancel_tool",
id="mcp-cancellation",
),
pytest.param(
"error",
"error_tool",
id="mcp-error",
),
],
)
async def test_agent_as_tool_streaming_settles_final_text_after_nested_mcp_failure(
server: str,
tool_name: str,
) -> None:
class CancelledNestedMCPServer(FakeMCPServer):
async def call_tool(
self,
tool_name: str,
arguments: dict[str, Any] | None,
meta: dict[str, Any] | None = None,
):
self.tool_calls.append(tool_name)
del arguments, meta
raise asyncio.CancelledError("synthetic nested mcp cancellation")
class ErrorNestedMCPServer(FakeMCPServer):
async def call_tool(
self,
tool_name: str,
arguments: dict[str, Any] | None,
meta: dict[str, Any] | None = None,
):
self.tool_calls.append(tool_name)
del arguments, meta
raise McpError(ErrorData(code=-32000, message="synthetic upstream 422"))
nested_server: FakeMCPServer
if server == "cancelled":
nested_server = CancelledNestedMCPServer()
else:
nested_server = ErrorNestedMCPServer()
nested_server.add_tool(tool_name, {})
agent = Agent(
name="streamer",
model=FakeModel(),
mcp_servers=[nested_server],
)
cast(FakeModel, agent.model).add_multiple_turn_outputs(
[
[get_function_tool_call(tool_name, "{}")],
[
ResponseOutputMessage(
id=f"msg_after_{server}_failure",
role="assistant",
status="completed",
type="message",
content=[
ResponseOutputText(
annotations=[],
text="first ",
type="output_text",
logprobs=[],
),
ResponseOutputText(
annotations=[],
text="second",
type="output_text",
logprobs=[],
),
],
)
],
]
)
async def on_stream(payload: AgentToolStreamEvent) -> None:
del payload
tool_call = ResponseFunctionToolCall(
id=f"call_nested_{server}",
arguments='{"input": "go"}',
call_id=f"call-nested-{server}",
name="stream_tool",
type="function_call",
)
tool = agent.as_tool(
tool_name="stream_tool",
tool_description="Streams events",
on_stream=on_stream,
)
tool_context = ToolContext(
context=None,
tool_name="stream_tool",
tool_call_id=tool_call.call_id,
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
output = await tool.on_invoke_tool(tool_context, '{"input": "go"}')
assert nested_server.tool_calls == [tool_name]
assert output == "first second"
@pytest.mark.asyncio
async def test_agent_as_tool_streaming_reraises_parent_cancellation_without_waiting_for_handler(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = Agent(name="streamer")
stream_event = RawResponsesStreamEvent(data=cast(Any, {"type": "response_started"}))
handler_started = asyncio.Event()
release_handler = asyncio.Event()
class DummyStreamingResult:
def __init__(self) -> None:
self.final_output = ""
self.current_agent = agent
self.new_items: list[Any] = []
self.raw_responses = [
ModelResponse(
output=[get_text_message("Recovered nested summary")],
usage=Usage(),
response_id="resp_nested",
)
]
self.run_loop_task = asyncio.create_task(asyncio.sleep(0))
async def stream_events(self):
yield stream_event
await asyncio.sleep(60)
streaming_result = DummyStreamingResult()
await streaming_result.run_loop_task
def fake_run_streamed(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
auto_previous_response_id=False,
conversation_id,
session,
):
return streaming_result
async def unexpected_run(*args: Any, **kwargs: Any) -> None:
raise AssertionError("Runner.run should not be called when on_stream is provided.")
monkeypatch.setattr(Runner, "run_streamed", classmethod(fake_run_streamed))
monkeypatch.setattr(Runner, "run", classmethod(unexpected_run))
async def on_stream(payload: AgentToolStreamEvent) -> None:
assert payload["event"] is stream_event
handler_started.set()
await release_handler.wait()
tool_call = ResponseFunctionToolCall(
id="call_cancelled",
arguments='{"input": "recover"}',
call_id="call-cancelled",
name="stream_tool",
type="function_call",
)
tool = agent.as_tool(
tool_name="stream_tool",
tool_description="Streams events",
on_stream=on_stream,
)
tool_context = ToolContext(
context=None,
tool_name="stream_tool",
tool_call_id=tool_call.call_id,
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
async def _invoke_tool() -> Any:
return await tool.on_invoke_tool(tool_context, '{"input": "recover"}')
invoke_task: asyncio.Task[Any] = asyncio.create_task(_invoke_tool())
await asyncio.wait_for(handler_started.wait(), timeout=1.0)
invoke_task.cancel()
try:
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(invoke_task, timeout=1.0)
finally:
release_handler.set()
with contextlib.suppress(asyncio.CancelledError):
await invoke_task
@pytest.mark.asyncio
async def test_agent_as_tool_streaming_extractor_can_access_agent_tool_invocation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = Agent(name="streaming_tool_context_agent")
stream_event = RawResponsesStreamEvent(data=cast(Any, {"type": "response_started"}))
streamed_instance = RunResultStreaming(
input="go",
new_items=[],
raw_responses=[],
final_output="raw output",
input_guardrail_results=[],
output_guardrail_results=[],
tool_input_guardrail_results=[],
tool_output_guardrail_results=[],
context_wrapper=ToolContext(
context=None,
tool_name="stream_tool",
tool_call_id="call-stream-123",
tool_arguments='{"input": "go"}',
),
current_agent=agent,
current_turn=0,
max_turns=1,
_current_agent_output_schema=None,
trace=None,
)
streamed_instance._event_queue.put_nowait(stream_event)
streamed_instance.is_complete = True
def fake_run_streamed(
cls,
/,
starting_agent,
input,
**kwargs,
) -> RunResultStreaming:
del cls, starting_agent, input, kwargs
return streamed_instance
async def unexpected_run(*args: Any, **kwargs: Any) -> None:
raise AssertionError("Runner.run should not be called when on_stream is provided.")
monkeypatch.setattr(Runner, "run_streamed", classmethod(fake_run_streamed))
monkeypatch.setattr(Runner, "run", classmethod(unexpected_run))
received_call_id: str | None = None
async def extractor(result: RunResult | RunResultStreaming) -> str:
nonlocal received_call_id
invocation = result.agent_tool_invocation
assert invocation is not None
received_call_id = invocation.tool_call_id
assert invocation.tool_name == "stream_tool"
assert invocation.tool_arguments == '{"input": "go"}'
return "custom value"
async def on_stream(payload: AgentToolStreamEvent) -> None:
del payload
tool = agent.as_tool(
tool_name="stream_tool",
tool_description="Streams events",
custom_output_extractor=extractor,
on_stream=on_stream,
)
tool_context = ToolContext(
context=None,
tool_name="stream_tool",
tool_call_id="call-stream-123",
tool_arguments='{"input": "go"}',
)
output = await tool.on_invoke_tool(tool_context, '{"input": "go"}')
assert output == "custom value"
assert received_call_id == "call-stream-123"
@pytest.mark.asyncio
async def test_agent_as_tool_streaming_accepts_sync_handler(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = Agent(name="sync_handler_agent")
class DummyStreamingResult:
def __init__(self) -> None:
self.final_output = "ok"
self.current_agent = agent
async def stream_events(self):
yield RawResponsesStreamEvent(data=cast(Any, {"type": "response_started"}))
monkeypatch.setattr(
Runner, "run_streamed", classmethod(lambda *args, **kwargs: DummyStreamingResult())
)
monkeypatch.setattr(
Runner,
"run",
classmethod(lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("no run"))),
)
calls: list[str] = []
def sync_handler(event: AgentToolStreamEvent) -> None:
calls.append(event["event"].type)
tool_call = ResponseFunctionToolCall(
id="call_sync",
arguments='{"input": "go"}',
call_id="call-sync",
name="sync_tool",
type="function_call",
)
tool = agent.as_tool(
tool_name="sync_tool",
tool_description="Uses sync handler",
on_stream=sync_handler,
)
tool_context = ToolContext(
context=None,
tool_name="sync_tool",
tool_call_id=tool_call.call_id,
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
output = await tool.on_invoke_tool(tool_context, '{"input": "go"}')
assert output == "ok"
assert calls == ["raw_response_event"]
@pytest.mark.asyncio
async def test_agent_as_tool_streaming_dispatches_without_blocking(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""on_stream handlers should not block streaming iteration."""
agent = Agent(name="nonblocking_agent")
first_handler_started = asyncio.Event()
allow_handler_to_continue = asyncio.Event()
second_event_yielded = asyncio.Event()
second_event_handled = asyncio.Event()
first_event = RawResponsesStreamEvent(data=cast(Any, {"type": "response_started"}))
second_event = RawResponsesStreamEvent(
data=cast(Any, {"type": "output_text_delta", "delta": "hi"})
)
class DummyStreamingResult:
def __init__(self) -> None:
self.final_output = "ok"
self.current_agent = agent
async def stream_events(self):
yield first_event
second_event_yielded.set()
yield second_event
dummy_result = DummyStreamingResult()
monkeypatch.setattr(Runner, "run_streamed", classmethod(lambda *args, **kwargs: dummy_result))
monkeypatch.setattr(
Runner,
"run",
classmethod(lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("no run"))),
)
async def on_stream(payload: AgentToolStreamEvent) -> None:
if payload["event"] is first_event:
first_handler_started.set()
await allow_handler_to_continue.wait()
else:
second_event_handled.set()
tool_call = ResponseFunctionToolCall(
id="call_nonblocking",
arguments='{"input": "go"}',
call_id="call-nonblocking",
name="nonblocking_tool",
type="function_call",
)
tool = agent.as_tool(
tool_name="nonblocking_tool",
tool_description="Uses non-blocking streaming handler",
on_stream=on_stream,
)
tool_context = ToolContext(
context=None,
tool_name="nonblocking_tool",
tool_call_id=tool_call.call_id,
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
async def _invoke_tool() -> Any:
return await tool.on_invoke_tool(tool_context, '{"input": "go"}')
invoke_task: asyncio.Task[Any] = asyncio.create_task(_invoke_tool())
await asyncio.wait_for(first_handler_started.wait(), timeout=1.0)
await asyncio.wait_for(second_event_yielded.wait(), timeout=1.0)
assert invoke_task.done() is False
allow_handler_to_continue.set()
await asyncio.wait_for(second_event_handled.wait(), timeout=1.0)
output = await asyncio.wait_for(invoke_task, timeout=1.0)
assert output == "ok"
@pytest.mark.asyncio
async def test_agent_as_tool_streaming_handler_exception_does_not_fail_call(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = Agent(name="handler_error_agent")
class DummyStreamingResult:
def __init__(self) -> None:
self.final_output = "ok"
self.current_agent = agent
async def stream_events(self):
yield RawResponsesStreamEvent(data=cast(Any, {"type": "response_started"}))
monkeypatch.setattr(
Runner, "run_streamed", classmethod(lambda *args, **kwargs: DummyStreamingResult())
)
monkeypatch.setattr(
Runner,
"run",
classmethod(lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("no run"))),
)
def bad_handler(event: AgentToolStreamEvent) -> None:
raise RuntimeError("boom")
tool_call = ResponseFunctionToolCall(
id="call_bad",
arguments='{"input": "go"}',
call_id="call-bad",
name="error_tool",
type="function_call",
)
tool = agent.as_tool(
tool_name="error_tool",
tool_description="Handler throws",
on_stream=bad_handler,
)
tool_context = ToolContext(
context=None,
tool_name="error_tool",
tool_call_id=tool_call.call_id,
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
output = await tool.on_invoke_tool(tool_context, '{"input": "go"}')
assert output == "ok"
@pytest.mark.asyncio
async def test_agent_as_tool_without_stream_uses_run(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = Agent(name="nostream_agent")
class DummyResult:
def __init__(self) -> None:
self.final_output = "plain"
run_calls: list[dict[str, Any]] = []
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
auto_previous_response_id=False,
conversation_id,
session,
):
run_calls.append({"input": input})
return DummyResult()
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
monkeypatch.setattr(
Runner,
"run_streamed",
classmethod(lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("no stream"))),
)
tool = agent.as_tool(
tool_name="nostream_tool",
tool_description="No streaming path",
)
tool_context = ToolContext(
context=None,
tool_name="nostream_tool",
tool_call_id="call-no",
tool_arguments='{"input": "plain"}',
)
output = await tool.on_invoke_tool(tool_context, '{"input": "plain"}')
assert output == "plain"
assert run_calls == [{"input": "plain"}]
@pytest.mark.asyncio
async def test_agent_as_tool_streaming_sets_tool_call_from_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = Agent(name="direct_invocation_agent")
class DummyStreamingResult:
def __init__(self) -> None:
self.final_output = "ok"
self.current_agent = agent
async def stream_events(self):
yield RawResponsesStreamEvent(data=cast(Any, {"type": "response_started"}))
monkeypatch.setattr(
Runner, "run_streamed", classmethod(lambda *args, **kwargs: DummyStreamingResult())
)
monkeypatch.setattr(
Runner,
"run",
classmethod(lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("no run"))),
)
captured: list[AgentToolStreamEvent] = []
async def on_stream(event: AgentToolStreamEvent) -> None:
captured.append(event)
tool_call = ResponseFunctionToolCall(
id="call_direct",
arguments='{"input": "hi"}',
call_id="direct-call-id",
name="direct_stream_tool",
type="function_call",
)
tool = agent.as_tool(
tool_name="direct_stream_tool",
tool_description="Direct invocation",
on_stream=on_stream,
)
tool_context = ToolContext(
context=None,
tool_name="direct_stream_tool",
tool_call_id=tool_call.call_id,
tool_arguments=tool_call.arguments,
tool_call=tool_call,
)
output = await tool.on_invoke_tool(tool_context, '{"input": "hi"}')
assert output == "ok"
assert captured[0]["tool_call"] is tool_call
@pytest.mark.asyncio
async def test_agent_as_tool_failure_error_function_none_reraises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If failure_error_function=None, exceptions should propagate to the caller."""
agent = Agent(name="failing_agent")
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
assert starting_agent is agent
assert input == "hello"
raise RuntimeError("test failure")
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
tool = agent.as_tool(
tool_name="failing_agent_tool",
tool_description="Agent tool that raises",
is_enabled=True,
failure_error_function=None,
)
assert isinstance(tool, FunctionTool)
tool_context = ToolContext(
context=None,
tool_name="failing_agent_tool",
tool_call_id="call_1",
tool_arguments='{"input": "hello"}',
)
with pytest.raises(RuntimeError, match="test failure"):
await tool.on_invoke_tool(tool_context, '{"input": "hello"}')
@pytest.mark.asyncio
async def test_agent_as_tool_failure_error_function_custom_handler(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Custom failure_error_function should be used to convert exceptions into tool output."""
agent = Agent(name="failing_agent")
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
assert starting_agent is agent
assert input == "hello"
raise ValueError("test failure")
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
def custom_failure_handler(ctx: RunContextWrapper[Any], error: Exception) -> str:
return f"handled:{type(error).__name__}:{error}"
tool = agent.as_tool(
tool_name="failing_agent_tool",
tool_description="Agent tool that raises",
is_enabled=True,
failure_error_function=custom_failure_handler,
)
assert isinstance(tool, FunctionTool)
tool_context = ToolContext(
context=None,
tool_name="failing_agent_tool",
tool_call_id="call_1",
tool_arguments='{"input": "hello"}',
)
result = await tool.on_invoke_tool(tool_context, '{"input": "hello"}')
assert result == "handled:ValueError:test failure"
@pytest.mark.asyncio
async def test_replaced_agent_as_tool_normal_failure_uses_replaced_policy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent = Agent(name="failing_agent")
async def fake_run(
cls,
starting_agent,
input,
*,
context,
max_turns,
hooks,
run_config,
previous_response_id,
conversation_id,
session,
):
assert starting_agent is agent
assert input == "hello"
raise RuntimeError("test failure")
monkeypatch.setattr(Runner, "run", classmethod(fake_run))
tool = dataclasses.replace(
agent.as_tool(
tool_name="failing_agent_tool",
tool_description="Agent tool that raises",
is_enabled=True,
),
_failure_error_function=None,
_use_default_failure_error_function=False,
)
tool_context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call_1",
tool_arguments='{"input": "hello"}',
)
with pytest.raises(RuntimeError, match="test failure"):
await tool.on_invoke_tool(tool_context, '{"input": "hello"}')
@pytest.mark.asyncio
async def test_replaced_agent_as_tool_invalid_input_uses_replaced_name() -> None:
nested_agent = Agent(name="nested_agent")
replaced_tool = dataclasses.replace(
nested_agent.as_tool(
tool_name="nested_agent_tool",
tool_description="Nested agent tool",
is_enabled=True,
failure_error_function=None,
),
name="replaced_nested_agent_tool",
)
with pytest.raises(
ModelBehaviorError,
match="Invalid JSON input for tool replaced_nested_agent_tool",
):
await replaced_tool.on_invoke_tool(
ToolContext(
context=None,
tool_name=replaced_tool.name,
tool_call_id="call_1",
tool_arguments="{}",
),
"{}",
)
def test_replaced_agent_as_tool_preserves_agent_markers_for_build_agent_map() -> None:
nested_agent = Agent(name="nested_agent")
replaced_tool = dataclasses.replace(
nested_agent.as_tool(
tool_name="nested_agent_tool",
tool_description="Nested agent tool",
is_enabled=True,
),
name="replaced_nested_agent_tool",
)
parent_agent = Agent(name="parent_agent", tools=[replaced_tool])
agent_map = _build_agent_map(parent_agent)
assert agent_map["nested_agent"] is nested_agent