diff --git a/crates/goose-provider-types/src/formats/google.rs b/crates/goose-provider-types/src/formats/google.rs index 8d9d5e913a61..1b2660fb6432 100644 --- a/crates/goose-provider-types/src/formats/google.rs +++ b/crates/goose-provider-types/src/formats/google.rs @@ -13,6 +13,7 @@ use uuid::Uuid; use crate::conversation::message::{Message, MessageContent, ProviderMetadata}; use serde_json::{json, Map, Value}; +use std::collections::HashMap; use std::ops::Deref; pub const THOUGHT_SIGNATURE_KEY: &str = "thoughtSignature"; @@ -53,17 +54,26 @@ fn maybe_insert_signature_from_metadata( } } -fn build_function_response_part(name: &str, text: String) -> Map { +fn build_function_response_part( + id: &str, + name: &str, + text: String, + media: Vec, +) -> Map { let mut part = Map::new(); let mut function_response = Map::new(); + function_response.insert("id".to_string(), json!(id)); function_response.insert("name".to_string(), json!(name)); function_response.insert("response".to_string(), json!({"content": {"text": text}})); + if !media.is_empty() { + function_response.insert("parts".to_string(), json!(media)); + } part.insert("functionResponse".to_string(), json!(function_response)); part } /// Convert internal Message format to Google's API message specification -pub fn format_messages(messages: &[Message]) -> Vec { +pub fn format_messages(messages: &[Message], nested_function_response_media: bool) -> Vec { let filtered: Vec<_> = messages .iter() .filter(|m| m.is_agent_visible()) @@ -77,6 +87,19 @@ pub fn format_messages(messages: &[Message]) -> Vec { }) .collect(); + let tool_names: HashMap<_, _> = filtered + .iter() + .flat_map(|message| &message.content) + .filter_map(|content| match content { + MessageContent::ToolRequest(request) => request + .tool_call + .as_ref() + .ok() + .map(|tool_call| (request.id.as_str(), sanitize_function_name(&tool_call.name))), + _ => None, + }) + .collect(); + let active_loop_start_idx = filtered .iter() .enumerate() @@ -109,6 +132,7 @@ pub fn format_messages(messages: &[Message]) -> Vec { MessageContent::ToolRequest(request) => match &request.tool_call { Ok(tool_call) => { let mut function_call_part = Map::new(); + function_call_part.insert("id".to_string(), json!(request.id)); function_call_part.insert( "name".to_string(), json!(sanitize_function_name(&tool_call.name)), @@ -145,15 +169,25 @@ pub fn format_messages(messages: &[Message]) -> Vec { MessageContent::ToolResponse(response) => match &response.tool_result { Ok(result) => { let mut tool_content = Vec::new(); + let mut media = Vec::new(); for content in result.content.iter().map(|c| c.raw.clone()) { match content { RawContent::Image(image) => { - parts.push(json!({ - "inline_data": { - "mime_type": image.mime_type, - "data": image.data, - } - })); + if nested_function_response_media { + media.push(json!({ + "inlineData": { + "mimeType": image.mime_type, + "data": image.data, + } + })); + } else { + parts.push(json!({ + "inline_data": { + "mime_type": image.mime_type, + "data": image.data, + } + })); + } } _ => { tool_content.push(content.no_annotation()); @@ -175,15 +209,28 @@ pub fn format_messages(messages: &[Message]) -> Vec { if text.is_empty() { text = "Tool call is done.".to_string(); } - let mut part = build_function_response_part(&response.id, text); + let name = tool_names + .get(response.id.as_str()) + .map(String::as_str) + .unwrap_or(response.id.as_str()); + let mut part = + build_function_response_part(&response.id, name, text, media); if include_signature { maybe_insert_signature_from_metadata(&mut part, &response.metadata); } parts.push(json!(part)); } Err(e) => { - let mut part = - build_function_response_part(&response.id, format!("Error: {}", e)); + let name = tool_names + .get(response.id.as_str()) + .map(String::as_str) + .unwrap_or(response.id.as_str()); + let mut part = build_function_response_part( + &response.id, + name, + format!("Error: {}", e), + Vec::new(), + ); if include_signature { maybe_insert_signature_from_metadata(&mut part, &response.metadata); } @@ -268,7 +315,11 @@ fn process_response_part_impl( ); None } else if let Some(function_call) = part.get("functionCall") { - let id = Uuid::new_v4().to_string(); + let id = function_call + .get("id") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| Uuid::new_v4().to_string()); let name = function_call["name"].as_str().unwrap_or_default(); if !is_valid_function_name(name) { @@ -512,7 +563,9 @@ struct GenerationConfig { #[derive(Serialize)] #[serde(rename_all = "lowercase")] enum ThinkingLevel { + Minimal, Low, + Medium, High, } @@ -544,6 +597,14 @@ fn get_thinking_config( if model_config.reasoning == Some(false) || model_config.thinking_effort() == Some(ThinkingEffort::Off) { + let model_name = model_config.model_name.to_lowercase(); + if model_name.starts_with("gemini-3.5") || model_name.starts_with("gemini-3.6") { + return Some(ThinkingConfig { + thinking_level: Some(ThinkingLevel::Minimal), + thinking_budget: None, + include_thoughts: false, + }); + } // Gemini 2.5 Flash defaults to dynamic thinking; only an explicit budget // of 0 turns it off. Other families can't be disabled, so leave them unset. if model_config @@ -574,9 +635,9 @@ fn get_thinking_config( return None; } let thinking_level = match effort { - ThinkingEffort::Off | ThinkingEffort::Low | ThinkingEffort::Medium => { - ThinkingLevel::Low - } + ThinkingEffort::Off | ThinkingEffort::Low => ThinkingLevel::Low, + ThinkingEffort::Medium if model_name.starts_with("gemini-3-pro") => ThinkingLevel::Low, + ThinkingEffort::Medium => ThinkingLevel::Medium, ThinkingEffort::High | ThinkingEffort::Max => ThinkingLevel::High, }; @@ -645,9 +706,15 @@ fn create_request_impl( }; let thinking_config = get_thinking_config(model_config, thinking_budget); + let temperature = (!model_config + .model_name + .to_lowercase() + .starts_with("gemini-3")) + .then(|| model_config.temperature.map(|t| t as f64)) + .flatten(); let generation_config = Some(GenerationConfig { - temperature: model_config.temperature.map(|t| t as f64), + temperature, max_output_tokens: Some(model_config.max_output_tokens()), thinking_config, }); @@ -656,7 +723,13 @@ fn create_request_impl( system_instruction: SystemInstruction { parts: [TextPart { text: system }], }, - contents: format_messages(messages), + contents: format_messages( + messages, + model_config + .model_name + .to_lowercase() + .starts_with("gemini-3"), + ), tools: tools_wrapper, generation_config, }; @@ -750,7 +823,7 @@ mod tests { set_up_text_message("Hello", Role::User), set_up_text_message("World", Role::Assistant), ]; - let payload = format_messages(&messages); + let payload = format_messages(&messages, false); assert_eq!(payload.len(), 2); assert_eq!(payload[0]["role"], "user"); assert_eq!(payload[0]["parts"][0]["text"], "Hello"); @@ -775,7 +848,7 @@ mod tests { MessageContent::Image(image.no_annotation()), ], )]; - let payload = format_messages(&messages); + let payload = format_messages(&messages, false); assert_eq!(payload.len(), 1); assert_eq!(payload[0]["role"], "user"); @@ -805,9 +878,10 @@ mod tests { CallToolRequestParams::new("tool_name_2").with_arguments(object(arguments.clone())), ), ]; - let payload = format_messages(&messages); + let payload = format_messages(&messages, false); assert_eq!(payload.len(), 1); assert_eq!(payload[0]["role"], "user"); + assert_eq!(payload[0]["parts"][0]["functionCall"]["id"], "id"); assert_eq!(payload[0]["parts"][0]["functionCall"]["args"], arguments); } @@ -815,19 +889,74 @@ mod tests { fn test_message_to_google_spec_tool_result_message() { let tool_result: Vec = vec![Content::text("Hello")]; let messages = vec![set_up_tool_response_message("response_id", tool_result)]; - let payload = format_messages(&messages); + let payload = format_messages(&messages, false); assert_eq!(payload.len(), 1); assert_eq!(payload[0]["role"], "model"); assert_eq!( payload[0]["parts"][0]["functionResponse"]["name"], "response_id" ); + assert_eq!( + payload[0]["parts"][0]["functionResponse"]["id"], + "response_id" + ); assert_eq!( payload[0]["parts"][0]["functionResponse"]["response"]["content"]["text"], "Hello" ); } + #[test] + fn test_function_response_matches_function_call() { + let messages = vec![ + set_up_tool_request_message("call_123", CallToolRequestParams::new("read_file")), + set_up_tool_response_message("call_123", vec![Content::text("contents")]), + ]; + + let payload = format_messages(&messages, false); + + assert_eq!( + payload[1]["parts"][0]["functionResponse"], + json!({ + "id": "call_123", + "name": "read_file", + "response": {"content": {"text": "contents"}} + }) + ); + } + + #[test] + fn test_image_tool_result_is_nested_in_function_response() { + let messages = vec![ + set_up_tool_request_message("call_123", CallToolRequestParams::new("screenshot")), + set_up_tool_response_message( + "call_123", + vec![ + Content::text("Screenshot captured"), + Content::image("base64encodeddata", "image/png"), + ], + ), + ]; + + let payload = format_messages(&messages, true); + + assert_eq!(payload[1]["parts"].as_array().unwrap().len(), 1); + assert_eq!( + payload[1]["parts"][0]["functionResponse"], + json!({ + "id": "call_123", + "name": "screenshot", + "response": {"content": {"text": "Screenshot captured"}}, + "parts": [{ + "inlineData": { + "mimeType": "image/png", + "data": "base64encodeddata" + } + }] + }) + ); + } + #[test] fn test_message_to_google_spec_tool_result_multiple_texts() { let tool_result: Vec = vec![ @@ -837,13 +966,14 @@ mod tests { ]; let messages = vec![set_up_tool_response_message("response_id", tool_result)]; - let payload = format_messages(&messages); + let payload = format_messages(&messages, false); let expected_payload = vec![json!({ "role": "model", "parts": [ { "functionResponse": { + "id": "response_id", "name": "response_id", "response": { "content": { @@ -962,6 +1092,7 @@ mod tests { "content": { "parts": [{ "functionCall": { + "id": "call_123", "name": "valid_name", "args": { "param": "value" @@ -974,6 +1105,7 @@ mod tests { let message = response_to_message(response).unwrap(); assert_eq!(message.role, Role::Assistant); assert_eq!(message.content.len(), 1); + assert_eq!(message.content[0].as_tool_request().unwrap().id, "call_123"); if let Ok(tool_call) = &message.content[0].as_tool_request().unwrap().tool_call { assert_eq!(tool_call.name, "valid_name"); assert_eq!( @@ -994,13 +1126,14 @@ mod tests { let tool_result: Vec = Vec::new(); let messages = vec![set_up_tool_response_message("response_id", tool_result)]; - let payload = format_messages(&messages); + let payload = format_messages(&messages, false); let expected_payload = vec![json!({ "role": "model", "parts": [ { "functionResponse": { + "id": "response_id", "name": "response_id", "response": { "content": { @@ -1080,8 +1213,10 @@ mod tests { req1.metadata.as_ref(), ); let user_prompt = set_up_text_message("List files", Role::User); - let google_out = - format_messages(&[user_prompt.clone(), native.clone(), tool_response.clone()]); + let google_out = format_messages( + &[user_prompt.clone(), native.clone(), tool_response.clone()], + false, + ); assert_eq!(google_out[1]["parts"][0]["thoughtSignature"], SIG); assert_eq!(google_out[2]["parts"][0]["thoughtSignature"], SIG); @@ -1090,7 +1225,10 @@ mod tests { "thoughtSignature": "sig_456" })])) .unwrap(); - let google_multi = format_messages(&[user_prompt, native, tool_response, second_assistant]); + let google_multi = format_messages( + &[user_prompt, native, tool_response, second_assistant], + false, + ); assert_eq!(google_multi[1]["parts"][0]["thoughtSignature"], SIG); assert_eq!(google_multi[2]["parts"][0]["thoughtSignature"], SIG); assert_eq!(google_multi[3]["parts"][0]["thoughtSignature"], "sig_456"); @@ -1133,7 +1271,7 @@ mod tests { })])) .unwrap(); - let formatted = format_messages(&[user_prompt, thinking_only, reasoning_only]); + let formatted = format_messages(&[user_prompt, thinking_only, reasoning_only], false); assert_eq!(formatted.len(), 1); assert_eq!(formatted[0]["role"], "user"); assert_eq!(formatted[0]["parts"][0]["text"], "hello"); @@ -1147,7 +1285,7 @@ mod tests { })])) .unwrap(); - let formatted = format_messages(&[user_prompt, assistant_tool]); + let formatted = format_messages(&[user_prompt, assistant_tool], false); assert_eq!( formatted[1]["parts"][0][THOUGHT_SIGNATURE_KEY], SYNTHETIC_THOUGHT_SIGNATURE @@ -1446,6 +1584,15 @@ data: [DONE]"#; let config = ModelConfig::new("gemini-2.5-pro").with_thinking_effort(ThinkingEffort::Off); assert!(get_thinking_config(&config, None).is_none()); + + let config = + ModelConfig::new("gemini-3.5-flash-lite").with_thinking_effort(ThinkingEffort::Off); + let thinking_config = get_thinking_config(&config, None).unwrap(); + assert!(matches!( + thinking_config.thinking_level, + Some(ThinkingLevel::Minimal) + )); + assert!(!thinking_config.include_thoughts); } #[test] @@ -1464,6 +1611,22 @@ data: [DONE]"#; assert!(thinking_config.thinking_budget.is_none()); assert!(thinking_config.include_thoughts); + let config = + ModelConfig::new("gemini-3.6-flash").with_thinking_effort(ThinkingEffort::Medium); + let thinking_config = get_thinking_config(&config, None).unwrap(); + assert!(matches!( + thinking_config.thinking_level, + Some(ThinkingLevel::Medium) + )); + + let config = + ModelConfig::new("gemini-3-pro-preview").with_thinking_effort(ThinkingEffort::Medium); + let thinking_config = get_thinking_config(&config, None).unwrap(); + assert!(matches!( + thinking_config.thinking_level, + Some(ThinkingLevel::Low) + )); + // Test 2: Gemini 3 model with high thinking effort let mut params = std::collections::HashMap::new(); params.insert("thinking_effort".to_string(), serde_json::json!("high")); @@ -1515,4 +1678,12 @@ data: [DONE]"#; let result = get_thinking_config(&config, None); assert!(result.is_none()); } + + #[test] + fn test_gemini_3_request_omits_temperature() { + let config = ModelConfig::new("gemini-3.6-flash").with_temperature(Some(0.2)); + let payload = create_request(&config, "system", &[], &[]).unwrap(); + + assert!(payload["generationConfig"].get("temperature").is_none()); + } } diff --git a/crates/goose-providers/src/google.rs b/crates/goose-providers/src/google.rs index 83e29353f0db..16599fd122b5 100644 --- a/crates/goose-providers/src/google.rs +++ b/crates/goose-providers/src/google.rs @@ -26,6 +26,9 @@ pub const GOOGLE_API_HOST: &str = "https://generativelanguage.googleapis.com"; pub const GOOGLE_DEFAULT_MODEL: &str = "gemini-2.5-pro"; pub const GOOGLE_DEFAULT_FAST_MODEL: &str = "gemini-2.5-flash"; pub const GOOGLE_KNOWN_MODELS: &[&str] = &[ + "gemini-3.6-flash", + "gemini-3.5-flash", + "gemini-3.5-flash-lite", // Gemini 3 models "gemini-3-pro-preview", "gemini-3-pro-image-preview", diff --git a/ui/desktop/tests/integration/test_providers_lib.ts b/ui/desktop/tests/integration/test_providers_lib.ts index b00bccc2f520..5da40dbd9379 100644 --- a/ui/desktop/tests/integration/test_providers_lib.ts +++ b/ui/desktop/tests/integration/test_providers_lib.ts @@ -84,7 +84,11 @@ function getProviders(): ProviderConfig[] { }, { provider: 'google', - models: [{ name: 'gemini-2.5-flash', flaky: true }, 'gemini-3.5-flash'], + models: [ + 'gemini-3.5-flash', + 'gemini-3.5-flash-lite', + 'gemini-3.6-flash', + ], available: () => hasEnv('GOOGLE_API_KEY'), }, {