From 7c9f280d65c199b480345033331a54e092d6ecca Mon Sep 17 00:00:00 2001 From: Thomas Marchand Date: Fri, 19 Dec 2025 11:56:42 +0000 Subject: [PATCH] fix: preserve Gemini thought_signature for tool call continuations - Add thought_signature field to ToolCall and FunctionCall structs - Add alias for reasoning_details in OpenRouterMessage - Expand ReasoningContent with format/index fields - Add debug logging for reasoning block tracking Fixes Gemini 3 "Function call is missing a thought_signature" errors --- src/agents/leaf/executor.rs | 24 ++++++++++++++++++++++++ src/llm/mod.rs | 32 ++++++++++++++++++++++++++++---- src/llm/openrouter.rs | 34 ++++++++++++++++++++++++++++++---- 3 files changed, 82 insertions(+), 8 deletions(-) diff --git a/src/agents/leaf/executor.rs b/src/agents/leaf/executor.rs index f3fb294..4234727 100644 --- a/src/agents/leaf/executor.rs +++ b/src/agents/leaf/executor.rs @@ -525,6 +525,30 @@ Use `search_memory` when you encounter a problem you might have solved before or // Add assistant message with tool calls // IMPORTANT: Preserve reasoning blocks for "thinking" models (Gemini 3, etc.) // These contain thought_signature that must be sent back for continuations. + + // Debug: Log if we have reasoning/thought_signature to preserve + if let Some(ref reasoning) = response.reasoning { + let has_sig = reasoning.iter().any(|r| r.thought_signature.is_some()); + tracing::debug!( + "Preserving {} reasoning blocks (has_thought_signature: {})", + reasoning.len(), + has_sig + ); + } + // Also check for thought_signature in tool_calls themselves (Gemini format) + for tc in tool_calls { + let has_tc_sig = tc.thought_signature.is_some(); + let has_fn_sig = tc.function.thought_signature.is_some(); + if has_tc_sig || has_fn_sig { + tracing::debug!( + "Tool call '{}' has thought_signature (tool_call: {}, function: {})", + tc.function.name, + has_tc_sig, + has_fn_sig + ); + } + } + messages.push(ChatMessage { role: Role::Assistant, content: response.content.clone().map(MessageContent::text), diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 50d999b..6172a89 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -88,17 +88,34 @@ pub enum MessageContent { /// the model to resume its chain of thought. /// /// Reference: https://openrouter.ai/docs/use-cases/reasoning-tokens +/// Reference: https://ai.google.dev/gemini-api/docs/thought-signatures #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReasoningContent { - /// The reasoning/thinking content (may be redacted or empty for some models) - #[serde(skip_serializing_if = "Option::is_none")] + /// The reasoning/thinking content (may be redacted or empty for some models). + /// OpenRouter uses both `content` and `text` fields depending on the model. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "text" + )] pub content: Option, - /// Encrypted thought signature for resuming reasoning (required for Gemini) + + /// Encrypted thought signature for resuming reasoning (required for Gemini 3). + /// This MUST be preserved and sent back in subsequent requests for tool call continuations. #[serde(skip_serializing_if = "Option::is_none")] pub thought_signature: Option, - /// Type of reasoning block (typically "thinking") + + /// Type of reasoning block (e.g., "thinking", "reasoning.text") #[serde(rename = "type", skip_serializing_if = "Option::is_none")] pub reasoning_type: Option, + + /// Format of the reasoning content (e.g., "unknown") + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + + /// Index of the reasoning block (for ordered reasoning) + #[serde(skip_serializing_if = "Option::is_none")] + pub index: Option, } impl ReasoningContent { @@ -209,6 +226,9 @@ pub struct ToolCall { #[serde(rename = "type")] pub call_type: String, pub function: FunctionCall, + /// Thought signature for Gemini 3 models (may be at this level instead of function level). + #[serde(skip_serializing_if = "Option::is_none")] + pub thought_signature: Option, } /// Function call details. @@ -218,6 +238,10 @@ pub struct FunctionCall { /// Arguments as a JSON string. May be empty or missing for no-argument functions. #[serde(default)] pub arguments: String, + /// Thought signature for Gemini 3 models. Must be preserved and sent back with tool results. + /// When present, this allows Gemini to resume its chain of thought after a tool call. + #[serde(skip_serializing_if = "Option::is_none")] + pub thought_signature: Option, } /// Tool definition for the LLM. diff --git a/src/llm/openrouter.rs b/src/llm/openrouter.rs index 55f31a2..b06c801 100644 --- a/src/llm/openrouter.rs +++ b/src/llm/openrouter.rs @@ -112,11 +112,36 @@ impl OpenRouterClient { .ok_or_else(|| LlmError::parse_error("No choices in response".to_string()))?; // Log if we received reasoning blocks (for debugging thinking models) - if choice.message.reasoning.is_some() { + if let Some(ref reasoning) = choice.message.reasoning { + let has_thought_sig = reasoning.iter().any(|r| r.thought_signature.is_some()); tracing::debug!( - "Received {} reasoning blocks from model", - choice.message.reasoning.as_ref().map_or(0, |r| r.len()) + "Received {} reasoning blocks from model (has_thought_signature: {})", + reasoning.len(), + has_thought_sig ); + // Log thought_signature details for debugging Gemini issues + for (i, r) in reasoning.iter().enumerate() { + if r.thought_signature.is_some() { + tracing::debug!("Reasoning block {} has thought_signature", i); + } + } + } + + // Log if tool calls have thought_signatures attached (Gemini format) + if let Some(ref tool_calls) = choice.message.tool_calls { + for tc in tool_calls { + // Check both ToolCall level and FunctionCall level + let has_tc_sig = tc.thought_signature.is_some(); + let has_fn_sig = tc.function.thought_signature.is_some(); + if has_tc_sig || has_fn_sig { + tracing::debug!( + "Tool call '{}' has thought_signature (tool_call: {}, function: {})", + tc.function.name, + has_tc_sig, + has_fn_sig + ); + } + } } Ok(ChatResponse { @@ -290,7 +315,8 @@ struct OpenRouterMessage { tool_calls: Option>, /// Reasoning blocks from "thinking" models (Gemini 3, etc.) /// Contains thought_signature that must be preserved for tool call continuations. - #[serde(default)] + /// OpenRouter uses `reasoning` or `reasoning_details` depending on the model/provider. + #[serde(default, alias = "reasoning_details")] reasoning: Option>, }