Skip to content

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

Description

@antsok

Description

Summary

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_contents by adjacency — the carrier must immediately precede its call:

if content.type == "text_reasoning":
    pending_signature = None
    ... pending_signature = b64decode(content.protected_data) ...
    continue
thought_signature = pending_signature
pending_signature = None

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:

import base64
from agent_framework import Content
from agent_framework_gemini import GeminiChatClient

SIG = b"signature-bytes"
client = object.__new__(GeminiChatClient)

def sig_of(contents):
    parts = client._convert_message_contents(contents, {})
    call = next(p for p in parts if p.function_call is not None)
    return call.thought_signature

carrier = Content.from_text_reasoning(text="thinking", protected_data=base64.b64encode(SIG).decode())
call = Content.from_function_call(call_id="c1", name="search", arguments={})

assert sig_of([carrier, call]) == SIG      # the normal loop: signed
assert sig_of([call]) is None              # the approval replay: unsigned -> 400

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_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.Partpart.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,

if function_call.id:
    call_id = function_call.id
else:
    call_id = self._generate_tool_call_id()
    logger.debug("function_call missing id; generated fallback call_id=%r", call_id)

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:

def replay_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]

def test_the_replay_history_contains_no_carrier():
    replay = replay_after_approval("c1")
    assert [c.type for c in replay] == ["function_call"]
    assert not any(c.type == "text_reasoning" and c.protected_data for c in replay)
    assert not isinstance(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.

Environment

  • agent-framework-core 1.12.1, agent-framework-gemini 1.0.0b260722, agent-framework-ag-ui 1.0.0
  • Pinned to main @ 143386fecc1e1c8ab02f1e3ee21b544ed5456d1c
  • Model gemini-3.6-flash via the Gemini Developer API; surface: AG-UI + CopilotKit, one approval-gated tool, answered
  • First observed on a deployed revision on 2026-07-24, i.e. shortly after we retired our own workaround on the strength of Python: preserve Gemini 3 thought_signature across function-call replays #7095 — not related to any later dependency bump

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    likely-fixedpythonUsage: [Issues, PRs], Target: PythontriageUsage: [Issues], Target: All issues that still need to be triaged

    Type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions