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:
- 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.
- 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:
Description
Gemini 3 models return an opaque, cryptographically-signed
thought_signatureon eachfunctionCallpart 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 with400 INVALID_ARGUMENT.agent_framework_geminipreserves the signature only when the replayedFunctionCallContentstill carries its originaltypes.Partasraw_representation—_convert_message_contentscopies that part. But the harness's tool-approval flow (and any layer that reconstructs a call fromcall_id/name/arguments) produces a freshFunctionCallContentwithout that raw part, so the client emits a bare, signature-lessfunctionCallpart on the next step and the run dies.load_skill(auto-approved viaSkillsProvider) and anyalways_requireMCP 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:
Reproduction
Reproducible in isolation against the live Gemini Developer API — a second turn whose prior
functionCallpart lacksthought_signature(exactly what the harness approval-reconstruction produces):Disabling thinking does not avoid it — verified that
thinking_config=ThinkingConfig(thinking_budget=0)(with or withoutinclude_thoughts=False) still returns the same 400.End-to-end: run a
create_harness_agentoverGeminiChatClient, 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 becomesContent.from_function_call(call_id=..., name=..., arguments=..., raw_representation=part). The storedpartdoes carrythought_signature(confirmed present, ~332 bytes, on the streamingfunctionCallchunk, alongside a stablefunction_call.id).Build (
_chat_client.py,_convert_message_contents, lines ~670–683):So the signature survives only for a content that still holds its original
types.Part. The moment any layer reconstructs the call — the harnessToolApprovalMiddlewarereleasing an auto-approvedload_skill, analways_requireMCP tool resolved via AG-UI, or a history rebuilt from a serialized transcript —raw_representationis no longer thatPart, theelsebranch 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.
FunctionCallContenthas 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 bycall_id:_parse_parts, when afunctionCallpart has athought_signature, store it on the resultingFunctionCallContent— e.g.additional_properties["gemini_thought_signature"](survives content transformations that preserve additional_properties) — keyed to the samecall_idthe client assigns._convert_message_contents, when building thefunctionCallpart, setPart.thought_signaturefrom that stored value whenever the part would otherwise lack one (i.e. theelsebranch, 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. Theagent-framework-ag-uimessage 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
thought_signature(~332 bytes) and a stablefunction_call.idon thefunctionCallpart, so capture-by-call_id+ replay is sound.{call_id: thought_signature}on parse and re-attaching bycall_idwhen the reconstructed part lacks one resolves it. End-to-end ongemini-3.5-flashover AG-UI: a prompt that triggers thealways_requireMicrosoft Learn MCP search, once approved, now completes the continuation step (tool result summarized with citations) with zero streaming errors and clean telemetry (0thought_signature400s). 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-frameworkissue covers the native Gemini client droppingthought_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:
create_react_agent+ Gemini 3 → same 400 on the agent loop