Skip to content

fix(provider): preserve reasoning_content on assistant messages with tool_calls (DeepSeek) - #9620

Closed
shafqatevo wants to merge 1 commit into
aaif-goose:mainfrom
shafqatevo:fix/deepseek-reasoning-content-v2
Closed

fix(provider): preserve reasoning_content on assistant messages with tool_calls (DeepSeek)#9620
shafqatevo wants to merge 1 commit into
aaif-goose:mainfrom
shafqatevo:fix/deepseek-reasoning-content-v2

Conversation

@shafqatevo

Copy link
Copy Markdown

Summary

Fixes the DeepSeek 400 error: "The reasoning_content in the thinking mode must be passed back to the API" — reported in #9402, #9397, #9200.

When DeepSeek (or similar providers with preserves_thinking: true) streams a response containing tool calls, the reasoning_content field was missing from the assistant message sent back on the next API turn, causing a 400 error.

Root Cause

Two bugs combined:

Bug 1 — openai.rs (format_messages_with_options):
pending_assistant_reasoning was cleared unconditionally after merging into any assistant message — including text-only messages that precede tool-call messages in the same turn. Fix: only clear on non-assistant messages (the true turn boundary).

Bug 2 — agent.rs (agent streaming loop):
When DeepSeek streams a response with tool calls, thinking content arrives in intermediate streaming chunks before the tool-call chunk. The agent stored these as separate thinking-only messages, but created the tool-request message without any thinking content. If any other message (MOIM injection, text content, etc.) appeared between the thinking and tool-request messages, format_messages_with_options had no reasoning to set on the tool-call message.

Fix: accumulate thinking from all intermediate streamed messages in turn_thinking_content and clone it onto tool-request messages.

Testing

  • Unit tests: 100 passed, 2 pre-existing failures (unrelated)
  • Live test: Verified against DeepSeek via OpenCode Go (deepseek-v4-flash) — tool call + tool result round-trip succeeds without 400 error
  • Regression tests: 2 new tests added for the format_messages_with_options fix

Files Changed

  • crates/goose/src/agents/agent.rs — accumulate turn_thinking_content across streamed messages, attach to tool-request messages
  • crates/goose/src/providers/formats/openai.rs — don't clear pending_assistant_reasoning on merge (defense-in-depth) + 2 regression tests

Clean PR replacing #9404 which was closed due to unrelated fork contamination.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cf30fe26fe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/goose/src/agents/agent.rs Outdated
Comment on lines +2075 to +2077
if from_response.is_empty() && !turn_thinking_content.is_empty() {
turn_thinking_content.clone()
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid replaying accumulated thinking as a new message

When a stream emits a thinking-only chunk and later a tool-call chunk with no thinking, that earlier chunk has already been stored by the num_tool_requests == 0 path. Cloning turn_thinking_content here creates a second thinking-only assistant message before the tool request; with the paired openai.rs change that no longer clears pending reasoning between assistant messages, format_messages_with_options concatenates both copies and sends duplicated reasoning_content on the next tool call. This affects the DeepSeek streaming scenario this change targets and can make the echoed reasoning differ from what the provider originally produced (or unnecessarily double the context).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7fcb294. The thinking_content variable now only comes from the current response — no longer falls back to turn_thinking_content. Intermediate Thinking messages were already pushed by the num_tool_requests == 0 path.

@shafqatevo
shafqatevo force-pushed the fix/deepseek-reasoning-content-v2 branch from cf30fe2 to 8a458e1 Compare June 5, 2026 18:03

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8a458e1860

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +404 to +407
if !pending_assistant_reasoning.is_empty() {
reasoning_text =
merge_reasoning_text(&pending_assistant_reasoning, &reasoning_text);
pending_assistant_reasoning.clear();
// Do NOT clear pending_assistant_reasoning here. It is cleared

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Carry merged reasoning forward after text chunks

When a streamed assistant chunk contains both additional Thinking and text before a later tool-call chunk, this merges the old pending prefix with the chunk's reasoning only for the current text message, but leaves pending_assistant_reasoning at the old prefix. The following tool-call-only assistant message then receives only that stale prefix (e.g. Thinking("a"), then Thinking("b") + Text, then ToolRequest formats the tool call with reasoning_content: "a" instead of "ab"), so DeepSeek can still reject or receive incomplete echoed reasoning for streams that interleave reasoning with visible text before tool calls.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7fcb294. After merging pending_assistant_reasoning with the current chunk's reasoning, pending_assistant_reasoning now advances to the merged value (reasoning_text.clone()). This ensures the next assistant message (e.g. tool-call) receives the full reasoning, not a stale prefix.

@shafqatevo
shafqatevo force-pushed the fix/deepseek-reasoning-content-v2 branch from 8a458e1 to 9a237df Compare June 5, 2026 18:12

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9a237dff69

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/goose/src/agents/agent.rs Outdated
Comment on lines +2095 to +2096
if from_response.is_empty() && !turn_thinking_content.is_empty() {
turn_thinking_content.clone()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid cloning streamed thinking into Anthropic tool calls

For Anthropic/Claude thinking streams, response_to_streaming_message emits the signed Thinking block as its own assistant message before yielding the later tool request, and formats/anthropic.rs serializes signed thinking blocks unconditionally. This fallback therefore stores the same thinking once in the original thinking-only assistant message and again inside the tool-request assistant message, so the next Anthropic request replays a duplicated signed thinking block rather than the exact assistant history the provider produced. Fresh evidence beyond the earlier OpenAI-focused duplicate-reasoning comment is the Anthropic streaming/parser path that yields thinking separately and has no de-duplication before formatting.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. The turn_thinking_content fallback on reasoning_content is intentional and necessary for OpenAI-compatible providers (DeepSeek, Kimi) where MOIM injections or other user messages can clear pending_assistant_reasoning between the thinking-only and tool-call messages in the same turn. Without this fallback, the tool-call message loses reasoning_content and DeepSeek returns 400.

For Anthropic, the concern about duplicate thinking blocks is valid but should be handled in formats/anthropic.rs (de-duplication at serialization time), not by removing the fallback which is critical for the OpenAI path. The Anthropic formatter already handles signed thinking blocks differently from the OpenAI formatter, so a formatter-level de-dup check is the right layer for this.

@shafqatevo
shafqatevo force-pushed the fix/deepseek-reasoning-content-v2 branch from 9a237df to 7fcb294 Compare June 5, 2026 18:19

@paulomac1000 paulomac1000 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Verified — fix works

Tested against custom_deepseek + deepseek-v4-flash. The 400 error reasoning_content must be passed back to the API is resolved. Normal multi-turn tool conversations now work. Thank you for the clean PR!


⚠️ Two remaining edge cases (follow-up, not blocking)

Our analysis (goose run -t --resume with extensive logging on v1.37.0) identified two additional code paths where reasoning_content can still be dropped. These are rare edge cases — the PR already fixes the common one — but worth tracking:

1. RedactedThinking silent drop (~openai.rs:210)

When format_messages_with_options() encounters a RedactedThinking variant, it skips it entirely — no reasoning_content is emitted for that assistant message. This can happen when DeepSeek's content filter intervenes mid-thinking.

// Current behavior: RedactedThinking → no reasoning_content emitted at all

2. merge_split_tool_call_messages() strict matching (~openai.rs:290-360)

When multiple tool-calls arrive in separate messages with non-identical reasoning_content (e.g., DeepSeek streams Thinking("a") → ToolCall(shell) → Thinking("b") → ToolCall(read)), the merge function only combines them when reasoning_content matches exactly. Mismatched reasoning can result in dropped content.


Suggestion

Merge this PR as-is (it fixes the primary issue affecting all known reporters: #9397, #9402, #9434, #9333, #9200). Then open a follow-up issue tracking the two edge cases above for a future improvement.

Again — great work on the clean resubmission. Appreciate the effort! 🚀

@shafqatevo shafqatevo left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for testing and the thorough analysis! 🙏

Agreed — merge as-is for the primary fix, and I'll open a follow-up issue for the two edge cases:

  1. RedactedThinking silent drop: When DeepSeek's content filter intervenes mid-thinking and produces RedactedThinking, format_messages_with_options() skips it entirely. Fix: treat RedactedThinking similarly to Thinking — extract whatever partial reasoning is available and include it in reasoning_content.

  2. merge_split strict matching: When DeepSeek streams Thinking("a") → ToolCall → Thinking("b") → ToolCall, the merge function only combines when reasoning_content matches exactly. Mismatched reasoning across tool-call splits results in unmerged (and potentially dropped) content. Fix: use the first message's reasoning as the canonical value for the merged message, or concatenate distinct reasoning blocks.

I'll open the issue now.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fff87012f0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/goose/src/agents/agent.rs Outdated
Comment on lines +2226 to +2229
if from_response.is_empty() && !turn_thinking_content.is_empty() {
turn_thinking_content.clone()
} else {
from_response

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Merge prior thinking when the tool chunk has a suffix

When the final streamed tool-call chunk contains any Thinking of its own, this branch ignores the earlier turn_thinking_content instead of combining it. That still loses reasoning for DeepSeek/Kimi histories where MOIM inserts a user message before the latest assistant tool call: format_messages_with_options clears pending reasoning at that user boundary, so the stored tool-call message only echoes the suffix from from_response rather than the full reasoning accumulated earlier in the turn.

Useful? React with 👍 / 👎.

@DOsinga

DOsinga commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

@shafqatevo can you look at the codex?

@filipkujawa

Copy link
Copy Markdown
Collaborator

Thanks for the work on this

Closing for two reasons:

  1. The branch no longer contains the fix. The current head is a single May 23 commit touching execution/manager.rs and .gitignore; the reviewed reasoning_content commits are gone and the PR is in a conflicting state.
  2. The underlying bug has since been fixed on main by fix(providers): Buffer thinking across stream chunks for DeepSeek/Kimi tool calls #9704 and fix: preserve reasoning_content for DeepSeek thinking mode on multi-turn tool calls #10366. The remaining edge cases (RedactedThinking, split tool-call merging) are tracked in other PR's

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants