From 42997d7e91fe8b1ac16f9f305d3cb56a22962da3 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Fri, 28 Aug 2026 10:39:06 +0800 Subject: [PATCH 1/2] Fix optional OpenAI function result call IDs --- python/packages/core/agent_framework/_types.py | 4 ++-- python/packages/core/tests/core/test_types.py | 9 +++++++++ .../openai/agent_framework_openai/_chat_client.py | 11 ++++++----- .../openai/tests/openai/test_openai_chat_client.py | 13 +++++++++++++ 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 7481d13e13..3df285fb2a 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -856,7 +856,7 @@ def from_function_call( @classmethod def from_function_result( cls: type[ContentT], - call_id: str, + call_id: str | None = None, *, result: Any = None, exception: str | None = None, @@ -871,7 +871,7 @@ def from_function_result( text from text items for backwards compatibility. Args: - call_id: The ID of the function call this result corresponds to. + call_id: The optional ID of the function call this result corresponds to. Keyword Args: result: The tool output. Accepts a ``list[Content]`` (the canonical diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 23902d17b9..e7967be7de 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -3220,6 +3220,15 @@ def test_from_function_result_with_string(): assert result.items[0].text == "just text" +def test_from_function_result_without_call_id(): + """Test Content.from_function_result accepts results without a call ID.""" + result = Content.from_function_result(result="just text") + + assert result.type == "function_result" + assert result.call_id is None + assert result.result == "just text" + + def test_content_from_function_result_items_in_to_dict(): """Test that items are included in to_dict serialization.""" content_list = [ diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 55ff20950a..f2bbbcd36d 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -1700,8 +1700,8 @@ def _prepare_message_for_openai( # local-shell-call IDs) must not be re-sent inline when the request carries # previous_response_id / conversation_id / conversation: the server already has them via # the prior response and rejects duplicates with "Duplicate item found with id ...". - # function_result keeps its call_id and the server pairs it to the prior function_call via - # that key. See microsoft/agent-framework#3295. The strip is gated on the request-level + # function_result keeps its call_id when present and the server pairs it to the prior + # function_call via that key. See microsoft/agent-framework#3295. The strip is gated on the request-level # flag, not a message-level one: HistoryProvider-attributed messages # (replays_local_storage) still need stripping when the request also carries a continuation # marker, since the server-stored items would otherwise duplicate the inline ones. Without @@ -1984,7 +1984,6 @@ def _prepare_content_for_openai( "type": OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL, "output": self._to_local_shell_output_payload(content), } - # call_id for the result needs to be the same as the call_id for the function call output: str | list[dict[str, Any]] = content.result or "" if ( self.SUPPORTS_RICH_FUNCTION_OUTPUT @@ -2001,11 +2000,13 @@ def _prepare_content_for_openai( output_parts.append(part) if output_parts: output = output_parts - return { - "call_id": content.call_id, + function_call_output: dict[str, Any] = { "type": "function_call_output", "output": output, } + if content.call_id is not None: + function_call_output["call_id"] = content.call_id + return function_call_output case "function_approval_request": return { "type": "mcp_approval_request", 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 aab0fc1fe2..bece604c37 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -5601,6 +5601,19 @@ def test_prepare_content_for_openai_function_result_without_items() -> None: assert result["output"] == "Simple result" +def test_prepare_content_for_openai_function_result_without_call_id() -> None: + """Test Responses API function output omits an optional call ID.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + content = Content.from_function_result(result="Simple result") + + result = client._prepare_content_for_openai("user", content) + + assert result == { + "type": "function_call_output", + "output": "Simple result", + } + + def test_parse_chunk_from_openai_code_interpreter() -> None: """Test _parse_chunk_from_openai with code_interpreter_call.""" client = OpenAIChatClient(model="test-model", api_key="test-key") From 69bba19b7e319aa381d9a4c3b9e9a60acb5cc199 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Fri, 28 Aug 2026 10:49:22 +0800 Subject: [PATCH 2/2] Document optional OpenAI function result IDs --- docs/specs/004-python-function-calling-loop.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 85e9e0c657..e05fdfbdd3 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -545,6 +545,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Streaming message injection with per-service-call persistence | A streaming response rebuilt from updates mirrors the inner conversation id exactly, including clearing it, and keeps its internal marker, so the next iteration appends only the latest message rather than replaying the whole turn on top of provider-held history, and never persists a conversation id from an earlier injected service call. | `packages/core/tests/core/test_middleware_with_chat.py::TestChatMiddleware::test_message_injection_middleware_streaming_preserves_inner_continuation_state`, `test_message_injection_middleware_streaming_keeps_service_conversation_id_external`, `test_message_injection_middleware_streaming_clears_conversation_id_when_final_call_has_none`, `test_message_injection_middleware_conversation_id_matches_across_streaming_modes`, `packages/core/tests/core/test_harness_agent.py::test_streaming_harness_tool_call_does_not_duplicate_transcript` | | Service-side approval decision | Stored hosted request is skipped; the current approved or rejected hosted response is sent, while local approval controls are omitted from provider input. | `packages/openai/tests/openai/test_openai_chat_client.py::test_prepare_messages_strips_approval_request_but_keeps_response_under_storage`, `test_prepare_messages_drops_local_approval_controls` | | OpenAI approval serialization | Hosted approval id and decision serialize to `mcp_approval_response`; local approvals remain in-process. | `test_prepare_message_for_openai_with_function_approval_response`, `test_prepare_content_for_opentool_approval_response`, `test_function_approval_response_with_mcp_tool_call` | +| OpenAI function-result serialization without call ID | A function result without a `call_id` is accepted and serialized without a null `call_id` field, while supplied IDs remain available for pairing. | `packages/openai/tests/openai/test_openai_chat_client.py::test_prepare_content_for_openai_function_result_without_call_id` | | OpenAI end-to-end hosted approval | Hosted request parses, response sends, and continuation completes. | `test_end_to_end_mcp_approval_flow` | | Stored function call/result | Service-side storage drops server-issued calls but keeps new outputs. | `test_prepare_options_with_conversation_id_strips_server_issued_items`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` | | Stateless reasoning replay | Replay reconstructs reasoning, call, and result together; missing required reasoning fails before the request. | `test_tool_loop_store_false_replays_encrypted_reasoning_group`, `test_stateless_request_rejects_non_replayable_reasoning_bound_mcp_output`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` |