From bd826500d7d1909b7c0a30297e37e7ea510d3494 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 26 Feb 2026 20:43:10 +0900 Subject: [PATCH 1/2] Fix OpenAIResponsesClient mishandling single-tool inputs (#4304) Use normalize_tools() in _prepare_tools_for_openai to wrap single tools (FunctionTool or dict) in a list before iteration, consistent with the chat client implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openai/_responses_client.py | 7 +++-- .../openai/test_openai_responses_client.py | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index fa140ee0b7..e6c5a4f56b 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -43,6 +43,7 @@ FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool, + normalize_tools, ) from .._types import ( Annotation, @@ -425,13 +426,13 @@ def _get_conversation_id( # region Prep methods - def _prepare_tools_for_openai(self, tools: Sequence[Any] | None) -> list[Any]: + def _prepare_tools_for_openai(self, tools: Sequence[Any] | Any | None) -> list[Any]: """Prepare tools for the OpenAI Responses API. Converts FunctionTool to Responses API format. All other tools pass through unchanged. Args: - tools: Sequence of tools to prepare. + tools: A single tool or sequence of tools to prepare. Returns: List of tool parameters ready for the OpenAI API. @@ -439,7 +440,7 @@ def _prepare_tools_for_openai(self, tools: Sequence[Any] | None) -> list[Any]: if not tools: return [] response_tools: list[Any] = [] - for tool in tools: + for tool in normalize_tools(tools): if isinstance(tool, FunctionTool): params = tool.parameters() params["additionalProperties"] = False diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 12e5b42d6d..4e68d1f519 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -1193,6 +1193,34 @@ def test_prepare_tools_for_openai_with_mcp() -> None: assert "require_approval" in mcp +def test_prepare_tools_for_openai_single_function_tool() -> None: + """Test that a single FunctionTool (not wrapped in a list) is handled correctly.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + @tool + def hello(name: str) -> str: + """Say hello.""" + return name + + resp_tools = client._prepare_tools_for_openai(hello) + assert isinstance(resp_tools, list) + assert len(resp_tools) == 1 + assert resp_tools[0]["type"] == "function" + assert resp_tools[0]["name"] == "hello" + + +def test_prepare_tools_for_openai_single_dict_tool() -> None: + """Test that a single dict tool (not wrapped in a list) is handled correctly.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + web_tool = OpenAIResponsesClient.get_web_search_tool(search_context_size="low") + resp_tools = client._prepare_tools_for_openai(web_tool) + assert isinstance(resp_tools, list) + assert len(resp_tools) == 1 + assert "type" in resp_tools[0] + assert resp_tools[0]["search_context_size"] == "low" + + def test_parse_response_from_openai_with_mcp_approval_request() -> None: """Test that a non-streaming mcp_approval_request is parsed into FunctionApprovalRequestContent.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") From 5d5b49454eb2a2233203909e4cc6556a62ad752c Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 26 Feb 2026 20:51:54 +0900 Subject: [PATCH 2/2] Address PR review feedback for #4304 - Use precise type annotation matching normalize_tools/OpenAIChatClient signature instead of collapsed Sequence[Any] | Any | None - Move emptiness guard after normalize_tools() call so single falsy tool objects are not silently swallowed - Import ToolTypes for the type annotation - Expand test_prepare_tools_for_openai_single_function_tool assertions to verify parameters, strict, and parameter schema fields - Add test_prepare_tools_for_openai_none to verify None input returns [] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openai/_responses_client.py | 10 ++++++--- .../openai/test_openai_responses_client.py | 22 +++++++++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index e6c5a4f56b..5ba0bbc686 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -43,6 +43,7 @@ FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool, + ToolTypes, normalize_tools, ) from .._types import ( @@ -426,7 +427,9 @@ def _get_conversation_id( # region Prep methods - def _prepare_tools_for_openai(self, tools: Sequence[Any] | Any | None) -> list[Any]: + def _prepare_tools_for_openai( + self, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None + ) -> list[Any]: """Prepare tools for the OpenAI Responses API. Converts FunctionTool to Responses API format. All other tools pass through unchanged. @@ -437,10 +440,11 @@ def _prepare_tools_for_openai(self, tools: Sequence[Any] | Any | None) -> list[A Returns: List of tool parameters ready for the OpenAI API. """ - if not tools: + tools_list = normalize_tools(tools) + if not tools_list: return [] response_tools: list[Any] = [] - for tool in normalize_tools(tools): + for tool in tools_list: if isinstance(tool, FunctionTool): params = tool.parameters() params["additionalProperties"] = False diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 4e68d1f519..7eaae1e776 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -1205,8 +1205,17 @@ def hello(name: str) -> str: resp_tools = client._prepare_tools_for_openai(hello) assert isinstance(resp_tools, list) assert len(resp_tools) == 1 - assert resp_tools[0]["type"] == "function" - assert resp_tools[0]["name"] == "hello" + tool_def = resp_tools[0] + assert tool_def["type"] == "function" + assert tool_def["name"] == "hello" + assert tool_def["strict"] is False + assert "parameters" in tool_def + params = tool_def["parameters"] + assert isinstance(params, dict) + assert params.get("type") == "object" + assert "properties" in params + assert "name" in params["properties"] + assert params["properties"]["name"]["type"] == "string" def test_prepare_tools_for_openai_single_dict_tool() -> None: @@ -1221,6 +1230,15 @@ def test_prepare_tools_for_openai_single_dict_tool() -> None: assert resp_tools[0]["search_context_size"] == "low" +def test_prepare_tools_for_openai_none() -> None: + """Test that passing None returns an empty list.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + resp_tools = client._prepare_tools_for_openai(None) + assert isinstance(resp_tools, list) + assert len(resp_tools) == 0 + + def test_parse_response_from_openai_with_mcp_approval_request() -> None: """Test that a non-streaming mcp_approval_request is parsed into FunctionApprovalRequestContent.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")