Carry Gemini thought signatures on the transcript - #219
Merged
mattt merged 2 commits intoSep 5, 2026
Conversation
Gemini attaches a `thought_signature` to each `functionCall` part and rejects a
request whose function calls have lost it:
400 INVALID_ARGUMENT "Function call is missing a thought_signature in
functionCall parts."
`GeminiPart` decoded the signature away, and `Transcript.ToolCall` had nowhere
to keep it, so every request rebuilt from the transcript dropped it — the
follow-up inside the tool loop, and the replay of that tool call on later turns.
Give `Transcript.ToolCall` a `providerMetadata` dictionary for opaque state that
a provider requires back verbatim, and have the Gemini adapter put the signature
there. The field is optional, so transcripts encoded before this change still
decode, and it is marked as an AnyLanguageModel extension rather than something
carried over from Foundation Models.
Other providers need the same shape: Anthropic's `encrypted_content` for server
tool results, and encrypted reasoning items on the OpenAI Responses API.
Collaborator
|
Hi @NoWaY233851. Thank you. This is exactly the shape I was hoping for, and the offline tests are great. On your three questions:
I'll merge as soon as that's in. |
`GeminiLanguageModel.respond` returned `transcriptEntries: ArraySlice(transcript)` — the whole conversation rather than the entries it appended — where the other adapters accumulate a local array. `LanguageModelSession.respond` appends that back into the session transcript, so history doubled on every turn: request `contents` went 1 -> 3 -> 6 -> 14 over three turns. Until thought signatures were echoed, that was a token cost. It now also replays a signed `functionCall` part at a position its signature was never issued for, so the signature fix does not hold past the second turn without this. Collect the tool call and tool output entries in a local `entries` array, as `AnthropicLanguageModel` and the other adapters do, and report that. `transcript` still carries the whole conversation, because each iteration of the tool loop rebuilds the request from it.
Contributor
Author
|
Second commit: Key left |
Collaborator
|
Merging now. Thank you, @NoWaY233851, for the fix and for pinning it with a test that fails without it. |
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.
Fixes #203.
Gemini attaches a
thought_signatureto eachfunctionCallpart and rejects any later request whose function calls have lost it, so tool calling could never complete on thinking models.The signature was dropped twice.
GeminiPartdecoded it away, andTranscript.ToolCallhad nowhere to keep it, so every request rebuilt from the transcript lost it again — the follow-up inside the tool loop, and the replay of that call on later turns.Transcript.ToolCallgainsproviderMetadata: [String: String]?for opaque state a provider needs back verbatim, and the Gemini adapter keeps the signature there. It's documented as an AnyLanguageModel extension, following the- Note: This property is exclusive to AnyLanguageModelconvention already used for the tool execution delegate. The property is optional, so transcripts encoded before this change still decode. Its key staysprivatefor now, per review.On the wire the signature sits on the enclosing part rather than inside
functionCall, soGeminiPartreads and writes it andGeminiFunctionCall.thoughtSignaturestays out ofCodingKeys. Parallel calls carry a signature only on the firstfunctionCallpart; since it's held perToolCalland rebuilt per part, that shape round-trips as issued.The second commit is the
transcriptEntriesfix.respondreportedArraySlice(transcript)at all three exits — the whole conversation rather than the entries it added — andLanguageModelSession.respondappends that back, so history doubled every turn: requestcontentswent 1 → 3 → 6 → 14 across three turns. That capped the signature fix at the second turn, because by the third the same signedfunctionCallpart went out twice in one request. It now collects a localentriesarray like the other adapters, and the same three turns are 1 → 3 → 5 → 7.The tests are offline:
StubURLProtocolrecords request bodies and replays canned responses, so the Gemini tool path is covered withoutGEMINI_API_KEYand the wire types stayprivate.echoesSignatureOnFollowUpRequest— the signature comes back with the function results.keepsSignatureOnLaterTurn— it survives into the next turn of the same session.doesNotReplayHistoryOnLaterTurns— request sizes stay linear over three turns and the tool call is replayed exactly once, still signed. Both of its assertions fail without the second commit.TranscriptTestsfor the new property: omitted from the encoding when nil, round-tripped when set.swift test --skip OllamaLanguageModelpasses (325 tests);swift format lint --strict --recursive .is clean.Signatures on text parts are still dropped; I'll open a separate issue for those as suggested.