fix: preserve reasoning_content for DeepSeek thinking mode on multi-turn tool calls (#10366)

Co-authored-by: pcace <{ID}+{username}@users.noreply.github.com>
This commit is contained in:
Johannes Lee-Frölich
2026-07-13 16:43:19 +02:00
committed by GitHub
parent 0b98172aca
commit 1e03bbb561
+83 -16
View File
@@ -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<MessageContent> = Vec::new();
let mut indices_to_remove: Vec<usize> = 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()) {