Files
2026-07-13 12:39:17 +08:00

403 lines
12 KiB
Python

import json
from typing import Any
import pytest
from pydantic import BaseModel
from agents import (
Agent,
FunctionTool,
ModelBehaviorError,
RunContextWrapper,
Runner,
UserError,
default_tool_error_function,
handoff,
)
from agents.exceptions import AgentsException
from ..fake_model import FakeModel
from ..test_responses import get_function_tool_call, get_text_message
from .helpers import FakeMCPServer
@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
async def test_runner_calls_mcp_tool(streaming: bool):
"""Test that the runner calls an MCP tool when the model produces a tool call."""
server = FakeMCPServer()
server.add_tool("test_tool_1", {})
server.add_tool("test_tool_2", {})
server.add_tool("test_tool_3", {})
model = FakeModel()
agent = Agent(
name="test",
model=model,
mcp_servers=[server],
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("test_tool_2", "")],
# Second turn: text message
[get_text_message("done")],
]
)
if streaming:
result = Runner.run_streamed(agent, input="user_message")
async for _ in result.stream_events():
pass
else:
await Runner.run(agent, input="user_message")
assert server.tool_calls == ["test_tool_2"]
@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
async def test_runner_asserts_when_mcp_tool_not_found(streaming: bool):
"""Test that the runner asserts when an MCP tool is not found."""
server = FakeMCPServer()
server.add_tool("test_tool_1", {})
server.add_tool("test_tool_2", {})
server.add_tool("test_tool_3", {})
model = FakeModel()
agent = Agent(
name="test",
model=model,
mcp_servers=[server],
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("test_tool_doesnt_exist", "")],
# Second turn: text message
[get_text_message("done")],
]
)
with pytest.raises(ModelBehaviorError):
if streaming:
result = Runner.run_streamed(agent, input="user_message")
async for _ in result.stream_events():
pass
else:
await Runner.run(agent, input="user_message")
@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
async def test_runner_works_with_multiple_mcp_servers(streaming: bool):
"""Test that the runner works with multiple MCP servers."""
server1 = FakeMCPServer()
server1.add_tool("test_tool_1", {})
server2 = FakeMCPServer()
server2.add_tool("test_tool_2", {})
server2.add_tool("test_tool_3", {})
model = FakeModel()
agent = Agent(
name="test",
model=model,
mcp_servers=[server1, server2],
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("test_tool_2", "")],
# Second turn: text message
[get_text_message("done")],
]
)
if streaming:
result = Runner.run_streamed(agent, input="user_message")
async for _ in result.stream_events():
pass
else:
await Runner.run(agent, input="user_message")
assert server1.tool_calls == []
assert server2.tool_calls == ["test_tool_2"]
@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
async def test_runner_errors_when_mcp_tools_clash(streaming: bool):
"""Test that the runner errors when multiple servers have the same tool name."""
server1 = FakeMCPServer()
server1.add_tool("test_tool_1", {})
server1.add_tool("test_tool_2", {})
server2 = FakeMCPServer()
server2.add_tool("test_tool_2", {})
server2.add_tool("test_tool_3", {})
model = FakeModel()
agent = Agent(
name="test",
model=model,
mcp_servers=[server1, server2],
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("test_tool_3", "")],
# Second turn: text message
[get_text_message("done")],
]
)
with pytest.raises(UserError):
if streaming:
result = Runner.run_streamed(agent, input="user_message")
async for _ in result.stream_events():
pass
else:
await Runner.run(agent, input="user_message")
@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
async def test_runner_can_call_server_prefixed_mcp_tool_names(streaming: bool):
server1 = FakeMCPServer(server_name="docs")
server1.add_tool("search", {})
server2 = FakeMCPServer(server_name="calendar")
server2.add_tool("search", {})
model = FakeModel()
agent = Agent(
name="test",
model=model,
mcp_servers=[server1, server2],
mcp_config={"include_server_in_tool_names": True},
)
model.add_multiple_turn_outputs(
[
[get_text_message("a_message"), get_function_tool_call("mcp_calendar__search", "")],
[get_text_message("done")],
]
)
if streaming:
result = Runner.run_streamed(agent, input="user_message")
async for _ in result.stream_events():
pass
else:
await Runner.run(agent, input="user_message")
assert server1.tool_calls == []
assert server2.tool_calls == ["search"]
@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
async def test_runner_prefixed_mcp_tool_names_do_not_collide_with_agent_tools(streaming: bool):
server1 = FakeMCPServer(server_name="docs")
server1.add_tool("search", {})
server2 = FakeMCPServer(server_name="calendar")
server2.add_tool("search", {})
local_tool_calls: list[str] = []
async def invoke_local_tool(context: Any, input_json: str) -> str:
local_tool_calls.append(input_json)
return "local"
local_tool = FunctionTool(
name="mcp_calendar__search",
description="Local tool that intentionally collides with the natural MCP prefix.",
params_json_schema={"type": "object", "properties": {}, "additionalProperties": False},
on_invoke_tool=invoke_local_tool,
)
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[local_tool],
mcp_servers=[server1, server2],
mcp_config={"include_server_in_tool_names": True},
)
mcp_tools = await agent.get_mcp_tools(RunContextWrapper(context=None))
calendar_search_tool_name = next(
tool.name
for tool in mcp_tools
if getattr(getattr(tool, "_tool_origin", None), "mcp_server_name", None) == "calendar"
)
assert calendar_search_tool_name != "mcp_calendar__search"
assert calendar_search_tool_name.startswith("mcp_calendar__search_")
model.add_multiple_turn_outputs(
[
[get_text_message("a_message"), get_function_tool_call(calendar_search_tool_name, "")],
[get_text_message("done")],
]
)
if streaming:
result = Runner.run_streamed(agent, input="user_message")
async for _ in result.stream_events():
pass
else:
await Runner.run(agent, input="user_message")
assert local_tool_calls == []
assert server1.tool_calls == []
assert server2.tool_calls == ["search"]
@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
async def test_runner_prefixed_mcp_tool_names_do_not_collide_with_handoffs(streaming: bool):
server = FakeMCPServer(server_name="calendar")
server.add_tool("search", {})
target_model = FakeModel()
target_agent = Agent(name="calendar_agent", model=target_model)
target_model.add_multiple_turn_outputs([[get_text_message("handoff target")]])
model = FakeModel()
agent = Agent(
name="test",
model=model,
handoffs=[handoff(target_agent, tool_name_override="mcp_calendar__search")],
mcp_servers=[server],
mcp_config={"include_server_in_tool_names": True},
)
mcp_tools = await agent.get_mcp_tools(RunContextWrapper(context=None))
assert len(mcp_tools) == 1
calendar_search_tool_name = mcp_tools[0].name
assert calendar_search_tool_name != "mcp_calendar__search"
assert calendar_search_tool_name.startswith("mcp_calendar__search_")
model.add_multiple_turn_outputs(
[
[get_text_message("a_message"), get_function_tool_call(calendar_search_tool_name, "")],
[get_text_message("done")],
]
)
if streaming:
result = Runner.run_streamed(agent, input="user_message")
async for _ in result.stream_events():
pass
else:
await Runner.run(agent, input="user_message")
assert server.tool_calls == ["search"]
assert target_model.first_turn_args is None
class Foo(BaseModel):
bar: str
baz: int
@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
async def test_runner_calls_mcp_tool_with_args(streaming: bool):
"""Test that the runner calls an MCP tool when the model produces a tool call."""
server = FakeMCPServer()
await server.connect()
server.add_tool("test_tool_1", {})
server.add_tool("test_tool_2", Foo.model_json_schema())
server.add_tool("test_tool_3", {})
model = FakeModel()
agent = Agent(
name="test",
model=model,
mcp_servers=[server],
)
json_args = json.dumps(Foo(bar="baz", baz=1).model_dump())
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("test_tool_2", json_args)],
# Second turn: text message
[get_text_message("done")],
]
)
if streaming:
result = Runner.run_streamed(agent, input="user_message")
async for _ in result.stream_events():
pass
else:
await Runner.run(agent, input="user_message")
assert server.tool_calls == ["test_tool_2"]
assert server.tool_results == [f"result_test_tool_2_{json_args}"]
await server.cleanup()
class CrashingFakeMCPServer(FakeMCPServer):
async def call_tool(
self,
tool_name: str,
arguments: dict[str, object] | None,
meta: dict[str, object] | None = None,
):
raise Exception("Crash!")
@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
async def test_runner_emits_mcp_error_tool_call_output_item(streaming: bool):
"""Runner should emit tool_call_output_item with failure output when MCP tool raises."""
server = CrashingFakeMCPServer()
server.add_tool("crashing_tool", {})
model = FakeModel()
agent = Agent(
name="test",
model=model,
mcp_servers=[server],
)
model.add_multiple_turn_outputs(
[
[get_text_message("a_message"), get_function_tool_call("crashing_tool", "{}")],
[get_text_message("done")],
]
)
if streaming:
streamed_result = Runner.run_streamed(agent, input="user_message")
async for _ in streamed_result.stream_events():
pass
tool_output_items = [
item for item in streamed_result.new_items if item.type == "tool_call_output_item"
]
assert streamed_result.final_output == "done"
else:
non_streamed_result = await Runner.run(agent, input="user_message")
tool_output_items = [
item for item in non_streamed_result.new_items if item.type == "tool_call_output_item"
]
assert non_streamed_result.final_output == "done"
assert tool_output_items, "Expected tool_call_output_item for MCP failure"
wrapped_error = AgentsException(
"Error invoking MCP tool crashing_tool on server 'fake_mcp_server': Crash!"
)
expected_error_message = default_tool_error_function(
RunContextWrapper(context=None),
wrapped_error,
)
assert tool_output_items[0].output == expected_error_message