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: diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index a9120e72ca..4880c459bd 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,63 @@ 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. + + ``.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. + + 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 +770,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 +809,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 +840,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 +855,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 +2690,32 @@ 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 + # `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(_plain_function_call_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 +2943,9 @@ def _parse_response_from_openai( raw_representation=item, ) ) + case "function_call_output": # ResponseFunctionToolCallOutputItem + if _function_call_output_has_result(item): + 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 +3057,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 +3422,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 _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)) 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 +3634,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 _function_call_output_has_result(done_item) 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..4fec7d89e2 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,251 @@ 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_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_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") + + 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")