Description
1. Symptom
Approving a gated local tool call ends the run with a hard error from the Responses API:
Error code: 400 - {'error': {'message': "The following MCP approval requests have approval
responses but weren't passed as input: call_OWTUay5B7jQPNHM8IGvIFaC7", 'type':
'invalid_request_error', 'param': None, 'code': None}}
Every answered approval card on the Responses path fails this way, so a gated local tool can never complete its turn.
2. Root cause
Two independent defects, and either fix alone prevents the 400.
2a. PR #7407 made the response arm unconditional
PR #7407 "Preserve approval decisions under OpenAI continuation" (95ec5b7d) split one match arm in two and dropped the storage guard from the response half:
- case "function_approval_response" | "function_approval_request":
+ case "function_approval_request":
if request_uses_service_side_storage:
continue
...
+ case "function_approval_response":
+ prepared = self._prepare_content_for_openai(...) # no guard
Under request_uses_service_side_storage=True the request is now omitted while the response is always emitted. Before #7407 the shared guard kept the pair consistent — both omitted under storage, both sent without it — so this asymmetry could not arise; the PR even renamed the test that had asserted it (test_prepare_messages_strips_approval_items_under_storage). The failing payload carries an mcp_approval_response and no mcp_approval_request, which is that asymmetry exactly.
The PR's reasoning is sound for a hosted/MCP approval — the service issued and stored the request, so replaying it would duplicate. It assumes every function_approval_response has a server-stored request to pair with, which is false for a local FunctionTool.
The serializer it reaches, _prepare_content_for_openai (agent_framework_openai/_chat_client.py:1965), has no hosted-tool check at all:
case "function_approval_response":
return {
"type": "mcp_approval_response",
"approval_request_id": content.id,
"approve": content.approved,
}
agent_framework._tools._is_hosted_tool_approval exists for exactly this distinction (function_call.additional_properties["server_label"]) and is used elsewhere in core, but not here. So a local FunctionTool's approval response — which has no server_label and must never leave the process — is emitted as an mcp_approval_response whose approval_request_id is a local call_id. No mcp_approval_request with that id can exist in the input, so the request is rejected.
The function_approval_request arm immediately above does read server_label, emitting None for a local tool — inconsistent with this arm, and arguably wrong in its own right.
2b. _approval_controls_to_keep only clears a response whose result comes later
PR #7345 "Improve function approval resume and replay" (5987a679) added _approval_controls_to_keep in agent_framework/_sessions.py, called from the concrete HistoryProvider.before_run via _filter_approval_control_messages.
It walks messages in order, registers a local function_approval_response into local_response_ids_by_call_id, and only pops it when it subsequently sees a terminal function_result for that call_id. When the terminal result is seated before the response — which is the layout the approval-resume path produces — the pop happens while the map is still empty, the response is then registered and never cleared, and it is preserved into model input as "unresolved".
This contradicts the stated contract. core/AGENTS.md says: "The base HistoryProvider.before_run filters resolved wrappers from later model replay, but preserves unresolved requests/responses until a terminal result or follow-up request closes them." Measured against the shipped function with one local approval response and that call's terminal result:
| Layout |
Response replayed? |
| response, then result — same message |
no ✅ |
| response, then result — later message |
no ✅ |
| result, then response — same message (the live layout) |
yes ❌ |
| result, then response — earlier message |
yes ❌ |
The approval is equally resolved in all four. Order alone decides. Rejected decisions and repeated call_ids leak by the same mechanism.
3. Evidence
The outgoing payload, from the OpenAI client's own debug logging (wrapped here for readability):
'json_data': {'include': ['reasoning.encrypted_content'], 'input': [
{'type': 'message', 'role': 'user', 'content': [{'type': 'input_text', 'text': '…'}]},
{'call_id': 'call_OWTUay5B7jQPNHM8IGvIFaC7', 'type': 'function_call_output', 'output': "File 'notes.md' not found."},
{'type': 'mcp_approval_response', 'approval_request_id': 'call_OWTUay5B7jQPNHM8IGvIFaC7', 'approve': True},
{'call_id': 'call_zGSd8XqalyjdoHvYs14lDQCZ', 'type': 'function_call_output', 'output': "File 'notes.md' written."},
…
call_OWTUay… is file_access_read, a local harness tool. Both defects are visible in three lines: its terminal function_call_output precedes the response (2b), and the response is serialized as an mcp_approval_response (2a). Item-type census over one failing run:
10 function_approval_request
5 function_approval_response
1 mcp_approval_response <-- the leak
A probe on the message list as the chat client receives it, immediately before upstream's serialization, shows the leftover is already present and already local:
[0/user] text
[1/tool] function_result call_id=call_WMZ0CiDqutoPaeKXq93hjkxs
[1/tool] function_approval_response id=call_WMZ0Ci… server_label=None HOSTED=False
[1/tool] function_result call_id=call_clAPm9keQggSCUnru1CI1QcU
[2/user] text
[3/assistant] function_call call_id=call_clAPm9keQggSCUnru1CI1QcU
[3/assistant] function_call call_id=call_WMZ0CiDqutoPaeKXq93hjkxs
[4/system] text
Two things this settles. HOSTED=False — no server_label, so it is unambiguously a local tool's approval, and §2a's missing guard is what mis-serializes it. And the response sits in the same role="tool" message as its own terminal function_result, immediately after it — so §2b's order-dependence is triggered within a single message and needs no message-level reordering to bite. _replace_approval_contents_with_results was expected to replace that response with the result; it left both.
4. Reproduction
- Register a local
FunctionTool with approval_mode="always_require". A harness-contributed tool such as file_access_read works, but nothing about the reproduction depends on the harness — any gated local tool reaches the same path.
- Run against the Responses API with service-side storage in effect.
- Drive a turn that calls the gated tool, and approve the resulting card.
- The next request carries an
mcp_approval_response with no matching mcp_approval_request, and the API returns the 400 in §1.
Reproduced consistently on azure_foundry/gpt-5.4-mini over the Responses API. Any application that gates a local tool on this path reaches the same failure.
Code Sample
Error Messages / Stack Traces
Package Versions
agent-framework-core: 1.12.1, agent-framework-openai: 1.11.0
Python Version
No response
Additional Context
5. Proposed fix
Primary — 2a. Gate the MCP projection on the approval actually being hosted, mirroring what core already does elsewhere:
case "function_approval_response":
if not _is_hosted_tool_approval(content):
return {} # local approvals are resolved in-process; never send them
return {"type": "mcp_approval_response", "approval_request_id": content.id, "approve": content.approved}
…and the same guard on the function_approval_request arm, instead of emitting server_label: None.
Nothing is lost by dropping a local approval response. It is resolved in-process by ToolApprovalMiddleware, and its outcome already travels as the ordinary function_call_output for the same call_id — which is why the shared if request_uses_service_side_storage: continue guard could drop it for as long as it did without anyone noticing.
Secondary — 2b. Make _approval_controls_to_keep order-independent: collect terminal results per call_id in a first pass, then decide which responses are unresolved, rather than relying on the result appearing after the response.
6. The same leftover breaks two other connectors
§2b's leftover is not only a Responses-path problem. The same resolved-but-replayed local approval response reaches every provider, and two others cannot represent it either.
Ollama — an outright request failure, and independent of §2a. The AG-UI approval-resume path synthesizes a role="user" message whose only contents are approval controls (_canonical_approval_resume_messages). agent_framework_ollama's _format_user_message rejects any user message with no text or data:
ChatClientInvalidRequestException: Ollama connector currently only supports user messages
with TextContent or DataContent.
This is an inconsistency between two upstream components rather than a consequence of #7407, and it does not need the Responses path at all — it was observed on the previous pin, before #7407 and #7345 merged. It is arguably worth its own issue; it is recorded here because §2b's leftover is what puts those contents in that message.
Gemini — a latent, ordering-dependent unsigning. Since PR #7095 the Gemini 3 thought_signature travels as base64 protected_data on a text_reasoning content and is re-attached by adjacency to the call that immediately follows. _convert_message_contents implements that with a pending_signature local and clears it on every non-reasoning content before the match, so a leaked approval control between carrier and call takes the signature with it and then discards itself via case _. Measured on the pinned connector:
| contents |
signature on the functionCall part |
carrier, call |
present |
carrier, approval_response, call |
None → 400 "Function call is missing a thought_signature in functionCall parts." |
carrier, approval_request, call |
None → same 400 |
carrier, call, approval_request |
present |
Whether it fires is ordering luck — a control after the call is harmless. Note that a separately observed Gemini 400 with the same message has a different cause (the approval replay carries no carrier at all, so there is no signature to eat); that is out of scope for this report, and this section describes only the hazard the leftover creates when a carrier is present.
Chat-completions providers are structurally immune. Mistral, OpenRouter and Hugging Face go through OpenAIChatCompletionClient, whose converter drops approval contents outright — verified offline against the real client, which yields [('tool', result)], [('assistant', tool_calls)], and [] for an approvals-only message. They never reach _prepare_content_for_openai's Responses arm.
Description
1. Symptom
Approving a gated local tool call ends the run with a hard error from the Responses API:
Every answered approval card on the Responses path fails this way, so a gated local tool can never complete its turn.
2. Root cause
Two independent defects, and either fix alone prevents the 400.
2a. PR #7407 made the response arm unconditional
PR #7407 "Preserve approval decisions under OpenAI continuation" (
95ec5b7d) split onematcharm in two and dropped the storage guard from the response half:Under
request_uses_service_side_storage=Truethe request is now omitted while the response is always emitted. Before #7407 the shared guard kept the pair consistent — both omitted under storage, both sent without it — so this asymmetry could not arise; the PR even renamed the test that had asserted it (test_prepare_messages_strips_approval_items_under_storage). The failing payload carries anmcp_approval_responseand nomcp_approval_request, which is that asymmetry exactly.The PR's reasoning is sound for a hosted/MCP approval — the service issued and stored the request, so replaying it would duplicate. It assumes every
function_approval_responsehas a server-stored request to pair with, which is false for a localFunctionTool.The serializer it reaches,
_prepare_content_for_openai(agent_framework_openai/_chat_client.py:1965), has no hosted-tool check at all:agent_framework._tools._is_hosted_tool_approvalexists for exactly this distinction (function_call.additional_properties["server_label"]) and is used elsewhere in core, but not here. So a localFunctionTool's approval response — which has noserver_labeland must never leave the process — is emitted as anmcp_approval_responsewhoseapproval_request_idis a localcall_id. Nomcp_approval_requestwith that id can exist in the input, so the request is rejected.The
function_approval_requestarm immediately above does readserver_label, emittingNonefor a local tool — inconsistent with this arm, and arguably wrong in its own right.2b.
_approval_controls_to_keeponly clears a response whose result comes laterPR #7345 "Improve function approval resume and replay" (
5987a679) added_approval_controls_to_keepinagent_framework/_sessions.py, called from the concreteHistoryProvider.before_runvia_filter_approval_control_messages.It walks messages in order, registers a local
function_approval_responseintolocal_response_ids_by_call_id, and only pops it when it subsequently sees a terminalfunction_resultfor thatcall_id. When the terminal result is seated before the response — which is the layout the approval-resume path produces — the pop happens while the map is still empty, the response is then registered and never cleared, and it is preserved into model input as "unresolved".This contradicts the stated contract.
core/AGENTS.mdsays: "The baseHistoryProvider.before_runfilters resolved wrappers from later model replay, but preserves unresolved requests/responses until a terminal result or follow-up request closes them." Measured against the shipped function with one local approval response and that call's terminal result:The approval is equally resolved in all four. Order alone decides. Rejected decisions and repeated
call_ids leak by the same mechanism.3. Evidence
The outgoing payload, from the OpenAI client's own debug logging (wrapped here for readability):
call_OWTUay…isfile_access_read, a local harness tool. Both defects are visible in three lines: its terminalfunction_call_outputprecedes the response (2b), and the response is serialized as anmcp_approval_response(2a). Item-type census over one failing run:A probe on the message list as the chat client receives it, immediately before upstream's serialization, shows the leftover is already present and already local:
Two things this settles.
HOSTED=False— noserver_label, so it is unambiguously a local tool's approval, and §2a's missing guard is what mis-serializes it. And the response sits in the samerole="tool"message as its own terminalfunction_result, immediately after it — so §2b's order-dependence is triggered within a single message and needs no message-level reordering to bite._replace_approval_contents_with_resultswas expected to replace that response with the result; it left both.4. Reproduction
FunctionToolwithapproval_mode="always_require". A harness-contributed tool such asfile_access_readworks, but nothing about the reproduction depends on the harness — any gated local tool reaches the same path.mcp_approval_responsewith no matchingmcp_approval_request, and the API returns the 400 in §1.Reproduced consistently on
azure_foundry/gpt-5.4-miniover the Responses API. Any application that gates a local tool on this path reaches the same failure.Code Sample
Error Messages / Stack Traces
Package Versions
agent-framework-core: 1.12.1, agent-framework-openai: 1.11.0
Python Version
No response
Additional Context
5. Proposed fix
Primary — 2a. Gate the MCP projection on the approval actually being hosted, mirroring what core already does elsewhere:
…and the same guard on the
function_approval_requestarm, instead of emittingserver_label: None.Nothing is lost by dropping a local approval response. It is resolved in-process by
ToolApprovalMiddleware, and its outcome already travels as the ordinaryfunction_call_outputfor the samecall_id— which is why the sharedif request_uses_service_side_storage: continueguard could drop it for as long as it did without anyone noticing.Secondary — 2b. Make
_approval_controls_to_keeporder-independent: collect terminal results percall_idin a first pass, then decide which responses are unresolved, rather than relying on the result appearing after the response.6. The same leftover breaks two other connectors
§2b's leftover is not only a Responses-path problem. The same resolved-but-replayed local approval response reaches every provider, and two others cannot represent it either.
Ollama — an outright request failure, and independent of §2a. The AG-UI approval-resume path synthesizes a
role="user"message whose only contents are approval controls (_canonical_approval_resume_messages).agent_framework_ollama's_format_user_messagerejects any user message with no text or data:This is an inconsistency between two upstream components rather than a consequence of #7407, and it does not need the Responses path at all — it was observed on the previous pin, before #7407 and #7345 merged. It is arguably worth its own issue; it is recorded here because §2b's leftover is what puts those contents in that message.
Gemini — a latent, ordering-dependent unsigning. Since PR #7095 the Gemini 3
thought_signaturetravels as base64protected_dataon atext_reasoningcontent and is re-attached by adjacency to the call that immediately follows._convert_message_contentsimplements that with apending_signaturelocal and clears it on every non-reasoning content before thematch, so a leaked approval control between carrier and call takes the signature with it and then discards itself viacase _. Measured on the pinned connector:functionCallpartcarrier, callcarrier, approval_response, call400 "Function call is missing a thought_signature in functionCall parts."carrier, approval_request, callcarrier, call, approval_requestWhether it fires is ordering luck — a control after the call is harmless. Note that a separately observed Gemini 400 with the same message has a different cause (the approval replay carries no carrier at all, so there is no signature to eat); that is out of scope for this report, and this section describes only the hazard the leftover creates when a carrier is present.
Chat-completions providers are structurally immune. Mistral, OpenRouter and Hugging Face go through
OpenAIChatCompletionClient, whose converter drops approval contents outright — verified offline against the real client, which yields[('tool', result)],[('assistant', tool_calls)], and[]for an approvals-only message. They never reach_prepare_content_for_openai's Responses arm.