From 22180bb0c734151875a1b33da50564c08b062ace Mon Sep 17 00:00:00 2001 From: Manjunath Janardhan Date: Fri, 4 Sep 2026 21:33:21 +0530 Subject: [PATCH 1/4] Python: parse Responses function_call_output so hosted tool results reach transports A hosted tool that executes server-side -- for example a Foundry Toolbox dispatching through its generic `call_tool` wrapper -- returns its result as a standalone `function_call_output` Responses item rather than on the originating call item. None of the three parse dispatch sites handled that item type, so it fell through to the `Unparsed ...` debug log and was discarded. No `Content.from_function_result` was produced, and AG-UI consequently emitted TOOL_CALL_END with no matching TOOL_CALL_RESULT, falling back to treating the call as declaration-only. The model still received the real output, so only the client lost the structured result. Add a `function_call_output` branch to all three sites -- the non-streaming `_parse_response_from_openai` and both streaming `response.output_item.added` / `.done` handlers -- sharing one `_parse_function_call_output_content` helper so the lists cannot drift again. `output` is a string or a list of input-content parts, so it is normalized through the existing `_stringify_mcp_output` rather than JSON-encoding provider models. The streaming handlers emit from whichever event first carries a populated `output` and record the item id in a per-request set, so the other event cannot produce a second result. Keyed on the item id rather than `call_id`, which the function-calling loop contract says must not be assumed unique forever. Reviewed against docs/specs/004-python-function-calling-loop.md, which covers provider serialization of function calls and results: no result is orphaned or duplicated, and the streaming and non-streaming paths agree. Fixes #8068 --- .../agent_framework_openai/_chat_client.py | 73 ++++++++ .../tests/openai/test_openai_chat_client.py | 171 ++++++++++++++++++ 2 files changed, 244 insertions(+) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index a9120e72ca..d69cfa44f1 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -76,6 +76,7 @@ from openai.types.responses import ( FunctionShellToolParam, ResponseCustomToolCall, + ResponseFunctionToolCallOutputItem, ResponseToolSearchCall, response_create_params, ) @@ -344,6 +345,26 @@ async def _open_event_stream(raw_response: Any) -> AsyncGenerator[Any]: yield raw_response +def _claim_function_call_output(seen_item_ids: set[str] | None, item: Any) -> bool: + """Claim a ``function_call_output`` item for emission; return ``False`` if already claimed. + + The Responses stream can surface the same output item on both ``response.output_item.added`` + and ``response.output_item.done``. Whichever event first carries a populated ``output`` emits + the result, and this test-and-set keeps the other from producing a duplicate one. Keyed on the + item id rather than ``call_id``, which is not guaranteed to be unique forever. When no set is + supplied the item is always claimable, so a single event parsed on its own still yields output. + """ + if seen_item_ids is None: + return True + item_id = getattr(item, "id", None) + if not isinstance(item_id, str) or not item_id: + return True + if item_id in seen_item_ids: + return False + seen_item_ids.add(item_id) + return True + + def _annotations_to_output_text(annotations: Sequence[Annotation] | None) -> list[dict[str, Any]]: """Convert framework `Annotation` objects to Responses API `output_text` annotation dicts. @@ -712,6 +733,7 @@ def _inner_get_response( if stream: function_call_ids: dict[int, tuple[str, str]] = {} seen_reasoning_delta_item_ids: set[str] = set() + seen_function_call_output_ids: set[str] = set() validated_options: dict[str, Any] | None = None # Captured once request options are validated/prepared so the streaming finalizer can # still parse the aggregated response into structured output after the stream completes. @@ -750,6 +772,7 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: options=validated_options, function_call_ids=function_call_ids, seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids, + seen_function_call_output_ids=seen_function_call_output_ids, ) if served_model is not None: update.model = served_model @@ -780,6 +803,7 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: options=validated_options, function_call_ids=function_call_ids, seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids, + seen_function_call_output_ids=seen_function_call_output_ids, ) else: raw_create_response = await client.responses.with_raw_response.create( @@ -794,6 +818,7 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: options=validated_options, function_call_ids=function_call_ids, seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids, + seen_function_call_output_ids=seen_function_call_output_ids, ) if served_model is not None: update.model = served_model @@ -2628,6 +2653,30 @@ def _parse_hosted_function_call_content( raw_representation=item, ) + def _parse_function_call_output_content(self, item: ResponseFunctionToolCallOutputItem) -> Content: + """Create function result content for a Responses ``function_call_output`` item. + + A hosted tool that executes server-side -- for example a Foundry Toolbox dispatching + through its generic ``call_tool`` wrapper -- returns its result as a standalone + ``function_call_output`` item rather than on the originating call item. Parsing it keeps + the call/result pair intact for transports such as AG-UI, which otherwise sees a tool call + with no result and falls back to treating it as declaration-only (issue #8068). + + ``output`` is either a string or a list of input-content parts, so it is normalized through + :meth:`_stringify_mcp_output` rather than JSON-encoding provider models. + """ + additional_properties: dict[str, Any] = {"item_type": item.type, "status": item.status} + if item.id: + additional_properties["item_id"] = item.id + if item.name: + additional_properties["name"] = item.name + return Content.from_function_result( + call_id=item.call_id, + result=self._stringify_mcp_output(item.output), + additional_properties=additional_properties, + raw_representation=item, + ) + # region Parse methods def _get_finish_reason_from_openai_response(self, response: Any) -> FinishReason | None: """Get the framework finish reason from a terminal Responses API response.""" @@ -2855,6 +2904,9 @@ def _parse_response_from_openai( raw_representation=item, ) ) + case "function_call_output": # ResponseFunctionToolCallOutputItem + if getattr(item, "output", None) is not None: + contents.append(self._parse_function_call_output_content(item)) case "custom_tool_call": contents.append( self._parse_hosted_function_call_content(item, name=item.name, arguments=item.input) @@ -2966,6 +3018,7 @@ def _parse_chunk_from_openai( options: dict[str, Any], function_call_ids: dict[int, tuple[str, str]], seen_reasoning_delta_item_ids: set[str] | None = None, + seen_function_call_output_ids: set[str] | None = None, ) -> ChatResponseUpdate: """Parse an OpenAI Responses API streaming event into a ChatResponseUpdate.""" metadata: dict[str, Any] = {} @@ -3330,6 +3383,14 @@ def output_text_properties(output: Any) -> dict[str, Any] | None: ) case "web_search_call" | "file_search_call": contents.append(self._parse_search_tool_call_content(event_item)) + case "function_call_output": # ResponseFunctionToolCallOutputItem + # Emitted from whichever of `.added` / `.done` first carries a populated + # `output`; the item id is recorded so the other event cannot emit a second + # result for the same item (issue #8068). + if getattr(event_item, "output", None) is not None and _claim_function_call_output( + seen_function_call_output_ids, event_item + ): + contents.append(self._parse_function_call_output_content(event_item)) case _: if getattr(event_item, "type", None) != _AZURE_AI_SEARCH_CALL_OUTPUT_TYPE: logger.debug("Unparsed event of type: %s: %s", event.type, event) @@ -3534,6 +3595,18 @@ def _get_ann_value(key: str) -> Any: arguments=tool_search_call.arguments, ) ) + elif getattr(done_item, "type", None) == "function_call_output": + # Counterpart to the `response.output_item.added` branch: whichever event first + # carries a populated `output` emits the result, and the shared seen-id set + # keeps the other from duplicating it (issue #8068). + if getattr(done_item, "output", None) is not None and _claim_function_call_output( + seen_function_call_output_ids, done_item + ): + contents.append( + self._parse_function_call_output_content( + cast(ResponseFunctionToolCallOutputItem, done_item) + ) + ) elif getattr(done_item, "type", None) == _AZURE_AI_SEARCH_CALL_OUTPUT_TYPE: pass case _: diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 7258c50cae..4ab546af8d 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -3479,6 +3479,177 @@ def test_parse_chunk_from_openai_with_mcp_output_item_done() -> None: assert result_content.raw_representation is mock_item +def _make_function_call_output_item(output: object, item_id: str = "fco_1") -> MagicMock: + """Build a Responses `function_call_output` item stub (hosted-toolbox tool result).""" + item = MagicMock() + item.type = "function_call_output" + item.id = item_id + item.call_id = "call_XXXX" + item.output = output + item.status = "completed" + # `.name` must be assigned after construction; MagicMock(name=...) sets the mock's own name. + item.name = None + return item + + +def test_parse_chunk_from_openai_with_function_call_output_added() -> None: + """A hosted-toolbox tool result on `.added` becomes function_result content (issue #8068).""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = _make_function_call_output_item("Seattle KB says it is 72F.") + mock_event.item = mock_item + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids={}) + + assert len(update.contents) == 1 + result_content = update.contents[0] + assert result_content.type == "function_result" + assert result_content.call_id == "call_XXXX" + assert result_content.result == "Seattle KB says it is 72F." + assert result_content.raw_representation is mock_item + assert result_content.additional_properties is not None + assert result_content.additional_properties["item_id"] == "fco_1" + assert result_content.additional_properties["status"] == "completed" + + +def test_parse_chunk_from_openai_function_call_output_added_extracts_list_output_text() -> None: + """A list-shaped `output` is text-extracted rather than JSON-encoded.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_event.item = _make_function_call_output_item([ + {"type": "input_text", "text": "part one "}, + {"type": "input_text", "text": "part two"}, + ]) + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids={}) + + assert len(update.contents) == 1 + assert update.contents[0].result == "part one part two" + + +def test_parse_chunk_from_openai_function_call_output_added_ignores_missing_output() -> None: + """An in-progress skeleton with no output must not synthesize an empty result.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_event.item = _make_function_call_output_item(None) + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids={}) + + assert update.contents == [] + + +def test_parse_chunk_from_openai_function_call_output_done_emits_when_added_did_not() -> None: + """`.done` carries the result when `.added` was an empty skeleton, so order does not matter.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + seen: set[str] = set() + + added = MagicMock() + added.type = "response.output_item.added" + added.item = _make_function_call_output_item(None) + + done = MagicMock() + done.type = "response.output_item.done" + done.item = _make_function_call_output_item("late result") + + added_update = client._parse_chunk_from_openai( + added, options={}, function_call_ids={}, seen_function_call_output_ids=seen + ) + done_update = client._parse_chunk_from_openai( + done, options={}, function_call_ids={}, seen_function_call_output_ids=seen + ) + + assert added_update.contents == [] + assert len(done_update.contents) == 1 + assert done_update.contents[0].result == "late result" + + +def test_parse_chunk_from_openai_function_call_output_is_not_emitted_twice() -> None: + """The same output item on both `.added` and `.done` yields exactly one result.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + seen: set[str] = set() + + added = MagicMock() + added.type = "response.output_item.added" + added.item = _make_function_call_output_item("only once") + + done = MagicMock() + done.type = "response.output_item.done" + done.item = _make_function_call_output_item("only once") + + added_update = client._parse_chunk_from_openai( + added, options={}, function_call_ids={}, seen_function_call_output_ids=seen + ) + done_update = client._parse_chunk_from_openai( + done, options={}, function_call_ids={}, seen_function_call_output_ids=seen + ) + + assert len(added_update.contents) == 1 + assert done_update.contents == [] + + +def test_parse_chunk_from_openai_function_call_output_without_item_id_still_emits() -> None: + """A result carrying no usable item id is still emitted rather than silently swallowed.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + seen: set[str] = set() + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_event.item = _make_function_call_output_item("no id here", item_id="") + + update = client._parse_chunk_from_openai( + mock_event, options={}, function_call_ids={}, seen_function_call_output_ids=seen + ) + + assert len(update.contents) == 1 + assert update.contents[0].result == "no id here" + assert update.contents[0].additional_properties is not None + assert "item_id" not in update.contents[0].additional_properties + + +def test_parse_chunk_from_openai_function_call_output_keeps_tool_name() -> None: + """The inner tool name, when the host supplies one, survives onto the result content.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = _make_function_call_output_item("kb answer") + mock_item.name = "search_knowledge_base" + mock_event.item = mock_item + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids={}) + + assert update.contents[0].additional_properties is not None + assert update.contents[0].additional_properties["name"] == "search_knowledge_base" + + +def test_parse_response_from_openai_with_function_call_output() -> None: + """Non-streaming parsing agrees with streaming: the hosted tool result is not dropped.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-id" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.output = [_make_function_call_output_item("non-streaming KB result")] + + response = client._parse_response_from_openai(mock_response, options={}) # type: ignore + + contents = response.messages[0].contents + assert len(contents) == 1 + assert contents[0].type == "function_result" + assert contents[0].call_id == "call_XXXX" + assert contents[0].result == "non-streaming KB result" + + def test_parse_chunk_from_openai_with_mcp_output_item_done_no_output() -> None: """Test that response.output_item.done for mcp_call with no output emits result with None output.""" client = OpenAIChatClient(model="test-model", api_key="test-key") From 83df08195cd79581261505e35c7cfd0ca39074df Mon Sep 17 00:00:00 2001 From: Manjunath Janardhan Date: Fri, 4 Sep 2026 22:11:00 +0530 Subject: [PATCH 2/4] Python: forward the function_call_output seen-id set through Foundry parse overrides `RawFoundryChatClient` and `RawFoundryAgentChatClient` override `_parse_chunk_from_openai` to intercept oauth_consent items and then delegate to `RawOpenAIChatClient`. Both had the pre-change signature, so once the base started passing `seen_function_call_output_ids` every Foundry streaming call raised `TypeError: _parse_chunk_from_openai() got an unexpected keyword argument`. Accept and forward the new parameter in both overrides, and update the two delegation assertions that pin the forwarded argument list. Caught against a live Foundry Responses endpoint; `poe test -P foundry` also reproduces it, but that package is not in the validation command list in docs/specs/004-python-function-calling-loop.md even though it subclasses the OpenAI Responses client. --- .../packages/foundry/agent_framework_foundry/_agent.py | 2 ++ .../foundry/agent_framework_foundry/_chat_client.py | 9 ++++++++- .../packages/foundry/tests/foundry/test_foundry_agent.py | 2 +- .../foundry/tests/foundry/test_foundry_chat_client.py | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index cd44b46088..8b4d391664 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -439,6 +439,7 @@ def _parse_chunk_from_openai( options: dict[str, Any], function_call_ids: dict[int, tuple[str, str]], seen_reasoning_delta_item_ids: set[str] | None = None, + seen_function_call_output_ids: set[str] | None = None, ) -> ChatResponseUpdate: """Parse streaming events while preserving hosted-agent session state.""" update = try_parse_oauth_consent_event(event, self.model) @@ -448,6 +449,7 @@ def _parse_chunk_from_openai( options, function_call_ids, seen_reasoning_delta_item_ids, + seen_function_call_output_ids, ) if agent_session_id := _extract_foundry_hosted_agent_session_id(getattr(event, "response", None)): if update.additional_properties is None: diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 6c0539bb98..a776a470a7 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -292,12 +292,19 @@ def _parse_chunk_from_openai( options: dict[str, Any], function_call_ids: dict[int, tuple[str, str]], seen_reasoning_delta_item_ids: set[str] | None = None, + seen_function_call_output_ids: set[str] | None = None, ) -> ChatResponseUpdate: """Parse streaming event, intercepting oauth_consent_request items.""" update = try_parse_oauth_consent_event(event, self.model) if update is not None: return update - return super()._parse_chunk_from_openai(event, options, function_call_ids, seen_reasoning_delta_item_ids) + return super()._parse_chunk_from_openai( + event, + options, + function_call_ids, + seen_reasoning_delta_item_ids, + seen_function_call_output_ids, + ) async def configure_azure_monitor( self, diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index a4c3b8ca25..2ffb4ce626 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -1846,7 +1846,7 @@ def test_parse_chunk_delegates_non_oauth_events_to_super() -> None: return_value=MagicMock(), ) as mock_super: client._parse_chunk_from_openai(mock_event, {}, {}) - mock_super.assert_called_once_with(mock_event, {}, {}, None) + mock_super.assert_called_once_with(mock_event, {}, {}, None, None) def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None: diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 4494f660ad..ea7fd282ae 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -1589,7 +1589,7 @@ def test_parse_chunk_delegates_non_oauth_events_to_super() -> None: return_value=MagicMock(), ) as mock_super: client._parse_chunk_from_openai(mock_event, {}, {}) - mock_super.assert_called_once_with(mock_event, {}, {}, None) + mock_super.assert_called_once_with(mock_event, {}, {}, None, None) def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None: From 4044d6ed44879ec191f91def58b365dbfbd57f21 Mon Sep 17 00:00:00 2001 From: Manjunath Janardhan Date: Fri, 4 Sep 2026 22:18:49 +0530 Subject: [PATCH 3/4] Python: do not emit an unpairable function_call_output result Review follow-up. `Content.from_function_result` does not validate `call_id`, so a `function_call_output` item carrying a blank one produced an orphaned result: transports drop it (`_emit_tool_result` returns early on a falsy `call_id`) and the outbound serializer would re-send it as an unpairable `function_call_output` input item on the next turn. These items are synthesized by the hosting layer, so a blank `call_id` is a realistic host-side defect rather than a theoretical one, and the function-calling loop contract requires that no result becomes orphaned. Extract the emission gate into `_function_call_output_has_result` so the populated-output and pairable-call_id checks are shared by all three dispatch sites instead of being repeated at each one. --- .../agent_framework_openai/_chat_client.py | 22 ++++++++++++++++--- .../tests/openai/test_openai_chat_client.py | 19 ++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index d69cfa44f1..19cd61bbf1 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -345,6 +345,22 @@ async def _open_event_stream(raw_response: Any) -> AsyncGenerator[Any]: yield raw_response +def _function_call_output_has_result(item: Any) -> bool: + """True when a ``function_call_output`` item carries a result that can be paired. + + ``.added`` can precede a populated ``output``, so an in-progress skeleton must not synthesize + an empty result. A blank ``call_id`` is rejected as well: these items are synthesized by the + hosting layer, and a result that cannot be paired to its call is dropped by transports and, + worse, re-sent as an unpairable ``function_call_output`` input item on the next turn. + """ + if getattr(item, "output", None) is None: + return False + if not getattr(item, "call_id", None): + logger.debug("Skipping function_call_output with no call_id: item_id=%s", getattr(item, "id", None)) + return False + return True + + def _claim_function_call_output(seen_item_ids: set[str] | None, item: Any) -> bool: """Claim a ``function_call_output`` item for emission; return ``False`` if already claimed. @@ -2905,7 +2921,7 @@ def _parse_response_from_openai( ) ) case "function_call_output": # ResponseFunctionToolCallOutputItem - if getattr(item, "output", None) is not None: + if _function_call_output_has_result(item): contents.append(self._parse_function_call_output_content(item)) case "custom_tool_call": contents.append( @@ -3387,7 +3403,7 @@ def output_text_properties(output: Any) -> dict[str, Any] | None: # Emitted from whichever of `.added` / `.done` first carries a populated # `output`; the item id is recorded so the other event cannot emit a second # result for the same item (issue #8068). - if getattr(event_item, "output", None) is not None and _claim_function_call_output( + if _function_call_output_has_result(event_item) and _claim_function_call_output( seen_function_call_output_ids, event_item ): contents.append(self._parse_function_call_output_content(event_item)) @@ -3599,7 +3615,7 @@ def _get_ann_value(key: str) -> Any: # Counterpart to the `response.output_item.added` branch: whichever event first # carries a populated `output` emits the result, and the shared seen-id set # keeps the other from duplicating it (issue #8068). - if getattr(done_item, "output", None) is not None and _claim_function_call_output( + if _function_call_output_has_result(done_item) and _claim_function_call_output( seen_function_call_output_ids, done_item ): contents.append( diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 4ab546af8d..4782646fda 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -3628,6 +3628,25 @@ def test_parse_chunk_from_openai_function_call_output_keeps_tool_name() -> None: assert update.contents[0].additional_properties["name"] == "search_knowledge_base" +def test_parse_chunk_from_openai_function_call_output_without_call_id_is_skipped() -> None: + """A result that cannot be paired to its call is not emitted at all. + + A blank `call_id` would produce an orphaned function_result: transports drop it, and the + outbound serializer would re-send it as an unpairable `function_call_output` input item. + """ + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = _make_function_call_output_item("orphan result") + mock_item.call_id = "" + mock_event.item = mock_item + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids={}) + + assert update.contents == [] + + def test_parse_response_from_openai_with_function_call_output() -> None: """Non-streaming parsing agrees with streaming: the hosted tool result is not dropped.""" client = OpenAIChatClient(model="test-model", api_key="test-key") From a8dd5aa6f9cf167306be325e17cef874d93d399e Mon Sep 17 00:00:00 2001 From: Manjunath Janardhan Date: Fri, 4 Sep 2026 22:35:39 +0530 Subject: [PATCH 4/4] Python: keep function_call_output parsing safe on the declared openai SDK floor Address review on two counts. `name` is not on `ResponseFunctionToolCallOutputItem` in openai 2.25.0, the declared floor -- that version ships only call_id/id/output/status/type. Reading it as an attribute raised `AttributeError` out of the shared parse helper, which all three dispatch sites call, so on any supported SDK below the release that added the field the whole response parse failed rather than merely dropping the result. Read it with `getattr`. The other attributes touched here (type/status/id/call_id/output) are all present on the floor, and the two module helpers already used `getattr`. `output` may also be a list of input-content parts. Passing those provider models straight to `_stringify_mcp_output` fell through to `json.dumps(..., default=str)` and embedded a Python repr in the result text sent back to the model -- e.g. `"ResponseInputImage(detail='auto', ...)"`. Dump each part first so text extraction still works and non-text parts serialize as readable JSON. Both paths are now regression-tested, including a stub item shaped like the 2.25.0 field set. --- .../agent_framework_openai/_chat_client.py | 29 +++++++++- .../tests/openai/test_openai_chat_client.py | 55 +++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 19cd61bbf1..4880c459bd 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -345,6 +345,27 @@ async def _open_event_stream(raw_response: Any) -> AsyncGenerator[Any]: yield raw_response +def _plain_function_call_output(output: Any) -> Any: + """Render provider content parts as plain data before stringifying a tool result. + + ``function_call_output.output`` is either a string or a list of input-content parts + (text/image/file). Passing those provider models straight to + :meth:`_stringify_mcp_output` would fall through to ``json.dumps(..., default=str)`` and embed + a Python repr in the tool result. Dumping each part first keeps text extraction working and + turns non-text parts into readable JSON. + """ + if output is None or isinstance(output, str): + return output + if isinstance(output, Sequence) and not isinstance(output, (str, bytes, bytearray)): + entries = cast(Sequence[Any], output) + plain: list[Any] = [] + for entry in entries: + model_dump = getattr(entry, "model_dump", None) + plain.append(model_dump(exclude_none=True) if callable(model_dump) else entry) + return plain + return output + + def _function_call_output_has_result(item: Any) -> bool: """True when a ``function_call_output`` item carries a result that can be paired. @@ -2684,11 +2705,13 @@ def _parse_function_call_output_content(self, item: ResponseFunctionToolCallOutp additional_properties: dict[str, Any] = {"item_type": item.type, "status": item.status} if item.id: additional_properties["item_id"] = item.id - if item.name: - additional_properties["name"] = item.name + # `name` (and the other caller-attribution fields) only exist on newer openai SDKs; the + # declared floor of 2.25.0 ships only call_id/id/output/status/type. + if tool_name := getattr(item, "name", None): + additional_properties["name"] = tool_name return Content.from_function_result( call_id=item.call_id, - result=self._stringify_mcp_output(item.output), + result=self._stringify_mcp_output(_plain_function_call_output(item.output)), additional_properties=additional_properties, raw_representation=item, ) diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 4782646fda..4fec7d89e2 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -3647,6 +3647,61 @@ def test_parse_chunk_from_openai_function_call_output_without_call_id_is_skipped assert update.contents == [] +def test_parse_chunk_from_openai_function_call_output_on_sdk_floor_without_name_field() -> None: + """Parsing must not require `name`, absent from the item on the openai>=2.25.0 floor. + + On SDK 2.25.0 `ResponseFunctionToolCallOutputItem` carries only call_id/id/output/status/type. + Touching `.name` directly would raise AttributeError out of the parse and fail the whole + response rather than merely dropping the result. + """ + + class FloorItem: + """Stand-in for the 2.25.0 item shape -- no `name` attribute at all.""" + + type = "function_call_output" + id = "fco_floor" + call_id = "call_floor" + output = "floor result" + status = "completed" + + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_event.item = FloorItem() + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids={}) + + assert len(update.contents) == 1 + assert update.contents[0].result == "floor result" + assert update.contents[0].additional_properties is not None + assert "name" not in update.contents[0].additional_properties + + +def test_parse_chunk_from_openai_function_call_output_serializes_non_text_parts_as_json() -> None: + """Non-text output parts serialize as JSON rather than an embedded Python repr.""" + from openai.types.responses.response_input_image import ResponseInputImage + from openai.types.responses.response_input_text import ResponseInputText + + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_event.item = _make_function_call_output_item([ + ResponseInputText(type="input_text", text="see chart: "), + ResponseInputImage(type="input_image", detail="auto", image_url="https://example.com/c.png"), + ]) + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids={}) + + result = update.contents[0].result + assert result is not None + assert result.startswith("see chart: ") + # The image part is readable JSON, not `ResponseInputImage(...)`. + assert "ResponseInputImage(" not in result + assert "https://example.com/c.png" in result + + def test_parse_response_from_openai_with_function_call_output() -> None: """Non-streaming parsing agrees with streaming: the hosted tool result is not dropped.""" client = OpenAIChatClient(model="test-model", api_key="test-key")