fix(agents): preserve Gemini thought_signature across tool-calling turns - #396
Open
jirisacha wants to merge 1 commit into
Open
fix(agents): preserve Gemini thought_signature across tool-calling turns#396jirisacha wants to merge 1 commit into
jirisacha wants to merge 1 commit into
Conversation
Gemini 3 models reason before they act, and when that reasoning produces a function call the response carries an opaque signature at tool_calls[].extra_content.google.thought_signature which must be sent back with the same tool call on the next turn. The reasoning is never returned as text, so the signature is the only thing carrying it forward, and Gemini rejects a request that omits it: 400 INVALID_ARGUMENT - "Function call is missing a thought_signature in functionCall parts. This is required for tools to work correctly." The field sits outside the OpenAI schema, so the OpenAI .NET SDK and Microsoft.Extensions.AI drop it while remapping the response, and the follow-up request is rebuilt without it. The first call of a conversation succeeds and the second fails, which in practice means an agent dies on its first tool result -- catalog and document generation never get past their opening step. Handle it at the HTTP layer, where the raw JSON is still intact, following the pattern FinishReasonNormalizingHandler already uses for a different Gemini/OpenAI mismatch on the same layer. ThoughtSignatureHandler records each signature as the response streams past, keyed by tool call id, and restores it on any later request naming the same call. Responses are only read, never modified; only requests bound for Gemini's host are touched; parsing failures are swallowed so a malformed chunk can never break a response; and the cache is bounded, since a signature runs to several kilobytes. Verified against the live API: without the handler the second turn of a tool conversation returns 400 with the signature error, with it the same conversation returns 200 and the model's answer.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Gemini 3 models reason before they act. When that reasoning produces a function call, the
response carries an opaque signature:
That signature has to come back with the same tool call on the next turn. The reasoning is
never returned as text, so the signature is the only thing carrying it forward, and Gemini
rejects any request that omits it:
Symptom in OpenDeepWiki: the first request of a conversation returns 200, the follow-up
returns 400. Since wiki generation is a long tool conversation, catalog generation dies on
its very first tool result and the repository ends up
Failed.This affects every Gemini 3 model, so it is not avoidable by picking a different one — I
tested
gemini-3.1-pro-preview,gemini-3.7-flash,gemini-3.5-flash,gemini-3-flash-previewandgemini-3.1-flash-lite-preview, and all five behave the same.It matters for new users in particular, because the default model in the built-in
Google Gemini preset (
BuiltinProviderPresets.json) isgemini-3-pro-preview— sopicking Google today gives a configuration where generation cannot complete.
Why the SDK does not handle it
extra_contentis a Google extension outside the OpenAI schema. Requests go out throughOpenAI→Microsoft.Extensions.AI→FunctionInvokingChatClient, and the outgoingrequest is rebuilt from those libraries' own message objects, which have nowhere to keep a
provider-specific field on a tool call. So it is dropped coming in and never re-emitted
going out.
Upgrading does not help:
OpenAI2.8.0 and 2.13.0 andMicrosoft.Extensions.AI.OpenAI10.9.0 contain no reference to
extra_contentorthought_signatureat all.Disabling thinking is not a way out either —
gemini-3.1-pro-previewanswers400 Budget 0 is invalid. This model only works in thinking mode, and the flash modelsstill require the signature.
The fix
A
DelegatingHandlerthat carries the signature across turns at the HTTP layer, where theraw JSON is still intact — the same approach
FinishReasonNormalizingHandleralready takesfor Gemini's non-OpenAI
finish_reasonvalues.tool_calls[].id→thought_signaturepair as the SSEstream passes through. The stream itself is forwarded byte for byte; nothing is modified,
and nothing beyond the current line is buffered. Streaming splits a tool call across
chunks, so an
index→idmap handles chunks that carry the signature without an id.not already carry
extra_content.generativelanguage.googleapis.com; every other provider isuntouched.
cache is per-handler —
AgentFactorybuilds oneHttpClientper agent, so it spans asingle conversation — and bounded at 512 entries, since a signature runs to several
kilobytes.
Wired in as the outermost handler so it sees the request before it goes out and the
response after the retry logic below has settled.
Testing
12 new xUnit tests in
tests/OpenDeepWiki.Tests/Agents/ThoughtSignatureHandlerTests.cs,following the style of the neighbouring
FinishReasonNormalizingHandlerTests(internalhelpers called directly, no HTTP stack). They cover recording from streaming and
non-streaming responses, index-based pairing when a chunk omits the id, restoring onto the
request, not overwriting a caller-supplied
extra_content, tolerating malformed JSON, thefull round trip, and cache bounding.
Suite goes from 591 passed to 603 passed. The 7 failures in
RepositoryAnalyzerSourceTestsandRepositorySkillMarkdownBuilderTestsare pre-existing— I confirmed they fail identically on a clean checkout of
main(75840e5).Verified against the live Gemini API with this handler compiled into a small harness
that replays what the SDK does — send a tool call, strip the response to standard OpenAI
fields, send the tool result back:
missing a thought_signatureAlso verified across a four-turn conversation with three sequential tool calls.