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