From fb98f0ebb8ac45c4cd59b17abc407597eb01239d Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Thu, 5 Mar 2026 15:41:36 +0000 Subject: [PATCH] fix(gemini): strictly enforce alternating roles and improve message merging - Merge all consecutive messages with the same role into a single GeminiContent object. - Ensure the first message is always 'user' by prepending a placeholder if necessary. - Add final check for empty contents to prevent sending malformed requests. - This addresses strict role-sequence requirements in Gemini 2.0/3.0 models. --- src/providers/gemini.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/providers/gemini.rs b/src/providers/gemini.rs index 5d3a8118..54e48c94 100644 --- a/src/providers/gemini.rs +++ b/src/providers/gemini.rs @@ -216,7 +216,7 @@ impl GeminiProvider { let role = match msg.role.as_str() { "assistant" => "model".to_string(), - "tool" => "user".to_string(), // Tool results are technically from the user side in Gemini + "tool" => "user".to_string(), // Tool results are user-side in Gemini _ => "user".to_string(), }; @@ -233,7 +233,6 @@ impl GeminiProvider { }) .unwrap_or_default(); - // Gemini function response MUST have a name. Fallback to tool_call_id if name is missing. let name = msg.name.clone().or_else(|| msg.tool_call_id.clone()).unwrap_or_else(|| "unknown_function".to_string()); let response_value = serde_json::from_str::(&text_content) .unwrap_or_else(|_| serde_json::json!({ "result": text_content })); @@ -250,7 +249,6 @@ impl GeminiProvider { } else if msg.role == "assistant" && msg.tool_calls.is_some() { // Assistant messages with tool_calls if let Some(tool_calls) = &msg.tool_calls { - // Include text content if present for p in &msg.content { if let ContentPart::Text { text } = p { if !text.trim().is_empty() { @@ -316,7 +314,8 @@ impl GeminiProvider { continue; } - // Merge with previous message if role matches + // STRATEGY: Strictly enforce alternating roles. + // If current message has the same role as the last one, merge their parts. if let Some(last_content) = contents.last_mut() { if last_content.role.as_ref() == Some(&role) { last_content.parts.extend(parts); @@ -331,7 +330,6 @@ impl GeminiProvider { } // Gemini requires the first message to be from "user". - // If it starts with "model", we prepend a placeholder user message. if let Some(first) = contents.first() { if first.role.as_deref() == Some("model") { contents.insert(0, GeminiContent { @@ -346,6 +344,12 @@ impl GeminiProvider { } } + // Final check: ensure we don't have empty contents after filtering. + // If the last message was merged or filtered, we might have an empty array. + if contents.is_empty() && system_parts.is_empty() { + return Err(AppError::ProviderError("No valid content parts after filtering".to_string())); + } + let system_instruction = if !system_parts.is_empty() { Some(GeminiContent { parts: system_parts,