From 3b1411a3aca2964d8a25623a639d7e252f0bb638 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 23 Feb 2026 10:14:19 -0500 Subject: [PATCH] Preserve Gemini thought_signature on function calls Gemini 3 models include a thoughtSignature field on function call parts that must be returned in subsequent conversation turns. Add provider_metadata to ToolCall to carry this through, and extract/emit it in both the non-streaming and streaming Gemini code paths. Co-Authored-By: Claude Opus 4.6 --- crates/llm/src/providers/gemini.rs | 39 +++++++++++++++++++++--------- crates/llm/src/types.rs | 6 +++++ 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/crates/llm/src/providers/gemini.rs b/crates/llm/src/providers/gemini.rs index e0c1f55dc..a3fdcccd0 100644 --- a/crates/llm/src/providers/gemini.rs +++ b/crates/llm/src/providers/gemini.rs @@ -189,11 +189,12 @@ fn parse_part(part: &serde_json::Value) -> Option { .get("args") .cloned() .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); - return Some(ContentPart::ToolCall(ToolCall::new( - uuid::Uuid::new_v4().to_string(), - name, - args, - ))); + let mut tc = ToolCall::new(uuid::Uuid::new_v4().to_string(), name, args); + // Preserve thought_signature for Gemini 3 models (sibling of functionCall in the part) + if let Some(sig) = part.get("thoughtSignature") { + tc.provider_metadata = Some(serde_json::json!({"thoughtSignature": sig})); + } + return Some(ContentPart::ToolCall(tc)); } None } @@ -240,12 +241,23 @@ fn translate_messages(messages: &[&Message]) -> Vec { .iter() .filter_map(|part| match part { ContentPart::Text(text) => Some(serde_json::json!({"text": text})), - ContentPart::ToolCall(tc) => Some(serde_json::json!({ - "functionCall": { - "name": tc.name, - "args": tc.arguments, + ContentPart::ToolCall(tc) => { + let mut part_json = serde_json::json!({ + "functionCall": { + "name": tc.name, + "args": tc.arguments, + } + }); + // Re-attach thought_signature as sibling of functionCall + if let Some(sig) = tc + .provider_metadata + .as_ref() + .and_then(|m| m.get("thoughtSignature")) + { + part_json["thoughtSignature"] = sig.clone(); } - })), + Some(part_json) + } ContentPart::Image(img) => { img.url.as_ref().map_or_else( || { @@ -804,7 +816,12 @@ impl SseStreamState { .get("args") .cloned() .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); - let tool_call = ToolCall::new(uuid::Uuid::new_v4().to_string(), name, args); + let mut tool_call = ToolCall::new(uuid::Uuid::new_v4().to_string(), name, args); + // Preserve thought_signature for Gemini 3 models (sibling of functionCall) + if let Some(sig) = part.get("thoughtSignature") { + tool_call.provider_metadata = + Some(serde_json::json!({"thoughtSignature": sig})); + } // Gemini delivers function calls as complete objects in a single chunk. self.pending_events diff --git a/crates/llm/src/types.rs b/crates/llm/src/types.rs index c47523c65..0705b87ab 100644 --- a/crates/llm/src/types.rs +++ b/crates/llm/src/types.rs @@ -55,6 +55,11 @@ pub struct ToolCall { pub name: String, pub arguments: serde_json::Value, pub raw_arguments: Option, + /// Opaque provider-specific metadata (e.g. Gemini thought_signature). + /// Preserved across round-trips so the provider can include it when + /// sending conversation history back to the API. + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, } impl ToolCall { @@ -68,6 +73,7 @@ impl ToolCall { name: name.into(), arguments, raw_arguments: None, + provider_metadata: None, } } }