diff --git a/src/agents/tool.py b/src/agents/tool.py index 4bcb0f670c..0d88f33719 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -1715,6 +1715,8 @@ def is_responses_tool_search_surface(tool: Tool) -> bool: """Return True when a tool can be exposed through hosted Responses tool search.""" if isinstance(tool, FunctionTool): return tool.defer_loading or get_explicit_function_tool_namespace(tool) is not None + if isinstance(tool, CustomTool): + return tool.defer_loading if isinstance(tool, HostedMCPTool): return bool(tool.tool_config.get("defer_loading")) return False @@ -1729,6 +1731,8 @@ def is_required_tool_search_surface(tool: Tool) -> bool: """Return True when a tool requires ToolSearchTool() to stay reachable.""" if isinstance(tool, FunctionTool): return tool.defer_loading + if isinstance(tool, CustomTool): + return tool.defer_loading if isinstance(tool, HostedMCPTool): return bool(tool.tool_config.get("defer_loading")) return False @@ -1763,7 +1767,8 @@ def validate_responses_tool_search_configuration( raise UserError( "ToolSearchTool() requires at least one searchable Responses surface: a " "tool_namespace(...) function tool, a deferred-loading function tool " - "(`function_tool(..., defer_loading=True)`), or a deferred-loading hosted MCP " + "(`function_tool(..., defer_loading=True)`), a deferred-loading custom tool " + "(`CustomTool(..., defer_loading=True)`), or a deferred-loading hosted MCP " "server (`HostedMCPTool(tool_config={..., 'defer_loading': True})`)." ) diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index 9808f443ba..af210435ab 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -11,7 +11,12 @@ import httpx2 import pytest from openai import NOT_GIVEN, APIConnectionError, AsyncOpenAI, RateLimitError, omit -from openai.types.responses import Response, ResponseCompletedEvent, ResponseErrorEvent +from openai.types.responses import ( + Response, + ResponseCompletedEvent, + ResponseCustomToolCall, + ResponseErrorEvent, +) from openai.types.responses.response import IncompleteDetails from openai.types.responses.response_create_params import ContextManagement, PromptCacheOptions from openai.types.responses.response_usage import ResponseUsage @@ -23,6 +28,7 @@ AsyncComputer, Computer, ComputerTool, + CustomTool, ImageGenerationTool, ModelSettings, ModelTracing, @@ -1773,6 +1779,67 @@ def __init__(self): assert called_kwargs["tools"] == [{"type": "tool_search"}] +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_deferred_custom_tool_is_searchable_and_invocable() -> None: + invoked_inputs: list[str] = [] + sent_tools: list[Any] = [] + + class DummyResponses: + def __init__(self) -> None: + self.call_count = 0 + + async def create(self, **kwargs): + self.call_count += 1 + if self.call_count == 1: + sent_tools.extend(kwargs["tools"]) + return get_response_obj( + [ + ResponseCustomToolCall( + type="custom_tool_call", + id="ctc_1", + call_id="call_1", + name="raw_editor", + input="hello", + ) + ] + ) + return get_response_obj([]) + + class DummyResponsesClient: + def __init__(self): + self.responses = DummyResponses() + + def on_invoke_tool(_ctx: Any, raw_input: str) -> str: + invoked_inputs.append(raw_input) + return "edited" + + deferred_tool = CustomTool( + name="raw_editor", + description="Edit raw text.", + on_invoke_tool=on_invoke_tool, + defer_loading=True, + ) + model = OpenAIResponsesModel( + model="gpt-5.4", + openai_client=DummyResponsesClient(), # type: ignore[arg-type] + ) + agent = Agent(name="test", model=model, tools=[ToolSearchTool(), deferred_tool]) + + await Runner.run(agent, "hi") + + assert sent_tools == [ + {"type": "tool_search"}, + { + "type": "custom", + "name": "raw_editor", + "description": "Edit raw text.", + "defer_loading": True, + }, + ] + assert invoked_inputs == ["hello"] + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_ga_computer_tool_does_not_require_preview_metadata() -> None: diff --git a/tests/models/test_openai_responses_converter.py b/tests/models/test_openai_responses_converter.py index 9c64107892..eb1aa0267f 100644 --- a/tests/models/test_openai_responses_converter.py +++ b/tests/models/test_openai_responses_converter.py @@ -36,6 +36,7 @@ AgentOutputSchema, Computer, ComputerTool, + CustomTool, FileSearchTool, Handoff, HostedMCPTool, @@ -848,6 +849,39 @@ def test_convert_tools_top_level_deferred_function_with_tool_search() -> None: ] +def test_convert_tools_deferred_custom_tool_requires_tool_search() -> None: + deferred_tool = CustomTool( + name="raw_editor", + description="Edit raw text.", + on_invoke_tool=lambda _ctx, raw_input: raw_input, + defer_loading=True, + ) + + with pytest.raises(UserError, match="ToolSearchTool\\(\\)"): + Converter.convert_tools(tools=[deferred_tool], handoffs=[]) + + +def test_convert_tools_deferred_custom_tool_with_tool_search() -> None: + deferred_tool = CustomTool( + name="raw_editor", + description="Edit raw text.", + on_invoke_tool=lambda _ctx, raw_input: raw_input, + defer_loading=True, + ) + + converted = Converter.convert_tools(tools=[deferred_tool, ToolSearchTool()], handoffs=[]) + + assert converted.tools == [ + { + "type": "custom", + "name": "raw_editor", + "description": deferred_tool.description, + "defer_loading": True, + }, + {"type": "tool_search"}, + ] + + def test_convert_tools_preserves_tool_search_config_fields() -> None: deferred_tool = function_tool( lambda city: city,