Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/agents/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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})`)."
)

Expand Down
69 changes: 68 additions & 1 deletion tests/models/test_openai_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +28,7 @@
AsyncComputer,
Computer,
ComputerTool,
CustomTool,
ImageGenerationTool,
ModelSettings,
ModelTracing,
Expand Down Expand Up @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions tests/models/test_openai_responses_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
AgentOutputSchema,
Computer,
ComputerTool,
CustomTool,
FileSearchTool,
Handoff,
HostedMCPTool,
Expand Down Expand Up @@ -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,
Expand Down