diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 39a2d888f67a..d462df7e0dbe 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -2313,27 +2313,94 @@ 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!( + // 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_mut().iter_mut().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(_) + ) + }); + let has_thinking = m.content.iter().any(|c| { + matches!( + c, + MessageContent::Thinking(_) + | MessageContent::RedactedThinking(_) + ) + }); + if has_thinking { + // 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(_) - ) - }) - }); - match prior { - Some(idx) => messages_to_add.remove(idx).content, - None => Vec::new(), + ) { + accumulated_prior.push(c.clone()); + } + } + } } - } else { + if thinking_only { + indices_to_remove.push(idx); + } 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, + MessageContent::Thinking(_) + | MessageContent::RedactedThinking(_) + ) + }); + } + } + // 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()) {