Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/specs/004-python-function-calling-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 54 additions & 4 deletions python/packages/openai/agent_framework_openai/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and it changed the patch — though the fix belongs in the message rather than the gate.

You are right that background describes this request, not the response named by previous_response_id. A caller who submits a genuinely orphaned call_id on a background continuation produces all three signals and reaches this branch identically, including the foreground-predecessor case you name.

Gating on tracked predecessor provenance is not available to this client, though. It is stateless with respect to previous_response_id: it holds no map from a response id to how that response was created, and a continuation may be built by a different process, or a different client instance, than the one that created the predecessor. State carried forward from background polling would cover only the subset of continuations this instance itself created, so the branch would still be reachable without it — and would then be inconsistent about when it fires.

So the message no longer asserts anything about the predecessor. It states the background limitation as a condition the caller can check, and names the orphaned-call_id alternative explicitly:

If the preceding response was created with background=True, this is a service-side limitation: [...] If the preceding response was not a background response, the call_id above does not match a function_call on it.

Both readings are correct whichever case the caller is actually in, and the original error text is still included either way. test_background_tool_output_pairing_error_does_not_assert_predecessor_was_background pins the wording, and the helper's docstring records why the distinction is not decidable here.



class OpenAIContinuationToken(ContinuationToken):
"""Continuation token for OpenAI Responses API background operations."""
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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))
Expand Down
110 changes: 110 additions & 0 deletions python/packages/openai/tests/openai/test_openai_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added. docs/specs/004-python-function-calling-loop.md now carries a Rejected tool-output continuation row in the Errors, control flow, and limits matrix, stating the invariant — the specialized guidance carries the background limitation and the orphaned-call_id alternative as conditions to check without asserting how the predecessor was created, and every other pairing rejection keeps the generic transport message — and referencing all five regression tests, including the one added for the review point on the source file.

"""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")
Expand Down
Loading