diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 3cfedeb39f45..71fcb7931359 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1346,8 +1346,9 @@ impl Agent { } } - // Preserve thinking content from the original response + // Preserve thinking/reasoning content from the original response // Gemini (and other thinking models) require thinking to be echoed back + // Kimi/DeepSeek require reasoning_content on assistant tool call messages let thinking_content: Vec = response.content.iter() .filter(|c| matches!(c, MessageContent::Thinking(_))) .cloned() @@ -1361,10 +1362,25 @@ impl Agent { messages_to_add.push(thinking_msg); } + // Collect reasoning content to attach to tool request messages + let reasoning_content: Vec = response.content.iter() + .filter(|c| matches!(c, MessageContent::Reasoning(_))) + .cloned() + .collect(); + for (idx, request) in frontend_requests.iter().chain(remaining_requests.iter()).enumerate() { if request.tool_call.is_ok() { - let request_msg = Message::assistant() - .with_id(format!("msg_{}", Uuid::new_v4())) + let mut request_msg = Message::assistant() + .with_id(format!("msg_{}", Uuid::new_v4())); + + // Attach reasoning content to EVERY split tool request message. + // Providers like Kimi require reasoning_content on all assistant + // messages with tool_calls when thinking mode is enabled. + for rc in &reasoning_content { + request_msg = request_msg.with_content(rc.clone()); + } + + request_msg = request_msg .with_tool_request_with_metadata( request.id.clone(), request.tool_call.clone(), diff --git a/crates/goose/src/agents/platform_extensions/apps.rs b/crates/goose/src/agents/platform_extensions/apps.rs index e68c3ec8767d..46ea1cb94226 100644 --- a/crates/goose/src/agents/platform_extensions/apps.rs +++ b/crates/goose/src/agents/platform_extensions/apps.rs @@ -290,8 +290,17 @@ impl AppsManagerClient { let messages = vec![Message::user().with_text(&user_prompt)]; let tools = vec![Self::create_app_content_tool()]; + let mut model_config = provider.get_model_config(); + model_config.max_tokens = Some(16384); + let (response, _usage) = provider - .complete(session_id, &system_prompt, &messages, &tools) + .complete_with_model( + Some(session_id), + &model_config, + &system_prompt, + &messages, + &tools, + ) .await .map_err(|e| format!("LLM call failed: {}", e))?; @@ -321,8 +330,17 @@ impl AppsManagerClient { let messages = vec![Message::user().with_text(&user_prompt)]; let tools = vec![Self::update_app_content_tool()]; + let mut model_config = provider.get_model_config(); + model_config.max_tokens = Some(16384); + let (response, _usage) = provider - .complete(session_id, &system_prompt, &messages, &tools) + .complete_with_model( + Some(session_id), + &model_config, + &system_prompt, + &messages, + &tools, + ) .await .map_err(|e| format!("LLM call failed: {}", e))?; diff --git a/crates/goose/src/providers/formats/openai.rs b/crates/goose/src/providers/formats/openai.rs index 111bd660ce68..ae5c45950d89 100644 --- a/crates/goose/src/providers/formats/openai.rs +++ b/crates/goose/src/providers/formats/openai.rs @@ -82,7 +82,7 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec< let mut output = Vec::new(); let mut content_array = Vec::new(); let mut text_array = Vec::new(); - let mut reasoning_text: Option = None; + let mut reasoning_text = String::new(); for content in &message.content { match content { @@ -116,7 +116,7 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec< continue; } MessageContent::Reasoning(r) => { - reasoning_text = Some(r.text.clone()); + reasoning_text.push_str(&r.text); } MessageContent::ToolRequest(request) => match &request.tool_call { Ok(tool_call) => { @@ -278,15 +278,11 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec< converted["content"] = json!(null); } - // DeepSeek requires reasoning_content field when tool_calls are present - // Set it to the captured reasoning text, or empty string if not present - if converted.get("tool_calls").is_some() { - let reasoning = reasoning_text.unwrap_or_default(); - converted["reasoning_content"] = json!(reasoning); - } else if let Some(reasoning) = reasoning_text { - if !reasoning.is_empty() { - converted["reasoning_content"] = json!(reasoning); - } + // Include reasoning_content only when non-empty. + // Kimi rejects empty reasoning_content (""), so we must omit it entirely + // when there's no reasoning to send. + if !reasoning_text.is_empty() { + converted["reasoning_content"] = json!(reasoning_text); } if converted.get("content").is_some() || converted.get("tool_calls").is_some() { @@ -542,6 +538,7 @@ where use futures::StreamExt; let mut accumulated_reasoning: Vec = Vec::new(); + let mut accumulated_reasoning_content = String::new(); 'outer: while let Some(response) = stream.next().await { if response.as_ref().is_ok_and(|s| s == "data: [DONE]") { @@ -562,6 +559,9 @@ where if let Some(details) = &chunk.choices[0].delta.reasoning_details { accumulated_reasoning.extend(details.iter().cloned()); } + if let Some(rc) = &chunk.choices[0].delta.reasoning_content { + accumulated_reasoning_content.push_str(rc); + } } let mut usage = extract_usage_with_output_tokens(&chunk); @@ -602,6 +602,9 @@ where if let Some(details) = &tool_chunk.choices[0].delta.reasoning_details { accumulated_reasoning.extend(details.iter().cloned()); } + if let Some(rc) = &tool_chunk.choices[0].delta.reasoning_content { + accumulated_reasoning_content.push_str(rc); + } if let Some(delta_tool_calls) = &tool_chunk.choices[0].delta.tool_calls { for delta_call in delta_tool_calls { if let Some(index) = delta_call.index { @@ -642,6 +645,10 @@ where }; let mut contents = Vec::new(); + if !accumulated_reasoning_content.is_empty() { + contents.push(MessageContent::reasoning(&accumulated_reasoning_content)); + accumulated_reasoning_content.clear(); + } let mut sorted_indices: Vec<_> = tool_call_data.keys().cloned().collect(); sorted_indices.sort(); diff --git a/crates/goose/src/providers/gemini_cli.rs b/crates/goose/src/providers/gemini_cli.rs index 2a7aeac8f1ec..82f9b8082583 100644 --- a/crates/goose/src/providers/gemini_cli.rs +++ b/crates/goose/src/providers/gemini_cli.rs @@ -221,10 +221,18 @@ impl GeminiCliProvider { fn parse_stream_json_response(events: &[Value]) -> Result<(Message, Usage), ProviderError> { let mut all_text_content = Vec::new(); + let mut all_thinking_content = Vec::new(); let mut usage = Usage::default(); for parsed in events { match parsed.get("type").and_then(|t| t.as_str()) { + Some("thinking") => { + if let Some(content) = parsed.get("content").and_then(|c| c.as_str()) { + if !content.is_empty() { + all_thinking_content.push(content.to_string()); + } + } + } Some("message") => { if parsed.get("role").and_then(|r| r.as_str()) == Some("assistant") { if let Some(content) = parsed.get("content").and_then(|c| c.as_str()) { @@ -253,11 +261,16 @@ impl GeminiCliProvider { )); } - let message = Message::new( - Role::Assistant, - chrono::Utc::now().timestamp(), - vec![MessageContent::text(combined_text)], - ); + let mut content = Vec::new(); + + let combined_thinking = all_thinking_content.join(""); + if !combined_thinking.is_empty() { + content.push(MessageContent::thinking(combined_thinking, String::new())); + } + + content.push(MessageContent::text(combined_text)); + + let message = Message::new(Role::Assistant, chrono::Utc::now().timestamp(), content); Ok((message, usage)) } @@ -395,6 +408,44 @@ mod tests { assert!(GeminiCliProvider::parse_stream_json_response(&empty).is_err()); } + #[test] + fn test_parse_thinking_blocks() { + let events = vec![ + json!({"type":"init","session_id":"abc","model":"gemini-2.5-pro"}), + json!({"type":"thinking","content":"Let me reason about this...","delta":true}), + json!({"type":"thinking","content":" Step 1: analyze the problem.","delta":true}), + json!({"type":"message","role":"assistant","content":"Here is the answer.","delta":true}), + json!({"type":"result","status":"success","stats":{"input_tokens":30,"output_tokens":15,"total_tokens":45}}), + ]; + let (message, usage) = GeminiCliProvider::parse_stream_json_response(&events).unwrap(); + assert_eq!(message.role, Role::Assistant); + + // Should have thinking content followed by text content + assert_eq!(message.content.len(), 2); + let thinking = message.content[0] + .as_thinking() + .expect("first content should be thinking"); + assert_eq!( + thinking.thinking, + "Let me reason about this... Step 1: analyze the problem." + ); + assert_eq!(message.as_concat_text(), "Here is the answer."); + assert_eq!(usage.input_tokens, Some(30)); + assert_eq!(usage.output_tokens, Some(15)); + } + + #[test] + fn test_parse_no_thinking_blocks() { + // When there's no thinking, message should only have text content + let events = vec![ + json!({"type":"message","role":"assistant","content":"Direct answer.","delta":true}), + json!({"type":"result","status":"success","stats":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}), + ]; + let (message, _usage) = GeminiCliProvider::parse_stream_json_response(&events).unwrap(); + assert_eq!(message.content.len(), 1); + assert_eq!(message.as_concat_text(), "Direct answer."); + } + #[test] fn test_build_prompt_first_and_resume() { let provider = make_provider(); diff --git a/crates/goose/tests/providers.rs b/crates/goose/tests/providers.rs index bf515c6a31a1..68e81de4bccc 100644 --- a/crates/goose/tests/providers.rs +++ b/crates/goose/tests/providers.rs @@ -179,15 +179,17 @@ impl ProviderTester { .complete(session_id, "You are a helpful assistant.", &[message], &[]) .await?; - assert_eq!( - response.content.len(), - 1, - "Expected single content item in response" + assert!( + !response.content.is_empty(), + "Expected at least one content item in response" ); assert!( - matches!(response.content[0], MessageContent::Text(_)), - "Expected text response" + response + .content + .iter() + .any(|c| matches!(c, MessageContent::Text(_))), + "Expected at least one text content item in response" ); println!( diff --git a/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx b/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx index 688d86cfc767..9a48b396e949 100644 --- a/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx +++ b/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx @@ -105,8 +105,15 @@ export default function ProviderConfigurationModal({ const toSubmit = Object.fromEntries( Object.entries(configValues) - .filter(([_k, entry]) => !!entry.value) - .map(([k, entry]) => [k, entry.value || '']) + .filter( + ([_k, entry]) => + !!entry.value || + (entry.serverValue != null && typeof entry.serverValue === 'string') + ) + .map(([k, entry]) => [ + k, + entry.value ?? (typeof entry.serverValue === 'string' ? entry.serverValue : ''), + ]) ); try {