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 2e4c06dff7..de6cbb0845 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -160,6 +160,33 @@ 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 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 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 + 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 +660,36 @@ 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: {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, + ) from ex raise ChatClientException( maybe_append_azure_endpoint_guidance( f"{type(self)} service failed to complete the prompt: {ex}", @@ -750,7 +800,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 +844,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..bac6a8150b 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,116 @@ 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_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") + + 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")