Skip to content

fix(models/gemini): emit thought signature as its own reasoning delta#3306

Open
ebarkhordar wants to merge 1 commit into
strands-agents:mainfrom
ebarkhordar:fix/gemini-thought-signature-delta
Open

fix(models/gemini): emit thought signature as its own reasoning delta#3306
ebarkhordar wants to merge 1 commit into
strands-agents:mainfrom
ebarkhordar:fix/gemini-thought-signature-delta

Conversation

@ebarkhordar

Copy link
Copy Markdown

Description

GeminiModel._format_chunk builds a single reasoningContent delta that carries both text and
signature keys. The shared aggregator dispatches on key presence, not on value:

# strands-py/src/strands/event_loop/streaming.py, handle_content_block_delta
if "text" in delta_content["reasoningContent"]:
    ...
elif "signature" in delta_content["reasoningContent"]:

The text key is set unconditionally by the dict literal in gemini.py, so for Gemini the elif
arm never runs. state["signature"] is never set, and handle_content_block_stop leaves the
signature out of the reasoning block.

The adapter then reads that signature back on the next turn. _format_request_content_part does:

thought_signature = content["reasoningContent"]["reasoningText"].get("signature")

which always sees None, so thought_signature=None is always sent back to Gemini. The adapter
base64-encodes the signature on the way in (gemini.py:401), drops it in aggregation, then looks
for it again on the way out and finds nothing.

Invariant this restores: a thought signature that Gemini issues on a reasoning part survives
stream aggregation, so the signature sent back on the next turn is the one the model issued.

#1703 landed exactly this round-trip for the toolUse path. This is the same fix for the
reasoningText path, so signature preservation is consistent within the provider.

The fix: emit the signature as its own contentBlockDelta, which is the convention the other
providers already follow. anthropic.py:314 emits signature_delta as its own single-key delta,
and bedrock.py:1139 yields the reasoning text delta and then yields the signature as a separate
delta, which is the same shape this PR gives Gemini. Across every adapter that builds a
reasoningContent delta (anthropic, bedrock, gemini, llamacpp, openai,
openai_responses), Gemini was the only one packing both keys into one delta, which is why the
if/elif holds for every other provider.

Why not fix the aggregator instead

The one-word change at streaming.py (elif to if) also makes the signature survive, and it
keeps the existing tests green. I measured it rather than assume: handle_content_block_delta
returns a single typed_event, so the signature branch overwrites it and the
ReasoningTextStreamEvent is lost. Reasoning text would stop reaching streaming consumers.

Measured on 8a292d9, same input (a thought part with text="test reason", thought_signature=b"abc"):

aggregated signature ReasoningTextStreamEvent
main dropped emitted
elif to if in streaming.py preserved lost
this PR (adapter emits its own delta) preserved emitted

I chose the adapter-only seam because it touches no shared aggregation code and so has no
cross-provider blast radius. If you would rather fix the dispatch in streaming.py and make it
emit multiple events, that is your call and I am happy to redo it that way.

One open question

If Gemini can attach a complete thought_signature to more than one part inside a single reasoning
block, state["signature"] += ... concatenates them. Feeding two signed thought parts through my
branch produces 'czE=czI=', which is two base64 values joined rather than one signature. I could
not verify whether Gemini actually does that, because I made no live API calls, so I am flagging it
rather than guessing. On main that same input yields no signature at all, so this is not a case
that works today.

Related Issues

None. I found this by reading the adapter against the aggregator, and did not open a separate issue
for it since the repro and the fix are both here. Related merged work: #1703.

Documentation PR

Not needed. No public API or documented behavior changes.

Type of Change

Bug fix

Testing

All offline, no API keys, in a clean python:3.12-slim Docker container against 8a292d9, with
importlib.util.find_spec(...).origin checked on both sides to confirm which copy ran.

  • Added test_stream_response_reasoning_signature_survives_aggregation, which drives the real
    model.stream() through the real process_stream and asserts three things: the aggregated
    reasoningText carries the exact signature, the ReasoningTextStreamEvent is still emitted (this
    assertion is what fails on the elif to if alternative above), and feeding the aggregated
    message back through _format_request_content_part reproduces the original signature bytes.
  • Confirmed it fails without the source change: I reverted only the gemini.py hunks and kept the
    new test, and it fails on unfixed source. It passes on the branch.
  • Updated test_stream_response_reasoning and test_stream_response_reasoning_and_text, which
    asserted the combined-delta chunk shape. They passed while the aggregated message was wrong,
    which is why this survived.
  • Compared old and new emission across a variant matrix (plain text, thought without signature,
    thought with signature, signed thought followed by plain text, empty-text thought with
    signature). Output is identical except the signed-thought path.
  • hatch test tests: 4421 passed, 5 skipped, 2 failed. Both failures are in
    tests/strands/storage/test_local_file_storage.py and are not from this change: they fail
    identically on pristine 8a292d9, because my container runs as root and root ignores the chmod
    those two tests use to force a write error.
  • hatch fmt --linter --check: ruff check passes. ruff format --check reports both changed
    files already formatted. mypy reports no issues across 223 source files, though I ran it with
    --follow-imports=silent because the default run exhausts memory on my machine, so that leg is
    not byte-for-byte what CI runs.
  • No new test warnings: the Gemini test module reports zero warnings on this branch and on main.

What I did not verify: I made no live Gemini API calls, so everything above is about this SDK's own
round-trip, not about how Google's service responds to a request with a missing thought signature. I
also ran the unit suite on Python 3.10 only, not the full 3.10 to 3.14 matrix, so I did not run
hatch run prepare end to end.

  • I ran hatch run prepare

Checklist

  • I have read the CONTRIBUTING document
  • I have reviewed and understand every line of code in this PR, including any generated by AI tools, and I can explain why it works
  • My change is focused and reasonably small; I have split unrelated work into separate PRs
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

I used an AI coding assistant on this change. I reproduced the bug, ran every check quoted above
myself, and can explain and defend each line.

GeminiModel packed "text" and "signature" into a single reasoningContent
delta. handle_content_block_delta dispatches on key presence, so the "text"
arm always won and the signature never reached the aggregated message. The
signature is now emitted as its own delta, matching the convention every
other provider already follows.
@ebarkhordar
ebarkhordar requested a review from a team as a code owner July 16, 2026 16:58
@github-actions github-actions Bot added size/s bug Something isn't working area-model Related to models or model providers python Pull requests that update python code labels Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-model Related to models or model providers bug Something isn't working python Pull requests that update python code size/s

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant