Skip to content

Round-trip Gemini 3.x thought_signature for multi-turn tool use #256

Description

@maragubot

Background

PartTypeThought was added to the gai abstraction in #257. The clients/google package now publishes per-client ThinkingLevel constants (Minimal/Low/Medium/High) targeting the Gemini 3.x family. On the response side, genai.Part.Thought=true parts are surfaced as gai.PartTypeThought. On the request side, however, Gemini 3.x enforces a constraint that the current gai.Part shape cannot satisfy.

The Gemini constraint

When Gemini 3.x emits a functionCall part, it also returns a thoughtSignature on that part. The API requires the same signature to be sent back on the next turn alongside the functionCall for the response to be accepted — even when thinking is disabled via ThinkingBudget=0. Without it, the API returns:

Error 400, Message: 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. Additional data, function call `default_api:read_file`,
position 2.

See https://ai.google.dev/gemini-api/docs/thought-signatures for the full constraint.

The genai SDK exposes the signature on genai.Part.ThoughtSignature []byte, but gai.Part does not preserve it. Today, clients/google/chat_complete.go builds outgoing genai.Part values from gai.PartTypeToolCall without carrying any signature, and any multi-turn tool flow against a 3.x model fails on the second turn. The Google client also returns a typed error when gai.PartTypeThought is passed back as message history, mirroring the Anthropic-side behaviour in #250 (with the same span-recording shape).

What a fix would entail

  1. Extend gai.Part to carry opaque provider-specific metadata. Concrete shape to evaluate during implementation:
    • (a) Provider any field on Part set by the originating client and consumed only by the same provider's request builder. Honest about leakage; uses any.
    • (b) Sibling part types per provider gated behind an interface. Avoids any but explodes the type surface.
  2. On the stream-read side, clients/google/chat_complete.go populates the metadata from chunk.Candidates[0].Content.Parts[*].ThoughtSignature for both PartTypeToolCall and PartTypeThought parts.
  3. On the request-build side, when re-emitting a genai.Part for an outgoing gai.PartTypeToolCall (and gai.PartTypeThought, once that round-trips), populate ThoughtSignature from the metadata.
  4. The Anthropic and OpenAI clients can either ignore the new metadata (current behaviour) or carry it through opaquely.

This issue and #250 (Anthropic extended-thinking signature round-trip) have the same shape — both want opaque per-provider metadata on gai.Part. The two should be designed together so we don't end up with two parallel mechanisms.

Why deferred

Same reason as #250: the cleanest abstraction for opaque per-provider metadata is non-trivial. A typed field leaks Gemini specifics into the gai surface; a generic map[string]any is loose. The two cases (Gemini signatures, Anthropic signed thinking blocks) have similar shapes and likely share a fix.

Workaround until fixed

clients/google/chat_complete_test.go defaults newChatCompleter(t) to gemini-2.5-flash (a 2.5 model that does not require thought_signature on tool follow-ups) so existing multi-turn tool tests stay green. The Gemini 3.x mappings are exercised single-turn by the table-driven thinking level matrix subtest of TestChatCompleter_ChatComplete, which does not need to round-trip a signature. Callers targeting Gemini 3.x for tool flows must stay single-turn, or filter gai.PartTypeThought out of history and accept that tool-use multi-turn flows will 400 today.

See docs/decisions.md (entry: "Per-client ThinkingLevel constants (2026-04-29)") for the broader principle that gai does not pre-validate provider constraints, and docs/diary/2026-04-29-per-client-thinking-levels.md for the empirical narrative behind both this issue and #250.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions