1091 lines
35 KiB
Python
1091 lines
35 KiB
Python
# Copyright (c) OpenAI
|
|
#
|
|
# Licensed under the MIT License.
|
|
# See LICENSE file in the project root for full license information.
|
|
|
|
"""
|
|
Unit tests for the `Converter` class defined in
|
|
`agents.models.openai_responses`. The converter is responsible for
|
|
translating various agent tool types and output schemas into the parameter
|
|
structures expected by the OpenAI Responses API.
|
|
|
|
We test the following aspects:
|
|
|
|
- `convert_tool_choice` correctly maps high-level tool choice strings into
|
|
the tool choice values accepted by the Responses API, including special types
|
|
like `file_search` and `web_search`, and falling back to function names
|
|
for arbitrary string values.
|
|
- `get_response_format` returns `openai.omit` for plain-text response
|
|
formats and an appropriate format dict when a JSON-structured output schema
|
|
is provided.
|
|
- `convert_tools` maps our internal `Tool` dataclasses into the appropriate
|
|
request payloads and includes list, and enforces constraints like at most
|
|
one `ComputerTool`.
|
|
"""
|
|
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
from openai import omit
|
|
from pydantic import BaseModel
|
|
|
|
from agents import (
|
|
Agent,
|
|
AgentOutputSchema,
|
|
Computer,
|
|
ComputerTool,
|
|
FileSearchTool,
|
|
Handoff,
|
|
HostedMCPTool,
|
|
ShellTool,
|
|
Tool,
|
|
ToolSearchTool,
|
|
UserError,
|
|
WebSearchTool,
|
|
function_tool,
|
|
handoff,
|
|
tool_namespace,
|
|
)
|
|
from agents.model_settings import MCPToolChoice
|
|
from agents.models.openai_responses import Converter
|
|
|
|
|
|
class DummyComputer(Computer):
|
|
@property
|
|
def environment(self):
|
|
return "mac"
|
|
|
|
@property
|
|
def dimensions(self):
|
|
return (800, 600)
|
|
|
|
def screenshot(self) -> str:
|
|
raise NotImplementedError
|
|
|
|
def click(self, x: int, y: int, button: str) -> None:
|
|
raise NotImplementedError
|
|
|
|
def double_click(self, x: int, y: int) -> None:
|
|
raise NotImplementedError
|
|
|
|
def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None:
|
|
raise NotImplementedError
|
|
|
|
def type(self, text: str) -> None:
|
|
raise NotImplementedError
|
|
|
|
def wait(self) -> None:
|
|
raise NotImplementedError
|
|
|
|
def move(self, x: int, y: int) -> None:
|
|
raise NotImplementedError
|
|
|
|
def keypress(self, keys: list[str]) -> None:
|
|
raise NotImplementedError
|
|
|
|
def drag(self, path: list[tuple[int, int]]) -> None:
|
|
raise NotImplementedError
|
|
|
|
|
|
def test_convert_tool_choice_standard_values():
|
|
"""
|
|
Make sure that the standard tool_choice values map to themselves or
|
|
to "auto"/"required"/"none" as appropriate, and that special string
|
|
values map to the appropriate dicts.
|
|
"""
|
|
assert Converter.convert_tool_choice(None) is omit
|
|
assert Converter.convert_tool_choice("auto") == "auto"
|
|
assert Converter.convert_tool_choice("required") == "required"
|
|
assert Converter.convert_tool_choice("none") == "none"
|
|
# Special tool types are represented as dicts of type only.
|
|
assert Converter.convert_tool_choice("file_search") == {"type": "file_search"}
|
|
assert Converter.convert_tool_choice("web_search_preview") == {"type": "web_search_preview"}
|
|
# Arbitrary string should be interpreted as a function name.
|
|
assert Converter.convert_tool_choice("my_function") == {
|
|
"type": "function",
|
|
"name": "my_function",
|
|
}
|
|
|
|
|
|
def test_convert_tool_choice_computer_variants_follow_effective_model() -> None:
|
|
comp_tool = ComputerTool(computer=DummyComputer())
|
|
|
|
assert Converter.convert_tool_choice(
|
|
"computer",
|
|
tools=[comp_tool],
|
|
model="gpt-5.4",
|
|
) == {"type": "computer"}
|
|
assert Converter.convert_tool_choice(
|
|
"computer_use",
|
|
tools=[comp_tool],
|
|
model="gpt-5.4",
|
|
) == {"type": "computer"}
|
|
assert Converter.convert_tool_choice(
|
|
"computer_use_preview",
|
|
tools=[comp_tool],
|
|
model="gpt-5.4",
|
|
) == {"type": "computer"}
|
|
assert Converter.convert_tool_choice(
|
|
"computer_use_preview",
|
|
tools=[comp_tool],
|
|
model="computer-use-preview",
|
|
) == {"type": "computer_use_preview"}
|
|
assert Converter.convert_tool_choice(
|
|
"computer",
|
|
tools=[comp_tool],
|
|
model="computer-use-preview",
|
|
) == {"type": "computer_use_preview"}
|
|
assert Converter.convert_tool_choice(
|
|
"computer_use",
|
|
tools=[comp_tool],
|
|
model="computer-use-preview",
|
|
) == {"type": "computer_use_preview"}
|
|
assert Converter.convert_tool_choice(
|
|
"computer_use",
|
|
tools=[comp_tool],
|
|
model=None,
|
|
) == {"type": "computer"}
|
|
assert Converter.convert_tool_choice(
|
|
"computer",
|
|
tools=[comp_tool],
|
|
model=None,
|
|
) == {"type": "computer"}
|
|
|
|
|
|
def test_convert_tool_choice_allows_function_named_computer_without_computer_tool() -> None:
|
|
computer_function = function_tool(lambda: "ok", name_override="computer")
|
|
computer_use_function = function_tool(lambda: "ok", name_override="computer_use")
|
|
|
|
assert Converter.convert_tool_choice("computer", tools=[computer_function]) == {
|
|
"type": "function",
|
|
"name": "computer",
|
|
}
|
|
assert Converter.convert_tool_choice("computer_use", tools=[computer_use_function]) == {
|
|
"type": "function",
|
|
"name": "computer_use",
|
|
}
|
|
|
|
|
|
def test_convert_tool_choice_allows_function_named_tool_search() -> None:
|
|
tool = function_tool(lambda city: city, name_override="tool_search")
|
|
|
|
assert Converter.convert_tool_choice("tool_search", tools=[tool]) == {
|
|
"type": "function",
|
|
"name": "tool_search",
|
|
}
|
|
|
|
|
|
def test_convert_tool_choice_rejects_hosted_tool_search_choice() -> None:
|
|
deferred_tool = function_tool(
|
|
lambda city: city,
|
|
name_override="lookup_weather",
|
|
defer_loading=True,
|
|
)
|
|
|
|
with pytest.raises(UserError, match="ToolSearchTool\\(\\)"):
|
|
Converter.convert_tool_choice("tool_search", tools=[deferred_tool, ToolSearchTool()])
|
|
|
|
|
|
def test_convert_tool_choice_rejects_tool_search_without_matching_definition() -> None:
|
|
namespaced_tool = tool_namespace(
|
|
name="crm",
|
|
description="CRM tools",
|
|
tools=[function_tool(lambda city: city, name_override="lookup_weather")],
|
|
)[0]
|
|
|
|
with pytest.raises(
|
|
UserError,
|
|
match="requires ToolSearchTool\\(\\) or a real top-level function tool named `tool_search`",
|
|
):
|
|
Converter.convert_tool_choice("tool_search", tools=[namespaced_tool])
|
|
|
|
|
|
def test_convert_tool_choice_allows_function_named_tool_search_with_hosted_tool_search() -> None:
|
|
named_tool = function_tool(lambda city: city, name_override="tool_search")
|
|
deferred_tool = function_tool(
|
|
lambda city: city,
|
|
name_override="lookup_weather",
|
|
defer_loading=True,
|
|
)
|
|
|
|
assert Converter.convert_tool_choice(
|
|
"tool_search",
|
|
tools=[named_tool, deferred_tool, ToolSearchTool()],
|
|
) == {
|
|
"type": "function",
|
|
"name": "tool_search",
|
|
}
|
|
|
|
|
|
def test_convert_tool_choice_required_allows_eager_namespace_tools_without_tool_search() -> None:
|
|
tools = tool_namespace(
|
|
name="crm",
|
|
description="CRM tools",
|
|
tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")],
|
|
)
|
|
|
|
assert Converter.convert_tool_choice("required", tools=tools) == "required"
|
|
|
|
|
|
def test_convert_tool_choice_required_allows_eager_namespace_tools_with_tool_search() -> None:
|
|
tools: list[Tool] = [
|
|
*tool_namespace(
|
|
name="crm",
|
|
description="CRM tools",
|
|
tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")],
|
|
),
|
|
ToolSearchTool(),
|
|
]
|
|
|
|
assert Converter.convert_tool_choice("required", tools=tools) == "required"
|
|
|
|
|
|
def test_convert_tool_choice_required_rejects_deferred_function_tools() -> None:
|
|
tools: list[Tool] = [
|
|
function_tool(
|
|
lambda customer_id: customer_id,
|
|
name_override="lookup_account",
|
|
defer_loading=True,
|
|
)
|
|
]
|
|
|
|
with pytest.raises(UserError, match="ToolSearchTool\\(\\)"):
|
|
Converter.convert_tool_choice("required", tools=tools)
|
|
|
|
|
|
def test_convert_tool_choice_required_allows_deferred_function_tools_with_tool_search() -> None:
|
|
tools: list[Tool] = [
|
|
function_tool(
|
|
lambda customer_id: customer_id,
|
|
name_override="lookup_account",
|
|
defer_loading=True,
|
|
),
|
|
ToolSearchTool(),
|
|
]
|
|
|
|
assert Converter.convert_tool_choice("required", tools=tools) == "required"
|
|
|
|
|
|
def test_convert_tool_choice_required_allows_deferred_hosted_mcp_tools_with_tool_search() -> None:
|
|
tools: list[Tool] = [
|
|
HostedMCPTool(
|
|
tool_config=cast(
|
|
Any,
|
|
{
|
|
"type": "mcp",
|
|
"server_label": "crm_server",
|
|
"server_url": "https://example.com/mcp",
|
|
"defer_loading": True,
|
|
},
|
|
)
|
|
),
|
|
ToolSearchTool(),
|
|
]
|
|
|
|
assert Converter.convert_tool_choice("required", tools=tools) == "required"
|
|
|
|
|
|
def test_convert_tool_choice_allows_qualified_namespaced_function_tools() -> None:
|
|
namespaced_tool = tool_namespace(
|
|
name="crm",
|
|
description="CRM tools",
|
|
tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")],
|
|
)[0]
|
|
|
|
assert Converter.convert_tool_choice("crm.lookup_account", tools=[namespaced_tool]) == {
|
|
"type": "function",
|
|
"name": "crm.lookup_account",
|
|
}
|
|
|
|
|
|
def test_convert_tool_choice_rejects_namespace_wrapper_and_bare_inner_name() -> None:
|
|
namespaced_tool = tool_namespace(
|
|
name="crm",
|
|
description="CRM tools",
|
|
tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")],
|
|
)[0]
|
|
|
|
with pytest.raises(UserError, match="tool_namespace\\(\\)"):
|
|
Converter.convert_tool_choice("lookup_account", tools=[namespaced_tool])
|
|
|
|
with pytest.raises(UserError, match="tool_namespace\\(\\)"):
|
|
Converter.convert_tool_choice("crm", tools=[namespaced_tool])
|
|
|
|
|
|
def test_convert_tool_choice_allows_top_level_function_with_namespaced_tools_present() -> None:
|
|
top_level_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account")
|
|
namespaced_tool = tool_namespace(
|
|
name="crm",
|
|
description="CRM tools",
|
|
tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")],
|
|
)[0]
|
|
|
|
assert Converter.convert_tool_choice(
|
|
"lookup_account",
|
|
tools=[top_level_tool, namespaced_tool],
|
|
) == {"type": "function", "name": "lookup_account"}
|
|
|
|
|
|
def test_convert_tool_choice_allows_handoff_with_namespaced_function_name_clash() -> None:
|
|
namespaced_tool = tool_namespace(
|
|
name="crm",
|
|
description="CRM tools",
|
|
tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")],
|
|
)[0]
|
|
transfer_handoff = handoff(Agent(name="specialist"), tool_name_override="lookup_account")
|
|
|
|
assert Converter.convert_tool_choice(
|
|
"lookup_account",
|
|
tools=[namespaced_tool],
|
|
handoffs=[transfer_handoff],
|
|
) == {"type": "function", "name": "lookup_account"}
|
|
|
|
|
|
def test_convert_tool_choice_rejects_deferred_only_function_tools() -> None:
|
|
deferred_tool = function_tool(
|
|
lambda customer_id: customer_id,
|
|
name_override="lookup_account",
|
|
defer_loading=True,
|
|
)
|
|
|
|
with pytest.raises(UserError, match="deferred-loading function tools"):
|
|
Converter.convert_tool_choice("lookup_account", tools=[deferred_tool])
|
|
|
|
|
|
def test_convert_tool_choice_allows_visible_top_level_function_with_deferred_peer() -> None:
|
|
top_level_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account")
|
|
deferred_tool = function_tool(
|
|
lambda customer_id: customer_id,
|
|
name_override="lookup_account",
|
|
defer_loading=True,
|
|
)
|
|
|
|
assert Converter.convert_tool_choice(
|
|
"lookup_account",
|
|
tools=[top_level_tool, deferred_tool],
|
|
) == {"type": "function", "name": "lookup_account"}
|
|
|
|
|
|
def test_get_response_format_plain_text_and_json_schema():
|
|
"""
|
|
For plain text output (default, or output type of `str`), the converter
|
|
should return omit, indicating no special response format constraint.
|
|
If an output schema is provided for a structured type, the converter
|
|
should return a `format` dict with the schema and strictness. The exact
|
|
JSON schema depends on the output type; we just assert that required
|
|
keys are present and that we get back the original schema.
|
|
"""
|
|
# Default output (None) should be considered plain text.
|
|
assert Converter.get_response_format(None) is omit
|
|
# An explicit plain-text schema (str) should also yield omit.
|
|
assert Converter.get_response_format(AgentOutputSchema(str)) is omit
|
|
|
|
# A model-based schema should produce a format dict.
|
|
class OutModel(BaseModel):
|
|
foo: int
|
|
bar: str
|
|
|
|
out_schema = AgentOutputSchema(OutModel)
|
|
fmt = Converter.get_response_format(out_schema)
|
|
assert isinstance(fmt, dict)
|
|
assert "format" in fmt
|
|
inner = fmt["format"]
|
|
assert inner.get("type") == "json_schema"
|
|
assert inner.get("name") == "final_output"
|
|
assert isinstance(inner.get("schema"), dict)
|
|
# Should include a strict flag matching the schema's strictness setting.
|
|
assert inner.get("strict") == out_schema.is_strict_json_schema()
|
|
|
|
|
|
def test_convert_tools_basic_types_and_includes():
|
|
"""
|
|
Construct a variety of tool types and make sure `convert_tools` returns
|
|
a matching list of tool param dicts and the expected includes. Also
|
|
check that only a single computer tool is allowed.
|
|
"""
|
|
# Simple function tool
|
|
tool_fn = function_tool(lambda a: "x", name_override="fn")
|
|
# File search tool with include_search_results set
|
|
file_tool = FileSearchTool(
|
|
max_num_results=3, vector_store_ids=["vs1"], include_search_results=True
|
|
)
|
|
# Web search tool with custom params
|
|
web_tool = WebSearchTool(user_location=None, search_context_size="high")
|
|
|
|
# Wrap our concrete computer in a ComputerTool for conversion.
|
|
comp_tool = ComputerTool(computer=DummyComputer())
|
|
tools: list[Tool] = [tool_fn, file_tool, web_tool, comp_tool]
|
|
converted = Converter.convert_tools(tools, handoffs=[], model="gpt-5.4")
|
|
assert isinstance(converted.tools, list)
|
|
assert isinstance(converted.includes, list)
|
|
# The includes list should have exactly the include for file search when include_search_results
|
|
# is True.
|
|
assert converted.includes == ["file_search_call.results"]
|
|
# There should be exactly four converted tool dicts.
|
|
assert len(converted.tools) == 4
|
|
# Extract types and verify.
|
|
types = [ct["type"] for ct in converted.tools]
|
|
assert "function" in types
|
|
assert "file_search" in types
|
|
assert "web_search" in types
|
|
assert "computer" in types
|
|
# Verify file search tool contains max_num_results and vector_store_ids
|
|
file_params = next(ct for ct in converted.tools if ct["type"] == "file_search")
|
|
assert file_params.get("max_num_results") == file_tool.max_num_results
|
|
assert file_params.get("vector_store_ids") == file_tool.vector_store_ids
|
|
# Verify web search tool contains user_location and search_context_size
|
|
web_params = next(ct for ct in converted.tools if ct["type"] == "web_search")
|
|
assert web_params.get("user_location") == web_tool.user_location
|
|
assert web_params.get("search_context_size") == web_tool.search_context_size
|
|
assert "external_web_access" not in web_params
|
|
# Verify computer tool uses the GA built-in tool payload.
|
|
comp_params = next(ct for ct in converted.tools if ct["type"] == "computer")
|
|
assert comp_params == {"type": "computer"}
|
|
# The function tool dict should have name and description fields.
|
|
fn_params = next(ct for ct in converted.tools if ct["type"] == "function")
|
|
assert fn_params.get("name") == tool_fn.name
|
|
assert fn_params.get("description") == tool_fn.description
|
|
|
|
# Only one computer tool should be allowed.
|
|
with pytest.raises(UserError):
|
|
Converter.convert_tools(tools=[comp_tool, comp_tool], handoffs=[])
|
|
|
|
|
|
def test_convert_tools_includes_explicit_false_external_web_access() -> None:
|
|
web_tool = WebSearchTool(external_web_access=False)
|
|
|
|
converted = Converter.convert_tools([web_tool], handoffs=[], model="gpt-5.4")
|
|
|
|
assert converted.includes == []
|
|
assert converted.tools == [
|
|
{
|
|
"type": "web_search",
|
|
"filters": None,
|
|
"user_location": None,
|
|
"search_context_size": "medium",
|
|
"external_web_access": False,
|
|
}
|
|
]
|
|
|
|
|
|
def test_convert_tools_uses_preview_computer_payload_for_preview_model() -> None:
|
|
comp_tool = ComputerTool(computer=DummyComputer())
|
|
|
|
converted = Converter.convert_tools(
|
|
tools=[comp_tool],
|
|
handoffs=[],
|
|
model="computer-use-preview",
|
|
)
|
|
|
|
assert converted.tools == [
|
|
{
|
|
"type": "computer_use_preview",
|
|
"environment": "mac",
|
|
"display_width": 800,
|
|
"display_height": 600,
|
|
}
|
|
]
|
|
|
|
|
|
def test_convert_tools_prompt_managed_computer_defaults_to_preview_payload() -> None:
|
|
comp_tool = ComputerTool(computer=DummyComputer())
|
|
|
|
converted = Converter.convert_tools(
|
|
tools=[comp_tool],
|
|
handoffs=[],
|
|
model=None,
|
|
)
|
|
|
|
assert converted.tools == [
|
|
{
|
|
"type": "computer_use_preview",
|
|
"environment": "mac",
|
|
"display_width": 800,
|
|
"display_height": 600,
|
|
}
|
|
]
|
|
|
|
|
|
def test_convert_tools_shell_local_environment() -> None:
|
|
shell_tool = ShellTool(executor=lambda request: "ok")
|
|
|
|
converted = Converter.convert_tools(tools=[shell_tool], handoffs=[])
|
|
|
|
assert converted.tools == [{"type": "shell", "environment": {"type": "local"}}]
|
|
assert converted.includes == []
|
|
|
|
|
|
def test_convert_tools_shell_container_reference_environment() -> None:
|
|
shell_tool = ShellTool(environment={"type": "container_reference", "container_id": "cntr_123"})
|
|
|
|
converted = Converter.convert_tools(tools=[shell_tool], handoffs=[])
|
|
|
|
assert converted.tools == [
|
|
{
|
|
"type": "shell",
|
|
"environment": {
|
|
"type": "container_reference",
|
|
"container_id": "cntr_123",
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
def test_convert_tools_shell_container_auto_environment() -> None:
|
|
shell_tool = ShellTool(
|
|
environment={
|
|
"type": "container_auto",
|
|
"file_ids": ["file-123"],
|
|
"memory_limit": "1g",
|
|
"network_policy": {
|
|
"type": "allowlist",
|
|
"allowed_domains": ["example.com"],
|
|
"domain_secrets": [{"domain": "example.com", "name": "TOKEN", "value": "secret"}],
|
|
},
|
|
"skills": [
|
|
{"type": "skill_reference", "skill_id": "skill_123", "version": "latest"},
|
|
{
|
|
"type": "inline",
|
|
"name": "csv-workbench",
|
|
"description": "Analyze CSV files.",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": "application/zip",
|
|
"data": "ZmFrZS16aXA=",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
)
|
|
|
|
converted = Converter.convert_tools(tools=[shell_tool], handoffs=[])
|
|
|
|
assert converted.tools == [
|
|
{
|
|
"type": "shell",
|
|
"environment": {
|
|
"type": "container_auto",
|
|
"file_ids": ["file-123"],
|
|
"memory_limit": "1g",
|
|
"network_policy": {
|
|
"type": "allowlist",
|
|
"allowed_domains": ["example.com"],
|
|
"domain_secrets": [
|
|
{"domain": "example.com", "name": "TOKEN", "value": "secret"}
|
|
],
|
|
},
|
|
"skills": [
|
|
{
|
|
"type": "skill_reference",
|
|
"skill_id": "skill_123",
|
|
"version": "latest",
|
|
},
|
|
{
|
|
"type": "inline",
|
|
"name": "csv-workbench",
|
|
"description": "Analyze CSV files.",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": "application/zip",
|
|
"data": "ZmFrZS16aXA=",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
def test_convert_tools_tool_search_and_namespaces() -> None:
|
|
eager_tool = function_tool(
|
|
lambda customer_id: customer_id, name_override="get_customer_profile"
|
|
)
|
|
deferred_tool = function_tool(
|
|
lambda customer_id: customer_id,
|
|
name_override="list_open_orders",
|
|
defer_loading=True,
|
|
)
|
|
|
|
converted = Converter.convert_tools(
|
|
tools=[
|
|
*tool_namespace(
|
|
name="crm",
|
|
description="CRM tools for customer lookups.",
|
|
tools=[eager_tool, deferred_tool],
|
|
),
|
|
ToolSearchTool(),
|
|
],
|
|
handoffs=[],
|
|
)
|
|
|
|
assert converted.includes == []
|
|
assert converted.tools == [
|
|
{
|
|
"type": "namespace",
|
|
"name": "crm",
|
|
"description": "CRM tools for customer lookups.",
|
|
"tools": [
|
|
{
|
|
"type": "function",
|
|
"name": "get_customer_profile",
|
|
"description": eager_tool.description,
|
|
"parameters": eager_tool.params_json_schema,
|
|
"strict": True,
|
|
},
|
|
{
|
|
"type": "function",
|
|
"name": "list_open_orders",
|
|
"description": deferred_tool.description,
|
|
"parameters": deferred_tool.params_json_schema,
|
|
"strict": True,
|
|
"defer_loading": True,
|
|
},
|
|
],
|
|
},
|
|
{"type": "tool_search"},
|
|
]
|
|
|
|
|
|
def test_convert_tools_top_level_deferred_function_requires_tool_search() -> None:
|
|
deferred_tool = function_tool(
|
|
lambda city: city,
|
|
name_override="get_weather",
|
|
defer_loading=True,
|
|
)
|
|
|
|
with pytest.raises(UserError, match="ToolSearchTool\\(\\)"):
|
|
Converter.convert_tools(tools=[deferred_tool], handoffs=[])
|
|
|
|
|
|
def test_convert_tools_rejects_tool_search_without_deferred_function() -> None:
|
|
eager_tool = function_tool(lambda city: city, name_override="get_weather")
|
|
|
|
with pytest.raises(
|
|
UserError,
|
|
match=("ToolSearchTool\\(\\) requires at least one searchable Responses surface"),
|
|
):
|
|
Converter.convert_tools(tools=[eager_tool, ToolSearchTool()], handoffs=[])
|
|
|
|
|
|
def test_convert_tools_allows_prompt_managed_tool_search_without_local_surface() -> None:
|
|
converted = Converter.convert_tools(
|
|
tools=[ToolSearchTool()],
|
|
handoffs=[],
|
|
allow_opaque_tool_search_surface=True,
|
|
)
|
|
|
|
assert converted.tools == [{"type": "tool_search"}]
|
|
|
|
|
|
def test_convert_tools_rejects_duplicate_tool_search_tools() -> None:
|
|
deferred_tool = function_tool(
|
|
lambda city: city,
|
|
name_override="get_weather",
|
|
defer_loading=True,
|
|
)
|
|
|
|
with pytest.raises(UserError, match="Only one ToolSearchTool\\(\\) is allowed"):
|
|
Converter.convert_tools(
|
|
tools=[deferred_tool, ToolSearchTool(), ToolSearchTool()],
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
def test_convert_tools_top_level_deferred_function_with_tool_search() -> None:
|
|
deferred_tool = function_tool(
|
|
lambda city: city,
|
|
name_override="get_weather",
|
|
defer_loading=True,
|
|
)
|
|
|
|
converted = Converter.convert_tools(tools=[deferred_tool, ToolSearchTool()], handoffs=[])
|
|
|
|
assert converted.tools == [
|
|
{
|
|
"type": "function",
|
|
"name": "get_weather",
|
|
"description": deferred_tool.description,
|
|
"parameters": deferred_tool.params_json_schema,
|
|
"strict": True,
|
|
"defer_loading": True,
|
|
},
|
|
{"type": "tool_search"},
|
|
]
|
|
|
|
|
|
def test_convert_tools_preserves_tool_search_config_fields() -> None:
|
|
deferred_tool = function_tool(
|
|
lambda city: city,
|
|
name_override="get_weather",
|
|
defer_loading=True,
|
|
)
|
|
|
|
converted = Converter.convert_tools(
|
|
tools=[
|
|
deferred_tool,
|
|
ToolSearchTool(
|
|
description="Search deferred tools on the server.",
|
|
execution="server",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string"},
|
|
},
|
|
"required": ["query"],
|
|
},
|
|
),
|
|
],
|
|
handoffs=[],
|
|
)
|
|
|
|
assert converted.tools[-1] == {
|
|
"type": "tool_search",
|
|
"description": "Search deferred tools on the server.",
|
|
"execution": "server",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
}
|
|
|
|
|
|
def test_convert_tools_allows_client_executed_tool_search_for_manual_flows() -> None:
|
|
deferred_tool = function_tool(
|
|
lambda city: city,
|
|
name_override="get_weather",
|
|
defer_loading=True,
|
|
)
|
|
|
|
converted = Converter.convert_tools(
|
|
tools=[
|
|
deferred_tool,
|
|
ToolSearchTool(
|
|
execution="client",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
),
|
|
],
|
|
handoffs=[],
|
|
)
|
|
|
|
assert converted.tools[-1] == {
|
|
"type": "tool_search",
|
|
"execution": "client",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
}
|
|
|
|
|
|
def test_convert_tools_namespace_only_allows_eager_namespaces_without_tool_search() -> None:
|
|
crm_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account")
|
|
|
|
converted = Converter.convert_tools(
|
|
tools=[
|
|
*tool_namespace(
|
|
name="crm",
|
|
description="CRM tools",
|
|
tools=[crm_tool],
|
|
),
|
|
],
|
|
handoffs=[],
|
|
)
|
|
|
|
assert converted.tools == [
|
|
{
|
|
"type": "namespace",
|
|
"name": "crm",
|
|
"description": "CRM tools",
|
|
"tools": [
|
|
{
|
|
"type": "function",
|
|
"name": "lookup_account",
|
|
"description": crm_tool.description,
|
|
"parameters": crm_tool.params_json_schema,
|
|
"strict": True,
|
|
}
|
|
],
|
|
}
|
|
]
|
|
|
|
|
|
def test_convert_tools_allows_tool_search_with_namespace_only_tools() -> None:
|
|
crm_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account")
|
|
|
|
converted = Converter.convert_tools(
|
|
tools=[
|
|
*tool_namespace(
|
|
name="crm",
|
|
description="CRM tools",
|
|
tools=[crm_tool],
|
|
),
|
|
ToolSearchTool(),
|
|
],
|
|
handoffs=[],
|
|
)
|
|
|
|
assert converted.tools == [
|
|
{
|
|
"type": "namespace",
|
|
"name": "crm",
|
|
"description": "CRM tools",
|
|
"tools": [
|
|
{
|
|
"type": "function",
|
|
"name": "lookup_account",
|
|
"description": crm_tool.description,
|
|
"parameters": crm_tool.params_json_schema,
|
|
"strict": True,
|
|
}
|
|
],
|
|
},
|
|
{"type": "tool_search"},
|
|
]
|
|
|
|
|
|
def test_convert_tools_deferred_hosted_mcp_requires_tool_search() -> None:
|
|
hosted_mcp = HostedMCPTool(
|
|
tool_config=cast(
|
|
Any,
|
|
{
|
|
"type": "mcp",
|
|
"server_label": "crm_server",
|
|
"server_url": "https://example.com/mcp",
|
|
"defer_loading": True,
|
|
},
|
|
)
|
|
)
|
|
|
|
with pytest.raises(UserError, match="ToolSearchTool\\(\\)"):
|
|
Converter.convert_tools(tools=[hosted_mcp], handoffs=[])
|
|
|
|
|
|
def test_convert_tools_deferred_hosted_mcp_with_tool_search() -> None:
|
|
hosted_mcp = HostedMCPTool(
|
|
tool_config=cast(
|
|
Any,
|
|
{
|
|
"type": "mcp",
|
|
"server_label": "crm_server",
|
|
"server_url": "https://example.com/mcp",
|
|
"defer_loading": True,
|
|
},
|
|
)
|
|
)
|
|
|
|
converted = Converter.convert_tools(tools=[hosted_mcp, ToolSearchTool()], handoffs=[])
|
|
|
|
assert converted.tools == [
|
|
{
|
|
"type": "mcp",
|
|
"server_label": "crm_server",
|
|
"server_url": "https://example.com/mcp",
|
|
"defer_loading": True,
|
|
},
|
|
{"type": "tool_search"},
|
|
]
|
|
|
|
|
|
def test_convert_tools_rejects_reserved_same_name_namespace_shape() -> None:
|
|
invalid_tool = function_tool(lambda customer_id: customer_id, name_override="lookup_account")
|
|
invalid_tool._tool_namespace = "lookup_account"
|
|
invalid_tool._tool_namespace_description = "Same-name namespace"
|
|
|
|
with pytest.raises(UserError, match="synthetic namespace `lookup_account.lookup_account`"):
|
|
Converter.convert_tools(
|
|
tools=[invalid_tool, ToolSearchTool()],
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
def test_convert_tools_rejects_qualified_name_collision_with_dotted_top_level_tool() -> None:
|
|
dotted_top_level_tool = function_tool(
|
|
lambda customer_id: customer_id,
|
|
name_override="crm.lookup_account",
|
|
)
|
|
namespaced_tool = tool_namespace(
|
|
name="crm",
|
|
description="CRM tools",
|
|
tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")],
|
|
)[0]
|
|
|
|
with pytest.raises(UserError, match="qualified name `crm.lookup_account`"):
|
|
Converter.convert_tools(
|
|
tools=[dotted_top_level_tool, namespaced_tool, ToolSearchTool()],
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
def test_convert_tools_rejects_duplicate_deferred_top_level_names() -> None:
|
|
first_deferred_tool = function_tool(
|
|
lambda customer_id: customer_id,
|
|
name_override="lookup_account",
|
|
defer_loading=True,
|
|
)
|
|
second_deferred_tool = function_tool(
|
|
lambda customer_id: customer_id,
|
|
name_override="lookup_account",
|
|
defer_loading=True,
|
|
)
|
|
|
|
with pytest.raises(UserError, match="deferred top-level tool name `lookup_account`"):
|
|
Converter.convert_tools(
|
|
tools=[first_deferred_tool, second_deferred_tool, ToolSearchTool()],
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
def test_convert_tools_allows_dotted_non_function_tool_name_with_namespaced_function() -> None:
|
|
shell_tool = ShellTool(executor=lambda request: "ok", name="crm.lookup_account")
|
|
namespaced_tool = tool_namespace(
|
|
name="crm",
|
|
description="CRM tools",
|
|
tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")],
|
|
)[0]
|
|
|
|
converted = Converter.convert_tools(
|
|
tools=[shell_tool, namespaced_tool],
|
|
handoffs=[],
|
|
)
|
|
|
|
assert len(converted.tools) == 2
|
|
namespace_tool = cast(
|
|
dict[str, Any],
|
|
next(
|
|
tool
|
|
for tool in converted.tools
|
|
if isinstance(tool, dict) and tool.get("type") == "namespace"
|
|
),
|
|
)
|
|
shell_payload = cast(
|
|
dict[str, Any],
|
|
next(
|
|
tool
|
|
for tool in converted.tools
|
|
if isinstance(tool, dict) and tool.get("type") == "shell"
|
|
),
|
|
)
|
|
assert shell_payload["environment"] == {"type": "local"}
|
|
assert namespace_tool["name"] == "crm"
|
|
assert namespace_tool["tools"][0]["name"] == "lookup_account"
|
|
|
|
|
|
def test_convert_tools_shell_environment_passes_through_unknown_fields() -> None:
|
|
shell_tool = ShellTool(
|
|
environment=cast(
|
|
Any,
|
|
{
|
|
"type": "container_auto",
|
|
"network_policy": {
|
|
"type": "future_mode",
|
|
"allowed_domains": ["example.com"],
|
|
"some_new_field": "keep-me",
|
|
},
|
|
},
|
|
)
|
|
)
|
|
|
|
converted = Converter.convert_tools(tools=[shell_tool], handoffs=[])
|
|
assert converted.tools == [
|
|
{
|
|
"type": "shell",
|
|
"environment": {
|
|
"type": "container_auto",
|
|
"network_policy": {
|
|
"type": "future_mode",
|
|
"allowed_domains": ["example.com"],
|
|
"some_new_field": "keep-me",
|
|
},
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
def test_convert_tools_includes_handoffs():
|
|
"""
|
|
When handoff objects are included, `convert_tools` should append their
|
|
tool param dicts after tools and include appropriate descriptions.
|
|
"""
|
|
agent = Agent(name="support", handoff_description="Handles support")
|
|
handoff_obj = handoff(agent)
|
|
converted = Converter.convert_tools(tools=[], handoffs=[handoff_obj])
|
|
assert isinstance(converted.tools, list)
|
|
assert len(converted.tools) == 1
|
|
handoff_tool = converted.tools[0]
|
|
assert handoff_tool.get("type") == "function"
|
|
assert handoff_tool.get("name") == Handoff.default_tool_name(agent)
|
|
assert handoff_tool.get("description") == Handoff.default_tool_description(agent)
|
|
# No includes for handoffs by default.
|
|
assert converted.includes == []
|
|
|
|
|
|
@pytest.mark.parametrize("model", ["gpt-5.4", "gpt-5.5"])
|
|
def test_convert_tools_accepts_unresolved_computer_initializer(model: str):
|
|
comp_tool = ComputerTool(computer=lambda **_: DummyComputer())
|
|
converted = Converter.convert_tools(tools=[comp_tool], handoffs=[], model=model)
|
|
assert converted.tools == [{"type": "computer"}]
|
|
|
|
|
|
def test_resolve_computer_tool_model_returns_none_when_request_model_is_omitted():
|
|
comp_tool = ComputerTool(computer=lambda **_: DummyComputer())
|
|
|
|
resolved = Converter.resolve_computer_tool_model(
|
|
request_model=None,
|
|
tools=[comp_tool],
|
|
)
|
|
|
|
assert resolved is None
|
|
|
|
|
|
@pytest.mark.parametrize("model", ["gpt-5.4", "gpt-5.5"])
|
|
def test_convert_tools_preview_tool_choice_uses_ga_payload_for_ga_model(model: str) -> None:
|
|
comp_tool = ComputerTool(computer=lambda **_: DummyComputer())
|
|
|
|
converted = Converter.convert_tools(
|
|
tools=[comp_tool],
|
|
handoffs=[],
|
|
model=model,
|
|
tool_choice="computer_use_preview",
|
|
)
|
|
|
|
assert converted.tools == [{"type": "computer"}]
|
|
|
|
|
|
def test_convert_tools_prompt_managed_computer_respects_explicit_ga_tool_choice() -> None:
|
|
comp_tool = ComputerTool(computer=lambda **_: DummyComputer())
|
|
|
|
converted = Converter.convert_tools(
|
|
tools=[comp_tool],
|
|
handoffs=[],
|
|
model=None,
|
|
tool_choice="computer_use",
|
|
)
|
|
|
|
assert converted.tools == [{"type": "computer"}]
|
|
|
|
|
|
def test_convert_tools_prompt_managed_computer_accepts_mcp_tool_choice() -> None:
|
|
comp_tool = ComputerTool(computer=DummyComputer())
|
|
|
|
converted = Converter.convert_tools(
|
|
tools=[comp_tool],
|
|
handoffs=[],
|
|
model=None,
|
|
tool_choice=MCPToolChoice(server_label="remote", name="lookup_account"),
|
|
)
|
|
|
|
assert converted.tools == [
|
|
{
|
|
"type": "computer_use_preview",
|
|
"environment": "mac",
|
|
"display_width": 800,
|
|
"display_height": 600,
|
|
}
|
|
]
|