From 36305599dfe54e454a1fa0e12e09215de2992fff Mon Sep 17 00:00:00 2001 From: pcace <{ID}+{username}@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:55:29 +0200 Subject: [PATCH 1/5] fix: preserve reasoning_content for DeepSeek thinking mode on multi-turn tool calls Three fixes for the 'reasoning_content in thinking mode must be passed back' 400 error with DeepSeek/Kimi thinking models: 1. RedactedThinking was silently dropped in format_messages_with_options, causing reasoning_content to be missing for signed/redacted thinking. 2. When reasoning arrived across multiple streaming chunks, only the last thinking-only message was used (rposition). Now accumulates ALL prior thinking, even when direct_thinking is non-empty (reasoning on same chunk as tool_calls). 3. When thinking + text arrived in the same streaming chunk, the resulting mixed message (Thinking+Text) was not recognized as a thinking source by the prior-message filter (which required ALL content to be Thinking). Now extracts thinking from ANY prior assistant message. --- .../src/formats/openai.rs | 4 +- crates/goose/src/agents/agent.rs | 72 ++++++++++++++----- 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/crates/goose-provider-types/src/formats/openai.rs b/crates/goose-provider-types/src/formats/openai.rs index 5069f738015d..211914ef486a 100644 --- a/crates/goose-provider-types/src/formats/openai.rs +++ b/crates/goose-provider-types/src/formats/openai.rs @@ -257,8 +257,8 @@ pub fn format_messages_with_options( MessageContent::Thinking(t) => { reasoning_text.push_str(&t.thinking); } - MessageContent::RedactedThinking(_) => { - continue; + MessageContent::RedactedThinking(r) => { + reasoning_text.push_str(&r.data); } MessageContent::SystemNotification(_) => { continue; diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 39a2d888f67a..61e57b702d79 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -2313,27 +2313,63 @@ impl Agent { }) .cloned() .collect(); - // When thinking arrived in an earlier stream chunk it was stored as - // a standalone thinking-only message; reuse that thinking on the - // tool-call messages and drop the standalone so it isn't duplicated. - let response_thinking = if direct_thinking.is_empty() { - let prior = messages_to_add.messages().iter().rposition(|m| { - m.role == response.role - && !m.content.is_empty() - && m.content.iter().all(|c| { - matches!( - c, - MessageContent::Thinking(_) - | MessageContent::RedactedThinking(_) - ) - }) + // When thinking arrived in earlier stream chunks it was stored as + // standalone thinking-only messages; reuse that thinking on the + // tool-call messages and drop the standalone messages so the + // thinking isn't duplicated. + // Always accumulate ALL prior thinking — even when + // direct_thinking is non-empty (reasoning arrived on the same + // chunk as tool_calls) — because otherwise only the last chunk's + // reasoning ends up on split tool-call messages. + // Also extract thinking from mixed (thinking+text) messages, + // not just pure-thinking-only ones. + let mut accumulated_prior: Vec = Vec::new(); + let mut indices_to_remove: Vec = Vec::new(); + for (idx, m) in messages_to_add.messages().iter().enumerate() { + if m.role != response.role || m.content.is_empty() { + continue; + } + let thinking_only = m.content.iter().all(|c| { + matches!( + c, + MessageContent::Thinking(_) + | MessageContent::RedactedThinking(_) + ) }); - match prior { - Some(idx) => messages_to_add.remove(idx).content, - None => Vec::new(), + let has_thinking = m.content.iter().any(|c| { + matches!( + c, + MessageContent::Thinking(_) + | MessageContent::RedactedThinking(_) + ) + }); + if has_thinking { + for c in &m.content { + if matches!( + c, + MessageContent::Thinking(_) + | MessageContent::RedactedThinking(_) + ) { + accumulated_prior.push(c.clone()); + } + } } - } else { + if thinking_only { + indices_to_remove.push(idx); + } + } + // Remove in reverse order to preserve indices + for idx in indices_to_remove.into_iter().rev() { + messages_to_add.remove(idx); + } + let response_thinking = if direct_thinking.is_empty() { + accumulated_prior + } else if accumulated_prior.is_empty() { direct_thinking + } else { + let mut merged = accumulated_prior; + merged.extend(direct_thinking); + merged }; for request in frontend_requests.iter().chain(remaining_requests.iter()) { From db83395e5fbbbcdc81047665ba4c6915b67869aa Mon Sep 17 00:00:00 2001 From: pcace <{ID}+{username}@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:03:16 +0200 Subject: [PATCH 2/5] fix: strip thinking blocks from mixed prior messages to avoid duplicate signed/unsigned reasoning --- crates/goose/src/agents/agent.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 61e57b702d79..1b17184da4c0 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -2325,7 +2325,8 @@ impl Agent { // not just pure-thinking-only ones. let mut accumulated_prior: Vec = Vec::new(); let mut indices_to_remove: Vec = Vec::new(); - for (idx, m) in messages_to_add.messages().iter().enumerate() { + for (idx, m) in messages_to_add.messages_mut().iter_mut().enumerate() + { if m.role != response.role || m.content.is_empty() { continue; } @@ -2356,6 +2357,17 @@ impl Agent { } if thinking_only { indices_to_remove.push(idx); + } else if has_thinking { + // Strip thinking blocks from mixed messages so the same + // signed/unsigned thinking is not duplicated when it is + // carried onto the tool-call request messages below. + m.content.retain(|c| { + !matches!( + c, + MessageContent::Thinking(_) + | MessageContent::RedactedThinking(_) + ) + }); } } // Remove in reverse order to preserve indices From 561d0b7498ee3b1372261cf645ea6978a4ce74c7 Mon Sep 17 00:00:00 2001 From: pcace <{ID}+{username}@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:16:47 +0200 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20revert=20RedactedThinking=20change?= =?UTF-8?q?=20in=20OpenAI=20formatter=20=E2=80=94=20it=20contains=20opaque?= =?UTF-8?q?=20encrypted=20data,=20not=20reasoning=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/goose-provider-types/src/formats/openai.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/goose-provider-types/src/formats/openai.rs b/crates/goose-provider-types/src/formats/openai.rs index 211914ef486a..5069f738015d 100644 --- a/crates/goose-provider-types/src/formats/openai.rs +++ b/crates/goose-provider-types/src/formats/openai.rs @@ -257,8 +257,8 @@ pub fn format_messages_with_options( MessageContent::Thinking(t) => { reasoning_text.push_str(&t.thinking); } - MessageContent::RedactedThinking(r) => { - reasoning_text.push_str(&r.data); + MessageContent::RedactedThinking(_) => { + continue; } MessageContent::SystemNotification(_) => { continue; From c688b6d725238358fc2d3da5b72d8474bc6e5746 Mon Sep 17 00:00:00 2001 From: pcace <{ID}+{username}@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:31:52 +0200 Subject: [PATCH 4/5] fix: skip messages that already contain tool requests when stripping prior thinking chunks --- crates/goose/src/agents/agent.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 1b17184da4c0..3c390b7439c0 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -2357,10 +2357,21 @@ impl Agent { } if thinking_only { indices_to_remove.push(idx); - } else if has_thinking { - // Strip thinking blocks from mixed messages so the same - // signed/unsigned thinking is not duplicated when it is - // carried onto the tool-call request messages below. + } else if has_thinking + && !m.content.iter().any(|c| { + matches!(c, MessageContent::ToolRequest(_)) + }) + { + // Strip thinking blocks from mixed text+thinking + // messages so the same signed/unsigned thinking is not + // duplicated when carried onto the tool-call request + // messages below. Messages that already contain tool + // requests are prior-split request_msg items whose + // thinking was already attached — stripping their + // thinking would leave only the last split message + // with reasoning, violating the signed-thinking + // dedup expectation that the first split message + // retains it. m.content.retain(|c| { !matches!( c, From 1f70054cd2874800a6f47c232adbf7f17a612228 Mon Sep 17 00:00:00 2001 From: pcace <{ID}+{username}@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:46:08 +0200 Subject: [PATCH 5/5] fix: skip prior tool-call messages when accumulating thinking, not only when stripping --- crates/goose/src/agents/agent.rs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 3c390b7439c0..d462df7e0dbe 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -2345,13 +2345,21 @@ impl Agent { ) }); if has_thinking { - for c in &m.content { - if matches!( - c, - MessageContent::Thinking(_) - | MessageContent::RedactedThinking(_) - ) { - accumulated_prior.push(c.clone()); + // Only accumulate thinking from messages that + // have not already been split into tool-call + // request_msg items — prior-split messages + // already carry their own thinking copy. + if !m.content.iter().any(|c| { + matches!(c, MessageContent::ToolRequest(_)) + }) { + for c in &m.content { + if matches!( + c, + MessageContent::Thinking(_) + | MessageContent::RedactedThinking(_) + ) { + accumulated_prior.push(c.clone()); + } } } }