-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Python: fix(openai): report the background cause when a tool result is rejected #7603
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0a928a9
d31aa0e
410fcce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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} " | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we reject background=True with local tools before the first Responses request? The new predicate runs only after the background job and local tool execution have already occurred, leaving callers to restart the run after the failed continuation. A preflight check or explicit continuation policy would address the unsupported combination at the layer that creates it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good question, and I looked at whether it can be done at that point. I do not think a check before the first request can be made correct, but there is a narrower one that can, and the gap you are pointing at is real either way. Why not before the first request. The 400 is raised by the continuation, not by the request that declares the tools, so at the first request none of the failing conditions exist yet. Rejecting
The stronger point is in Gieril Lindi (@Laende)'s own harness table in #7538: a fresh stored background response carrying replayed output and tool output, with no What is decidable, and where. The narrowest correct preflight is on the continuation request rather than the first one: I would gently argue against it, though, for the reason the diagnostic form has going for it: this is a service defect tracked in Azure/azure-sdk-for-python#46092. A local reject is a hard-coded assertion about service behaviour, so when the service starts accepting the pairing, Agent Framework keeps refusing it until someone ships a revert. A message attached to the service's own 400 stops appearing by itself the day the 400 stops. The part of your comment I cannot answer with a preflight. You are right that the local tool has already run by the time this fires, and no check on the request path changes that. The only place to close it is between the background response arriving with a That makes it the "explicit continuation policy" half of your comment rather than the preflight half, and it is your design call, not mine. If you want a background-continuation policy on the client, with replay or reject as the non-default options, I am glad to build it, either in this PR or a follow-up. So: leave it as the diagnostic, move it to a preflight on the continuation request, or take it to a continuation policy. Tell me which and I will push it. |
||
| "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)) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.