You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Python: [Bug]: Gemini: thought_signature is lost when a tool approval is answered — PR #7095's adjacency mechanism does not cover the approval path (#6963 regression) #7453
On gemini-3.6-flash, the turn immediately after a tool approval is answered fails:
google.genai.errors.ClientError: 400 Bad Request
"Function call is missing a thought_signature in functionCall parts. This is required for
tools to work correctly, and missing thought_signature may lead to degraded model performance."
This is the failure of #6963, which was closed by PR #7095. #7095 does fix the general case — a normal multi-round tool loop replays signed calls correctly, verified below. It does not cover the approval path, which is the path #6963 was opened for:
the harness's tool-approval flow reconstructs an approved call as a fresh FunctionCallContent without that part
Filing fresh rather than commenting because #6963 is closed and we cannot reopen it.
Why the mechanism misses this path
PR #7095 carries the signature as base64 protected_data on a text_reasoning content and re-attaches it in GeminiChatClient._convert_message_contentsby adjacency — the carrier must immediately precede its call:
After an approval is answered, the replayed history has no carrier for the approved call, so pending_signature is None when the call is serialized. A probe inside _convert_message_contents, logging (content.type, bool(content.protected_data), call_id) for each message — same conversation, same call_id:
request before the approval: [('text_reasoning', SIG), ('function_call', 'rLR9mbWo'), ('text', '')] -> 200 OK
replay after the approval: [('function_call', 'rLR9mbWo')] -> 400
There are zero protected_data carriers anywhere in the failing request. The first line is #7095 working exactly as designed; the second is the same call, unsigned.
Scope of what we verified. The probe establishes that the carrier is absent from the replay. We did not isolate whether raw_representation also fails to survive as a types.Part (the mechanism #6963 originally described, which would otherwise let the raw_part.thought_signature branch cover it). Either way adjacency has nothing to correlate, so the outcome is the same, but the reconstruction detail is worth confirming on your side — _replace_approval_contents_with_results rebuilds the call from the function_call nested in the approval content, and whether that nested copy still carries the original Part is the deciding factor for which branch could have saved it.
A second, smaller fragility in the same loop
pending_signature is cleared by any intervening content, so the signature is also lost whenever anything sits between carrier and call — including another text_reasoning that has no protected_data (a thought summary), or an approval control that the loop then discards through case _. Measured on the pinned connector:
contents passed to _convert_message_contents
signature on the emitted functionCall part
carrier, call
present
carrier, approval_response, call
None
carrier, summary_reasoning, call
None
carrier, call, approval_request
present
Whether this fires is therefore ordering luck, which makes the current mechanism awkward to depend on even where a carrier does exist.
Reproduction
End to end: any AG-UI surface on a Gemini 3.x model with one approval-gated tool — send a prompt that triggers the tool, approve the card. The next model call 400s.
The adjacency dependency itself reproduces offline, no network, and is the part worth asserting in a test:
importbase64fromagent_frameworkimportContentfromagent_framework_geminiimportGeminiChatClientSIG=b"signature-bytes"client=object.__new__(GeminiChatClient)
defsig_of(contents):
parts=client._convert_message_contents(contents, {})
call=next(pforpinpartsifp.function_callisnotNone)
returncall.thought_signaturecarrier=Content.from_text_reasoning(text="thinking", protected_data=base64.b64encode(SIG).decode())
call=Content.from_function_call(call_id="c1", name="search", arguments={})
assertsig_of([carrier, call]) ==SIG# the normal loop: signedassertsig_of([call]) isNone# the approval replay: unsigned -> 400
Correlate by call_id instead of position — robust to the carrier being absent, reordered, or separated by another content:
keep the signature on the function-call content itself, or in a per-request call_id → signature map populated at parse time; and
on build, backfill only when the part lacks a signature, so this composes with the current adjacency path rather than replacing it.
One pitfall worth flagging, because we walked into it. The obvious implementation captures from the raw types.Part — part.function_call.id — and that is what our own pre-#7095 shim did. It has a hole: _parse_parts (_chat_client.py:1168-1172) generates the id when Gemini omits it,
so a raw-part capture is keyed by an id the rest of the framework never sees, and the backfill silently misses every call that took the fallback. Capturing from the parsed contents instead — the carrier and call you have just emitted, where the call_id is already resolved — keys it correctly in both cases. Adjacency is reliable at parse time (both are appended while walking one response's parts); it is only the replay side where it fails.
We have re-shipped this locally against 143386fe and verified it live on gemini-3.6-flash: the backfill fires for each outstanding call and the previously-400ing approval turn now completes. So the direction is known to work, not merely proposed. Our implementation is a GeminiChatClient subclass holding a bounded per-client OrderedDict, populated in _parse_parts and consumed in _convert_message_contents; happy to paste it here if that is useful.
For history: we shipped this once while #6963 was open and retired it when #7095 landed. That was our call and our mistake, but the gap is real either way.
Suggested regression test
Worth passing on, because it is why this slipped past us too: our own retirement guard did exercise a reconstructed call — but handed the converter [carrier, reconstructed], supplying by hand the very carrier the approval path drops. It could therefore only ever prove the adjacency path while reading as coverage of the approval one.
A test that asserts the approval path works needs to build the history the approval flow actually produces, and then assert that it did so. The shape we landed on:
defreplay_after_approval(call_id):
# The history _replace_approval_contents_with_results produces: the call rebuilt from# the function_call nested in the approval content, and no carrier anywhere.approved=Content.from_function_approval_response(
True, id=call_id,
function_call=Content.from_function_call(call_id=call_id, name="search", arguments={}),
)
return [approved.function_call]
deftest_the_replay_history_contains_no_carrier():
replay=replay_after_approval("c1")
assert [c.typeforcinreplay] == ["function_call"]
assertnotany(c.type=="text_reasoning"andc.protected_dataforcinreplay)
assertnotisinstance(replay[0].raw_representation, types.Part)
That last test is the one that matters: it fails the moment a future edit starts supplying a carrier or a raw Part, which is the only way the approval-path assertions can silently stop testing anything.
Description
Summary
On
gemini-3.6-flash, the turn immediately after a tool approval is answered fails:This is the failure of #6963, which was closed by PR #7095. #7095 does fix the general case — a normal multi-round tool loop replays signed calls correctly, verified below. It does not cover the approval path, which is the path #6963 was opened for:
Filing fresh rather than commenting because #6963 is closed and we cannot reopen it.
Why the mechanism misses this path
PR #7095 carries the signature as base64
protected_dataon atext_reasoningcontent and re-attaches it inGeminiChatClient._convert_message_contentsby adjacency — the carrier must immediately precede its call:After an approval is answered, the replayed history has no carrier for the approved call, so
pending_signatureisNonewhen the call is serialized. A probe inside_convert_message_contents, logging(content.type, bool(content.protected_data), call_id)for each message — same conversation, samecall_id:There are zero
protected_datacarriers anywhere in the failing request. The first line is #7095 working exactly as designed; the second is the same call, unsigned.Scope of what we verified. The probe establishes that the carrier is absent from the replay. We did not isolate whether
raw_representationalso fails to survive as atypes.Part(the mechanism #6963 originally described, which would otherwise let theraw_part.thought_signaturebranch cover it). Either way adjacency has nothing to correlate, so the outcome is the same, but the reconstruction detail is worth confirming on your side —_replace_approval_contents_with_resultsrebuilds the call from thefunction_callnested in the approval content, and whether that nested copy still carries the original Part is the deciding factor for which branch could have saved it.A second, smaller fragility in the same loop
pending_signatureis cleared by any intervening content, so the signature is also lost whenever anything sits between carrier and call — including anothertext_reasoningthat has noprotected_data(a thought summary), or an approval control that the loop then discards throughcase _. Measured on the pinned connector:_convert_message_contentsfunctionCallpartcarrier, callcarrier, approval_response, callcarrier, summary_reasoning, callcarrier, call, approval_requestWhether this fires is therefore ordering luck, which makes the current mechanism awkward to depend on even where a carrier does exist.
Reproduction
End to end: any AG-UI surface on a Gemini 3.x model with one approval-gated tool — send a prompt that triggers the tool, approve the card. The next model call 400s.
The adjacency dependency itself reproduces offline, no network, and is the part worth asserting in a test:
Code Sample
Error Messages / Stack Traces
Package Versions
agent-framework-core: 1.12.1, agent-framework-gemini: 1.0.0b260722, agent-framework-ag-ui: 1.0.0
Python Version
No response
Additional Context
Suggested fix
Correlate by
call_idinstead of position — robust to the carrier being absent, reordered, or separated by another content:call_id → signaturemap populated at parse time; andOne pitfall worth flagging, because we walked into it. The obvious implementation captures from the raw
types.Part—part.function_call.id— and that is what our own pre-#7095 shim did. It has a hole:_parse_parts(_chat_client.py:1168-1172) generates the id when Gemini omits it,so a raw-part capture is keyed by an id the rest of the framework never sees, and the backfill silently misses every call that took the fallback. Capturing from the parsed contents instead — the carrier and call you have just emitted, where the
call_idis already resolved — keys it correctly in both cases. Adjacency is reliable at parse time (both are appended while walking one response's parts); it is only the replay side where it fails.We have re-shipped this locally against
143386feand verified it live ongemini-3.6-flash: the backfill fires for each outstanding call and the previously-400ing approval turn now completes. So the direction is known to work, not merely proposed. Our implementation is aGeminiChatClientsubclass holding a bounded per-clientOrderedDict, populated in_parse_partsand consumed in_convert_message_contents; happy to paste it here if that is useful.For history: we shipped this once while #6963 was open and retired it when #7095 landed. That was our call and our mistake, but the gap is real either way.
Suggested regression test
Worth passing on, because it is why this slipped past us too: our own retirement guard did exercise a reconstructed call — but handed the converter
[carrier, reconstructed], supplying by hand the very carrier the approval path drops. It could therefore only ever prove the adjacency path while reading as coverage of the approval one.A test that asserts the approval path works needs to build the history the approval flow actually produces, and then assert that it did so. The shape we landed on:
That last test is the one that matters: it fails the moment a future edit starts supplying a carrier or a raw Part, which is the only way the approval-path assertions can silently stop testing anything.
Environment
agent-framework-core1.12.1,agent-framework-gemini1.0.0b260722,agent-framework-ag-ui1.0.0main@143386fecc1e1c8ab02f1e3ee21b544ed5456d1cgemini-3.6-flashvia the Gemini Developer API; surface: AG-UI + CopilotKit, one approval-gated tool, answeredReferences