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

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions