Skip to content

Python: [Bug]: Gemini 3 thought_signature dropped on replayed function calls #6963

Description

@antsok

Description

Gemini 3 models return an opaque, cryptographically-signed thought_signature on each functionCall part and require it echoed back on every later turn that replays that call (Google docs). A request that replays a function call without its signature is rejected with 400 INVALID_ARGUMENT.

agent_framework_gemini preserves the signature only when the replayed FunctionCallContent still carries its original types.Part as raw_representation_convert_message_contents copies that part. But the harness's tool-approval flow (and any layer that reconstructs a call from call_id/name/arguments) produces a fresh FunctionCallContent without that raw part, so the client emits a bare, signature-less functionCall part on the next step and the run dies. load_skill (auto-approved via SkillsProvider) and any always_require MCP tool both take this reconstruction path, so a Gemini 3 harness agent breaks on the second step of essentially any tool loop. Disabling thinking does not help — Gemini 3 requires the signature regardless.

Symptoms

The first model turn (which emits the function call) succeeds and streams; the run dies on the next step, after the tool is approved/executed. User-visible on an AG-UI frontend: "An internal error has occurred while streaming events."

Backend traceback (OpenTelemetry logs), during the harness tool loop:

File ".../agent_framework/_harness/_tool_approval.py", line 445, in _stream
    async for update in context.result:
...
File ".../agent_framework_gemini/_chat_client.py", line 541, in _stream
    async for chunk in await self._genai_client.aio.models.generate_content_stream(...)
google.genai.errors.ClientError: 400 Bad Request. {
  "error": { "code": 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:load_skill` , position 3.
      Please refer to https://ai.google.dev/gemini-api/docs/thought-signatures for more details.",
    "status": "INVALID_ARGUMENT" } }

Reproduction

Reproducible in isolation against the live Gemini Developer API — a second turn whose prior functionCall part lacks thought_signature (exactly what the harness approval-reconstruction produces):

from google import genai
from google.genai import types

client = genai.Client(api_key="<GEMINI_API_KEY>")
weather = types.Tool(function_declarations=[types.FunctionDeclaration(
    name="get_weather", description="Get weather",
    parameters_json_schema={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]})])

contents = [
    types.Content(role="user", parts=[types.Part(text="What's the weather in Paris? Call the tool then answer.")]),
    # Prior model turn replayed WITHOUT the thought_signature Gemini originally attached:
    types.Content(role="model", parts=[types.Part(function_call=types.FunctionCall(name="get_weather", args={"city": "Paris"}))]),
    types.Content(role="user", parts=[types.Part(function_response=types.FunctionResponse(name="get_weather", response={"temp": "20C"}))]),
]

# 400 INVALID_ARGUMENT: "Function call is missing a thought_signature in functionCall parts."
client.models.generate_content(model="gemini-3.5-flash", contents=contents,
    config=types.GenerateContentConfig(tools=[weather]))

Disabling thinking does not avoid it — verified that thinking_config=ThinkingConfig(thinking_budget=0) (with or without include_thoughts=False) still returns the same 400.

End-to-end: run a create_harness_agent over GeminiChatClient, send a prompt that triggers an approval-gated tool (e.g. the Microsoft Learn MCP, approval_mode="always_require"), approve it — the continuation step 400s.

Code Sample

Error Messages / Stack Traces

Package Versions

agent-framework-gemini: 1.0.0a260630, agent-framework-core: 1.10.0

Python Version

Python 3.12

Additional Context

Root cause

The client round-trips the signature only via raw_representation.

  • Parse (_chat_client.py, _parse_parts): a response part becomes
    Content.from_function_call(call_id=..., name=..., arguments=..., raw_representation=part). The stored part does carry thought_signature (confirmed present, ~332 bytes, on the streaming functionCall chunk, alongside a stable function_call.id).

  • Build (_chat_client.py, _convert_message_contents, lines ~670–683):

    raw_part = content.raw_representation
    if isinstance(raw_part, types.Part) and raw_part.function_call is not None:
        parts.append(raw_part.model_copy(update={"function_call": function_call}, deep=True))  # keeps thought_signature
    else:
        parts.append(types.Part(function_call=function_call))                                   # NO thought_signature

So the signature survives only for a content that still holds its original types.Part. The moment any layer reconstructs the call — the harness ToolApprovalMiddleware releasing an auto-approved load_skill, an always_require MCP tool resolved via AG-UI, or a history rebuilt from a serialized transcript — raw_representation is no longer that Part, the else branch fires, and the replayed part has no signature → 400.

This is a structural gap: the signature is treated as incidental raw-response detail, not as first-class per-call state the client must round-trip. FunctionCallContent has no dedicated field for it, so it cannot survive any transformation that doesn't copy the whole provider part.

Proposed fix

Carry the signature independently of raw_representation, keyed by call_id:

  1. Capture on parse: in _parse_parts, when a functionCall part has a thought_signature, store it on the resulting FunctionCallContent — e.g. additional_properties["gemini_thought_signature"] (survives content transformations that preserve additional_properties) — keyed to the same call_id the client assigns.
  2. Replay on build: in _convert_message_contents, when building the functionCall part, set Part.thought_signature from that stored value whenever the part would otherwise lack one (i.e. the else branch, or unconditionally if the raw part carries none).

A client-scoped {call_id: signature} map is a viable minimal implementation (call_ids are unique, so a stale entry can never be mis-applied) and is what the app-side shim uses; storing on the content is more robust because it survives process boundaries.

Caveat for serialized hosts (AG-UI). For hosts that round-trip history through a wire format (AG-UI's MESSAGES_SNAPSHOT, thread snapshots), the signature must also survive serialization — the AG-UI message schema ({role, content, tool_calls:[{id,function}]}) has no field for an opaque per-call signature, so a content-level fix alone won't cover cross-request resume there. The agent-framework-ag-ui message adapters would need to carry it too (e.g. in a provider-metadata field). Worth tracking as a follow-up; the client-level fix already covers the common in-process multi-step tool loop.

Verification

  • Repro: §3 returns the 400 on the signature-less replay; disabling thinking does not change it.
  • Facts confirmed against the live API: the streaming response carries thought_signature (~332 bytes) and a stable function_call.id on the functionCall part, so capture-by-call_id + replay is sound.
  • Fix (app-side shim): capturing {call_id: thought_signature} on parse and re-attaching by call_id when the reconstructed part lacks one resolves it. End-to-end on gemini-3.5-flash over AG-UI: a prompt that triggers the always_require Microsoft Learn MCP search, once approved, now completes the continuation step (tool result summarized with citations) with zero streaming errors and clean telemetry (0 thought_signature 400s). Covered by unit tests asserting capture, replay-by-call_id, no-op on unknown ids, and cache bounding.

Prior art / duplicate check (2026-07-07)

No existing microsoft/agent-framework issue covers the native Gemini client dropping thought_signature. The closest is #2947 ("Preserve reasoning block on OpenRouter") — the analogous concern on the OpenAI-compatible path, not the native client. Microsoft's own products hit the same Gemini requirement on their OpenAI-compatible BYOK path: microsoft/vscode#296713, #318970, copilot-intellij-feedback#1381.

The requirement and fix are corroborated across the ecosystem — notably Google's own agent framework:

Ref Relevance
google/adk-python#3705 Google's own ADK: "fail with missing thought_signature after multiple tool uses" — same multi-step-loop trigger
langchain-ai/langchain-google#1364 create_react_agent + Gemini 3 → same 400 on the agent loop
vercel/ai#10344 Gemini 3 function call missing thought_signature
agno-agi/agno#4165 "Agent not sending thoughtSignature when using Gemini" — same root cause (not echoing it back)
block/goose#5792, continuedev/continue#8785, danny-avila/LibreChat#10566, windmill-labs/windmill#7679, livekit/agents-js#920 Same defect across many agent frameworks — confirms it is a Gemini-3-wide client requirement, not app-specific

Metadata

Metadata

Assignees

Labels

agentsUsage: [Issues, PRs], Target: Single agentpythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow

Type

Fields

No fields configured for Bug.

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions