From 0a928a9c25083d9e48facd814b6d67682bba522d Mon Sep 17 00:00:00 2001 From: Chinmay V <203952148+chinmayv095@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:44:26 +0530 Subject: [PATCH 1/2] Python: fix(openai): report the background cause when a tool result is rejected A function_call_output chained with previous_response_id is rejected by the Responses API when the predecessor response was created with background=True, even though the predecessor contains the matching function_call. The failure surfaced only as the generic prompt-failure message, which points at the tool result rather than at the background predecessor that actually caused it. Report the cause and the supported alternative when all three signals are present: the pairing error, a previous_response_id continuation, and a background request. Foreground chaining is unaffected and keeps the generic message. --- .../agent_framework_openai/_chat_client.py | 50 ++++++++++- .../tests/openai/test_openai_chat_client.py | 84 +++++++++++++++++++ 2 files changed, 130 insertions(+), 4 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 2e4c06dff7..e2f68b27c9 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -160,6 +160,27 @@ class PromptCacheOptions(TypedDict, total=False): # item before returning, dropping any that are unmatched. _AF_MCP_PENDING_OUTPUT_KEY = "__af_pending_mcp_result__" +# Fragment of the Responses API 400 returned when a function_call_output is submitted against a +# predecessor the service will not pair it with. The error body carries no `code`, so the message +# is the only available signal. +_TOOL_OUTPUT_PAIRING_ERROR_FRAGMENT = "no tool call found for function call output" + + +def _is_background_tool_output_pairing_error(ex: Exception, run_options: Mapping[str, Any] | None) -> bool: + """Return whether a failure is the background-predecessor tool-output pairing rejection. + + The service rejects a `function_call_output` chained with `previous_response_id` when the + predecessor response was created with `background=True`, even though retrieving that + predecessor returns the matching `function_call`. The same chaining succeeds for a foreground + predecessor, so the combination of all three signals is required: the pairing error, a + `previous_response_id` continuation, and a background request. + """ + if not isinstance(ex, BadRequestError) or run_options is None: + return False + if _TOOL_OUTPUT_PAIRING_ERROR_FRAGMENT not in str(ex).lower(): + return False + return bool(run_options.get("background")) and bool(run_options.get("previous_response_id")) + class OpenAIContinuationToken(ContinuationToken): """Continuation token for OpenAI Responses API background operations.""" @@ -633,13 +654,34 @@ async def _prepare_request( run_options = await self._prepare_options(messages, validated_options) return client, run_options, validated_options - def _handle_request_error(self, ex: Exception) -> NoReturn: - """Convert exceptions to appropriate service exceptions. Always raises.""" + def _handle_request_error(self, ex: Exception, run_options: Mapping[str, Any] | None = None) -> NoReturn: + """Convert exceptions to appropriate service exceptions. Always raises. + + Args: + ex: The exception raised by the underlying client. + run_options: The request options that produced the failure, when the failure came from + a request this client built. Omitted for retrieve-only calls, which carry no request + body to attribute the failure to. + """ if isinstance(ex, BadRequestError) and ex.code == "content_filter": raise OpenAIContentFilterException( f"{type(self)} service encountered a content error: {ex}", inner_exception=ex, ) from ex + if _is_background_tool_output_pairing_error(ex, run_options): + raise ChatClientException( + maybe_append_azure_endpoint_guidance( + f"{type(self)} service rejected the tool result for a background response: {ex} " + "The service does not accept a function_call_output chained with " + "previous_response_id when the preceding response was created with " + "background=True, even though that response contains the matching function_call. " + "Run the tool-calling turn with background=False, which supports the same " + "previous_response_id chaining. This is a service-side limitation tracked in " + "Azure/azure-sdk-for-python#46092 and microsoft/agent-framework#7538.", + azure_endpoint=self.azure_endpoint, + ), + inner_exception=ex, + ) from ex raise ChatClientException( maybe_append_azure_endpoint_guidance( f"{type(self)} service failed to complete the prompt: {ex}", @@ -750,7 +792,7 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: update.model = served_model yield update except Exception as ex: - self._handle_request_error(ex) + self._handle_request_error(ex, run_options) return ResponseStream(_stream(), finalizer=_finalize_with_captured_format) @@ -794,7 +836,7 @@ async def _get_response() -> ChatResponse: raw_response = await client.responses.with_raw_response.create(stream=False, **run_options) response = raw_response.parse() except Exception as ex: - self._handle_request_error(ex) + self._handle_request_error(ex, run_options) chat_response = self._parse_response_from_openai(response, options=validated_options) # See note above on ``raw_stream_response.headers``. served_model = self._extract_served_model(getattr(raw_response, "headers", None)) 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 cf3363174b..facd1b0518 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -1374,6 +1374,90 @@ async def test_bad_request_error_non_content_filter() -> None: assert "failed to complete the prompt" in str(exc_info.value) +def _tool_output_pairing_error() -> BadRequestError: + """Build the 400 the Responses API returns for an unpaired function_call_output.""" + message = "No tool call found for function call output with call_id call_abc123." + error = BadRequestError( + message=message, + response=MagicMock(), + body={"error": {"code": None, "message": message, "param": "input"}}, + ) + error.code = None + return error + + +async def test_background_tool_output_pairing_error_reports_background_limitation() -> None: + """A background predecessor rejecting a tool result is reported with the actionable cause.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + with ( + patch.object(client.client.responses.with_raw_response, "create", side_effect=_tool_output_pairing_error()), + pytest.raises(ChatClientException) as exc_info, + ): + await client.get_response( + messages=[Message(role="user", contents=["Test message"])], + options={"background": True, "conversation_id": "resp_abc123"}, + ) + + message = str(exc_info.value) + assert "background=True" in message + assert "background=False" in message + assert "microsoft/agent-framework#7538" in message + + +async def test_streaming_background_tool_output_pairing_error_reports_background_limitation() -> None: + """The streaming path reports the same background limitation as the non-streaming path.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + with ( + patch.object(client.client.responses.with_raw_response, "create", side_effect=_tool_output_pairing_error()), + pytest.raises(ChatClientException, match="background=False"), + ): + response_stream = client.get_response( + stream=True, + messages=[Message(role="user", contents=["Test message"])], + options={"background": True, "conversation_id": "resp_abc123"}, + ) + async for _ in response_stream: + break + + +async def test_foreground_tool_output_pairing_error_keeps_generic_message() -> None: + """A foreground predecessor keeps the generic message, since that chaining is supported.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + with ( + patch.object(client.client.responses.with_raw_response, "create", side_effect=_tool_output_pairing_error()), + pytest.raises(ChatClientException) as exc_info, + ): + await client.get_response( + messages=[Message(role="user", contents=["Test message"])], + options={"conversation_id": "resp_abc123"}, + ) + + message = str(exc_info.value) + assert "failed to complete the prompt" in message + assert "background=False" not in message + + +async def test_background_without_previous_response_keeps_generic_message() -> None: + """A background request that is not a continuation keeps the generic message.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + with ( + patch.object(client.client.responses.with_raw_response, "create", side_effect=_tool_output_pairing_error()), + pytest.raises(ChatClientException) as exc_info, + ): + await client.get_response( + messages=[Message(role="user", contents=["Test message"])], + options={"background": True}, + ) + + message = str(exc_info.value) + assert "failed to complete the prompt" in message + assert "background=False" not in message + + async def test_streaming_content_filter_exception_handling() -> None: """Test that content filter errors in get_response(..., stream=True) are properly handled.""" client = OpenAIChatClient(model="test-model", api_key="test-key") From d31aa0efaf10ab999b72692e78fc186f1ccb2f35 Mon Sep 17 00:00:00 2001 From: Chinmay V <203952148+chinmayv095@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:46:57 +0530 Subject: [PATCH 2/2] Address review: state the background limitation as a condition, not a claim The run options' background flag describes the failing request, not the response named by previous_response_id. A genuinely orphaned call_id on a background continuation reaches the same branch, so the guidance no longer asserts the predecessor was a background response; it gives the background limitation as a condition to check and names the orphaned-call_id alternative. Adds a regression test for that wording and the required scenario-to-test row in the function-calling loop specification. --- .../specs/004-python-function-calling-loop.md | 1 + .../agent_framework_openai/_chat_client.py | 28 ++++++++++++------- .../tests/openai/test_openai_chat_client.py | 26 +++++++++++++++++ 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 8462dc76ac..c9b80999fc 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -465,6 +465,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Maximum function calls | Parallel overshoot is bounded after the batch; every executed result group counts even without a `function_result`; blank final responses get fallback content. | `test_max_function_calls_limits_parallel_invocations`, `test_max_function_calls_single_calls_per_iteration`, `test_user_input_request_multiple_contents_propagate`, `test_approval_resume_user_input_counts_toward_function_call_budget`, `test_max_function_calls_blank_final_fallback_synthesizes_message`, streaming equivalent | | Provider tool content after an active limit | Locally actionable calls and local approval requests returned despite `tool_choice="none"` are removed in both response modes. Provider-executed informational call/result pairs, hosted approval requests, and metadata-only streaming updates remain visible; fallback text never replaces retained transcript content. | `test_function_invocation_limit_drops_unexecutable_tool_content`, `test_streaming_function_invocation_limit_drops_unexecutable_tool_content`, `test_streaming_function_invocation_limit_preserves_metadata_after_tool_content_is_dropped`, `test_function_invocation_limit_preserves_provider_executed_tool_pair`, `test_streaming_function_invocation_limit_preserves_provider_executed_tool_pair`, `test_function_invocation_limit_appends_fallback_after_provider_executed_tool_pair`, `test_streaming_function_invocation_limit_appends_fallback_after_provider_executed_tool_pair`, `test_function_invocation_limit_preserves_hosted_approval_request`, `test_streaming_function_invocation_limit_preserves_hosted_approval_request` | | Conversation continuation | Conversation id updates between iterations and is cleared on stop where required. | `test_conversation_id_updated_in_options_between_tool_iterations`, `test_function_invocation_stop_clears_conversation_id_non_stream`, `test_streaming_function_invocation_stop_clears_conversation_id` | +| Rejected tool-output continuation | A provider rejection of a `function_call_output` chained with `previous_response_id` on a background request carries the background limitation and the orphaned-`call_id` alternative as conditions to check, without asserting how the predecessor was created; every other pairing rejection keeps the generic transport message. | `packages/openai/tests/openai/test_openai_chat_client.py::test_background_tool_output_pairing_error_reports_background_limitation`, `test_background_tool_output_pairing_error_does_not_assert_predecessor_was_background`, `test_streaming_background_tool_output_pairing_error_reports_background_limitation`, `test_foreground_tool_output_pairing_error_keeps_generic_message`, `test_background_without_previous_response_keeps_generic_message` | ### History and provider serialization diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index e2f68b27c9..de6cbb0845 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -167,13 +167,19 @@ class PromptCacheOptions(TypedDict, total=False): def _is_background_tool_output_pairing_error(ex: Exception, run_options: Mapping[str, Any] | None) -> bool: - """Return whether a failure is the background-predecessor tool-output pairing rejection. + """Return whether a failure could be the background-predecessor tool-output pairing rejection. The service rejects a `function_call_output` chained with `previous_response_id` when the predecessor response was created with `background=True`, even though retrieving that predecessor returns the matching `function_call`. The same chaining succeeds for a foreground - predecessor, so the combination of all three signals is required: the pairing error, a - `previous_response_id` continuation, and a background request. + predecessor, so all three signals are required: the pairing error, a `previous_response_id` + continuation, and a background request. + + `background` describes *this* request, not the response named by `previous_response_id`, and a + stateless client cannot know how that predecessor was created -- it may have been produced by a + different process. So a genuinely orphaned `call_id` on a background continuation reaches this + branch too. The added guidance is therefore phrased as a condition the caller can check rather + than as an assertion about the predecessor, and it names the orphaned-`call_id` alternative. """ if not isinstance(ex, BadRequestError) or run_options is None: return False @@ -671,13 +677,15 @@ def _handle_request_error(self, ex: Exception, run_options: Mapping[str, Any] | if _is_background_tool_output_pairing_error(ex, run_options): raise ChatClientException( maybe_append_azure_endpoint_guidance( - f"{type(self)} service rejected the tool result for a background response: {ex} " - "The service does not accept a function_call_output chained with " - "previous_response_id when the preceding response was created with " - "background=True, even though that response contains the matching function_call. " - "Run the tool-calling turn with background=False, which supports the same " - "previous_response_id chaining. This is a service-side limitation tracked in " - "Azure/azure-sdk-for-python#46092 and microsoft/agent-framework#7538.", + f"{type(self)} service rejected the tool result: {ex} " + "If the preceding response was created with background=True, this is a " + "service-side limitation: it does not accept a function_call_output chained to " + "a background response with previous_response_id, even though that response " + "contains the matching function_call. Run the tool-calling turn with " + "background=False, which supports the same previous_response_id chaining. " + "Tracked in Azure/azure-sdk-for-python#46092 and microsoft/agent-framework#7538. " + "If the preceding response was not a background response, the call_id above " + "does not match a function_call on it.", azure_endpoint=self.azure_endpoint, ), inner_exception=ex, 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 facd1b0518..bac6a8150b 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -1405,6 +1405,32 @@ async def test_background_tool_output_pairing_error_reports_background_limitatio assert "microsoft/agent-framework#7538" in message +async def test_background_tool_output_pairing_error_does_not_assert_predecessor_was_background() -> None: + """The guidance is conditional, since an orphaned call_id reaches this branch identically. + + `background` in the run options describes this request, not the response named by + `previous_response_id`. A caller submitting a genuinely orphaned `call_id` on a background + continuation produces the same three signals, so the message must state the background + limitation as a condition to check and name the orphaned-call_id alternative, rather than + claim the predecessor was a background response. + """ + client = OpenAIChatClient(model="test-model", api_key="test-key") + + with ( + patch.object(client.client.responses.with_raw_response, "create", side_effect=_tool_output_pairing_error()), + pytest.raises(ChatClientException) as exc_info, + ): + await client.get_response( + messages=[Message(role="user", contents=["Test message"])], + options={"background": True, "conversation_id": "resp_abc123"}, + ) + + message = str(exc_info.value) + assert "If the preceding response was created with background=True" in message + assert "If the preceding response was not a background response" in message + assert "does not match a function_call on it" in message + + async def test_streaming_background_tool_output_pairing_error_reports_background_limitation() -> None: """The streaming path reports the same background limitation as the non-streaming path.""" client = OpenAIChatClient(model="test-model", api_key="test-key")