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

502 lines
15 KiB
Python

from __future__ import annotations
import gc
import json
import weakref
from collections.abc import Sequence
from typing import Any, TypeVar, cast
import pytest
from mcp import Tool as MCPTool
from openai.types.responses.response_output_item import McpCall, McpListTools, McpListToolsTool
from pydantic import BaseModel
from agents import (
Agent,
HostedMCPTool,
ModelResponse,
RunConfig,
RunContextWrapper,
RunHooks,
Runner,
RunState,
ToolCallItem,
ToolCallOutputItem,
ToolOrigin,
ToolOriginType,
Usage,
function_tool,
)
from agents.items import MCPListToolsItem, ToolApprovalItem
from agents.mcp import MCPUtil
from agents.run_internal import run_loop
from agents.run_internal.agent_bindings import bind_public_agent
from agents.run_internal.run_loop import get_output_schema
from agents.run_internal.tool_execution import execute_function_tool_calls
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.factories import make_run_state, make_tool_call, roundtrip_state
TItem = TypeVar("TItem")
def _first_item(items: Sequence[object], item_type: type[TItem]) -> TItem:
for item in items:
if isinstance(item, item_type):
return item
raise AssertionError(f"Expected item of type {item_type.__name__}.")
class StructuredOutputPayload(BaseModel):
status: str
def _make_hosted_mcp_list_tools(server_label: str, tool_name: str) -> McpListTools:
return McpListTools(
id=f"list_{server_label}",
server_label=server_label,
tools=[
McpListToolsTool(
name=tool_name,
input_schema={},
description="Search the docs.",
annotations={"title": "Search Docs"},
)
],
type="mcp_list_tools",
)
@pytest.mark.asyncio
async def test_runner_attaches_function_tool_origin_to_call_and_output_items() -> None:
model = FakeModel()
@function_tool(name_override="lookup_account")
def lookup_account() -> str:
return "account"
agent = Agent(name="tool-origin-agent", model=model, tools=[lookup_account])
model.add_multiple_turn_outputs(
[
[get_function_tool_call("lookup_account", json.dumps({}), call_id="call_lookup")],
[get_text_message("done")],
]
)
result = await Runner.run(agent, input="hello")
expected = ToolOrigin(type=ToolOriginType.FUNCTION)
assert _first_item(result.new_items, ToolCallItem).tool_origin == expected
assert _first_item(result.new_items, ToolCallOutputItem).tool_origin == expected
@pytest.mark.asyncio
async def test_rejected_function_tool_output_preserves_tool_origin() -> None:
model = FakeModel()
@function_tool(name_override="approval_tool", needs_approval=True)
def approval_tool() -> str:
raise AssertionError("The tool should not run when rejected.")
agent = Agent(name="approval-agent", model=model, tools=[approval_tool])
model.add_multiple_turn_outputs(
[
[get_function_tool_call("approval_tool", json.dumps({}), call_id="call_approval")],
[get_text_message("done")],
]
)
first_run = await Runner.run(agent, input="hello")
assert first_run.interruptions
state = first_run.to_state()
state.reject(first_run.interruptions[0])
resumed = await Runner.run(agent, state)
assert _first_item(resumed.new_items, ToolCallOutputItem).tool_origin == ToolOrigin(
type=ToolOriginType.FUNCTION
)
def test_tool_call_output_item_preserves_positional_type_argument() -> None:
agent = Agent(name="positional")
item = ToolCallOutputItem(
agent,
{
"type": "function_call_output",
"call_id": "call_positional",
"output": "result",
},
"result",
"tool_call_output_item",
)
assert item.type == "tool_call_output_item"
assert item.tool_origin is None
@pytest.mark.asyncio
async def test_runner_attaches_local_mcp_tool_origin_to_call_and_output_items() -> None:
model = FakeModel()
server = FakeMCPServer(
server_name="docs_server",
tools=[
MCPTool(
name="search_docs",
inputSchema={},
description="Search the docs.",
title="Search Docs",
)
],
)
agent = Agent(name="mcp-agent", model=model, mcp_servers=[server])
model.add_multiple_turn_outputs(
[
[get_function_tool_call("search_docs", json.dumps({}), call_id="call_search_docs")],
[get_text_message("done")],
]
)
result = await Runner.run(agent, input="hello")
expected = ToolOrigin(type=ToolOriginType.MCP, mcp_server_name="docs_server")
assert _first_item(result.new_items, ToolCallItem).tool_origin == expected
assert _first_item(result.new_items, ToolCallOutputItem).tool_origin == expected
@pytest.mark.asyncio
async def test_streamed_tool_call_item_includes_local_mcp_origin() -> None:
model = FakeModel()
server = FakeMCPServer(
server_name="docs_server",
tools=[
MCPTool(
name="search_docs",
inputSchema={},
description=None,
title="Search Docs",
)
],
)
agent = Agent(name="stream-mcp-agent", model=model, mcp_servers=[server])
model.add_multiple_turn_outputs(
[
[get_function_tool_call("search_docs", json.dumps({}), call_id="call_stream_search")],
[get_text_message("done")],
]
)
result = Runner.run_streamed(agent, input="hello")
seen_tool_item: ToolCallItem | None = None
async for event in result.stream_events():
if (
event.type == "run_item_stream_event"
and isinstance(event.item, ToolCallItem)
and seen_tool_item is None
):
seen_tool_item = event.item
assert seen_tool_item is not None
assert seen_tool_item.tool_origin == ToolOrigin(
type=ToolOriginType.MCP,
mcp_server_name="docs_server",
)
def test_process_model_response_attaches_hosted_mcp_tool_origin() -> None:
agent = Agent(name="hosted-mcp")
hosted_tool = HostedMCPTool(
tool_config=cast(
Any,
{
"type": "mcp",
"server_label": "docs_server",
"server_url": "https://example.com/mcp",
},
)
)
existing_items = [
MCPListToolsItem(
agent=agent,
raw_item=_make_hosted_mcp_list_tools("docs_server", "search_docs"),
)
]
response = ModelResponse(
output=[
McpCall(
id="mcp_call_1",
arguments="{}",
name="search_docs",
server_label="docs_server",
type="mcp_call",
status="completed",
)
],
usage=Usage(),
response_id="resp_hosted_mcp",
)
processed = run_loop.process_model_response(
agent=agent,
all_tools=[hosted_tool],
response=response,
output_schema=None,
handoffs=[],
existing_items=existing_items,
)
tool_call_item = _first_item(processed.new_items, ToolCallItem)
assert tool_call_item.tool_origin == ToolOrigin(
type=ToolOriginType.MCP,
mcp_server_name="docs_server",
)
@pytest.mark.asyncio
async def test_streamed_tool_call_item_includes_hosted_mcp_origin() -> None:
model = FakeModel()
hosted_tool = HostedMCPTool(
tool_config=cast(
Any,
{
"type": "mcp",
"server_label": "docs_server",
"server_url": "https://example.com/mcp",
},
)
)
agent = Agent(name="stream-hosted-mcp", model=model, tools=[hosted_tool])
model.add_multiple_turn_outputs(
[
[
_make_hosted_mcp_list_tools("docs_server", "search_docs"),
McpCall(
id="mcp_call_stream_1",
arguments="{}",
name="search_docs",
server_label="docs_server",
type="mcp_call",
status="completed",
),
],
[get_text_message("done")],
]
)
result = Runner.run_streamed(agent, input="hello")
seen_tool_item: ToolCallItem | None = None
async for event in result.stream_events():
if (
event.type == "run_item_stream_event"
and isinstance(event.item, ToolCallItem)
and isinstance(event.item.raw_item, McpCall)
):
seen_tool_item = event.item
break
assert seen_tool_item is not None
assert seen_tool_item.tool_origin == ToolOrigin(
type=ToolOriginType.MCP,
mcp_server_name="docs_server",
)
def test_local_mcp_tool_origin_does_not_retain_server_object() -> None:
server = FakeMCPServer(server_name="docs_server")
function_tool = MCPUtil.to_function_tool(
MCPTool(
name="search_docs",
inputSchema={},
description="Search the docs.",
title="Search Docs",
),
server,
convert_schemas_to_strict=False,
)
item = ToolCallItem(
agent=Agent(name="release-agent"),
raw_item=make_tool_call(name="search_docs"),
description=function_tool.description,
title=function_tool._mcp_title,
tool_origin=function_tool._tool_origin,
)
server_ref = weakref.ref(server)
item.release_agent()
del function_tool
del server
gc.collect()
assert server_ref() is None
assert item.tool_origin == ToolOrigin(
type=ToolOriginType.MCP,
mcp_server_name="docs_server",
)
@pytest.mark.asyncio
async def test_json_tool_call_does_not_emit_function_tool_origin() -> None:
agent = Agent(name="structured-output", output_type=StructuredOutputPayload)
response = ModelResponse(
output=[
get_function_tool_call(
"json_tool_call",
StructuredOutputPayload(status="ok").model_dump_json(),
call_id="call_json_tool",
)
],
usage=Usage(),
response_id="resp_json_tool",
)
context_wrapper = RunContextWrapper(None)
processed = run_loop.process_model_response(
agent=agent,
all_tools=[],
response=response,
output_schema=get_output_schema(agent),
handoffs=[],
)
tool_call_item = _first_item(processed.new_items, ToolCallItem)
assert tool_call_item.tool_origin is None
function_results, _, _ = await execute_function_tool_calls(
bindings=bind_public_agent(agent),
tool_runs=processed.functions,
hooks=RunHooks(),
context_wrapper=context_wrapper,
config=RunConfig(),
)
tool_output_item = _first_item(
[result.run_item for result in function_results if result.run_item is not None],
ToolCallOutputItem,
)
assert tool_output_item.tool_origin is None
@pytest.mark.asyncio
async def test_run_state_roundtrip_preserves_distinct_agent_tool_names() -> None:
outer_agent = Agent(name="outer")
worker_a = Agent(name="worker")
worker_b = Agent(name="worker")
tool_a = worker_a.as_tool(tool_name="worker_lookup_a", tool_description="Worker A")
tool_b = worker_b.as_tool(tool_name="worker_lookup_b", tool_description="Worker B")
state: RunState[Any, Agent[Any]] = make_run_state(outer_agent)
state._generated_items.extend(
[
ToolCallItem(
agent=outer_agent,
raw_item=make_tool_call(call_id="call_worker_a", name=tool_a.name),
description=tool_a.description,
tool_origin=tool_a._tool_origin,
),
ToolCallItem(
agent=outer_agent,
raw_item=make_tool_call(call_id="call_worker_b", name=tool_b.name),
description=tool_b.description,
tool_origin=tool_b._tool_origin,
),
]
)
restored = await roundtrip_state(outer_agent, state)
restored_items = [item for item in restored._generated_items if isinstance(item, ToolCallItem)]
assert [item.tool_origin for item in restored_items] == [
ToolOrigin(
type=ToolOriginType.AGENT_AS_TOOL,
agent_name="worker",
agent_tool_name="worker_lookup_a",
),
ToolOrigin(
type=ToolOriginType.AGENT_AS_TOOL,
agent_name="worker",
agent_tool_name="worker_lookup_b",
),
]
@pytest.mark.asyncio
async def test_run_state_from_json_reads_legacy_1_5_without_tool_origin() -> None:
agent = Agent(name="legacy")
state: RunState[Any, Agent[Any]] = make_run_state(agent)
state._generated_items.append(
ToolCallItem(
agent=agent,
raw_item=make_tool_call(call_id="call_legacy", name="legacy_tool"),
description="Legacy tool",
tool_origin=ToolOrigin(type=ToolOriginType.FUNCTION),
)
)
restored = await roundtrip_state(
agent,
state,
mutate_json=lambda data: {
**data,
"$schemaVersion": "1.5",
"generated_items": [
{key: value for key, value in item.items() if key != "tool_origin"}
for item in data["generated_items"]
],
},
)
restored_item = _first_item(restored._generated_items, ToolCallItem)
assert restored_item.description == "Legacy tool"
assert restored_item.tool_origin is None
@pytest.mark.asyncio
async def test_run_state_roundtrip_preserves_tool_origin_on_approval_interruptions() -> None:
agent = Agent(name="approval-origin")
state: RunState[Any, Agent[Any]] = make_run_state(agent)
state._generated_items.append(
ToolApprovalItem(
agent=agent,
raw_item=make_tool_call(call_id="call_approval", name="approval_tool"),
tool_name="approval_tool",
tool_origin=ToolOrigin(type=ToolOriginType.FUNCTION),
)
)
restored = await roundtrip_state(agent, state)
approval_item = _first_item(restored._generated_items, ToolApprovalItem)
assert approval_item.tool_origin == ToolOrigin(type=ToolOriginType.FUNCTION)
@pytest.mark.asyncio
async def test_run_state_from_json_reads_legacy_1_6_approval_without_tool_origin() -> None:
agent = Agent(name="approval-origin-legacy")
state: RunState[Any, Agent[Any]] = make_run_state(agent)
state._generated_items.append(
ToolApprovalItem(
agent=agent,
raw_item=make_tool_call(call_id="call_legacy_approval", name="approval_tool"),
tool_name="approval_tool",
tool_origin=ToolOrigin(type=ToolOriginType.FUNCTION),
)
)
restored = await roundtrip_state(
agent,
state,
mutate_json=lambda data: {
**data,
"$schemaVersion": "1.6",
"generated_items": [
{key: value for key, value in item.items() if key != "tool_origin"}
for item in data["generated_items"]
],
},
)
approval_item = _first_item(restored._generated_items, ToolApprovalItem)
assert approval_item.tool_origin is None