Skip to content
Closed
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 @@ -510,6 +510,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"))
Comment thread
moonbox3 marked this conversation as resolved.


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} "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 background=True + local tools there would reject three things that work today:

  1. A background run whose model never calls a local tool. Declaring a FunctionTool does not mean one gets called. With no function_call there is no continuation and no 400, so these runs complete normally.
  2. A background run chained through a Conversations object. _prepare_options sends conversation for a conv_... id and only sets previous_response_id for a resp_... id, so a conversation-chained background run never builds the request the service rejects.
  3. A background run whose tools are hosted. _prepare_tools_for_openai only converts FunctionTool; web_search, code_interpreter and the rest pass through and execute service-side, so they never produce a function_call_output.

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 previous_response_id, completed 5/6. So background=True with local tools is not the unsupported combination. function_call_output + previous_response_id + a background predecessor is. A preflight keyed on the first pair would reject a combination the issue itself measured as working.

What is decidable, and where. The narrowest correct preflight is on the continuation request rather than the first one: background and previous_response_id and an input carrying a function_call_output. That is exactly the predicate this PR already evaluates, just checked before the HTTP call instead of after the 400 comes back. It saves the doomed round trip. I am happy to move it there if you want it.

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 function_call and Agent Framework executing the tool. The chat client cannot take that decision on its own: at that point it holds a valid completed response, and the caller may well continue by replaying into a fresh response or through a Conversations object, neither of which hits the rejection. Refusing to return the response would break those callers.

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}",
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:
Comment thread
moonbox3 marked this conversation as resolved.
"""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