Skip to content

fix: preserve reasoning_content for DeepSeek V4 thinking mode#611

Closed
octo-patch wants to merge 4 commits intoTauricResearch:mainfrom
octo-patch:fix/issue-599-deepseek-thinking-mode-reasoning-content
Closed

fix: preserve reasoning_content for DeepSeek V4 thinking mode#611
octo-patch wants to merge 4 commits intoTauricResearch:mainfrom
octo-patch:fix/issue-599-deepseek-thinking-mode-reasoning-content

Conversation

@octo-patch
Copy link
Copy Markdown

Fixes #599

Problem

When using DeepSeek V4 models (deepseek-v4-flash, deepseek-v4-pro) with thinking mode enabled (e.g. reasoning_effort: "high"), subsequent API calls fail with:

BadRequestError: 400 - The `reasoning_content` in the thinking mode must be passed back to the API.

DeepSeek's API requires that when a response includes reasoning_content, that field must be echoed back in the assistant message on the next turn. LangChain's current implementation drops this field in both directions:

  • Receiving: _convert_dict_to_message does not capture reasoning_content into AIMessage.additional_kwargs
  • Sending: _convert_message_to_dict does not include reasoning_content when serializing assistant messages

Solution

Override two methods in NormalizedChatOpenAI:

  1. _create_chat_result — after the parent converts the raw response, extracts reasoning_content from each choice's message dict and stores it in AIMessage.additional_kwargs["reasoning_content"].

  2. _get_request_payload — after the parent builds the request payload, injects reasoning_content back into any assistant message dict where it is present in additional_kwargs but missing from the converted dict.

This is a minimal, targeted shim that does not affect other providers. The default DeepSeek V3 model (no thinking mode) is unaffected since reasoning_content is absent from its responses.

Testing

  • Imports verified clean
  • Existing model validation tests pass (tests/test_model_validation.py)
  • Logic manually traced against langchain-openai 0.3.35 source for _create_chat_result and _get_request_payload
octo-patch and others added 3 commits April 23, 2026 10:06
…Research#572)

Conditionally include both the memory content and the surrounding
instruction only when past_memories is non-empty. Prevents agents from
hallucinating past lessons when no memories have been stored yet.

Affected agents: Bull Researcher, Bear Researcher, Research Manager,
Portfolio Manager, Trader.
…truction

Addresses gemini-code-assist review feedback:
- All five files (portfolio_manager, research_manager, bear/bull researchers,
  trader) now call .strip() on past_memory_str before injecting it into prompts,
  removing the trailing '\n\n' artifacts from the construction loop.
- portfolio_manager now also makes the 'past reflections' phrasing in the
  Investment Thesis instruction conditional on past_memories being non-empty,
  so an empty memory section can't trigger hallucinated past reflections.
…auricResearch#599)

DeepSeek V4 models in thinking mode require reasoning_content to be echoed
back in subsequent API calls. LangChain's _convert_dict_to_message drops this
field when parsing responses, and _convert_message_to_dict omits it when
serializing outgoing messages, causing a 400 error.

NormalizedChatOpenAI now overrides two methods:
- _create_chat_result: captures reasoning_content from the raw response and
  stores it in AIMessage.additional_kwargs
- _get_request_payload: injects reasoning_content back into the message dicts
  sent to the API on subsequent turns

This is a client-side shim until langchain-openai adds native support.

Co-Authored-By: Octopus <liyuan851277048@icloud.com>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the prompt generation logic across multiple agent nodes—including the Portfolio Manager, Research Manager, Bear/Bull Researchers, and Trader—to conditionally include past memory reflections, ensuring cleaner prompts when no memories are available. It also updates the NormalizedChatOpenAI client to support DeepSeek V4's thinking mode by preserving and echoing reasoning_content. Review feedback focuses on simplifying loops by removing unused enumeration indices in several files and refining the reasoning_content check to explicitly handle potential empty strings to avoid API errors.

choices = response_dict.get("choices") or []
for gen, choice in zip(result.generations, choices):
reasoning = choice.get("message", {}).get("reasoning_content")
if reasoning and isinstance(gen.message, AIMessage):
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The current check if reasoning will skip empty strings. While DeepSeek usually returns a non-empty string when reasoning is present, the API requirement is to echo the field back if it was provided. Using if reasoning is not None is safer to ensure that even an empty reasoning string (if ever returned) is preserved and echoed back, avoiding potential 400 errors.

Suggested change
if reasoning and isinstance(gen.message, AIMessage):
if reasoning is not None and isinstance(gen.message, AIMessage):
@@ -22,6 +22,17 @@ def portfolio_manager_node(state) -> dict:
for i, rec in enumerate(past_memories, 1):
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The loop index i is defined but never used in the loop body. You can simplify this by iterating directly over the list.

Suggested change
for i, rec in enumerate(past_memories, 1):
for rec in past_memories:
@@ -20,6 +20,12 @@ def research_manager_node(state) -> dict:
for i, rec in enumerate(past_memories, 1):
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The loop index i is defined but never used in the loop body. You can simplify this by iterating directly over the list.

Suggested change
for i, rec in enumerate(past_memories, 1):
for rec in past_memories:
@@ -19,6 +19,17 @@ def bear_node(state) -> dict:
for i, rec in enumerate(past_memories, 1):
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The loop index i is defined but never used in the loop body. You can simplify this by iterating directly over the list.

Suggested change
for i, rec in enumerate(past_memories, 1):
for rec in past_memories:
@@ -19,6 +19,17 @@ def bull_node(state) -> dict:
for i, rec in enumerate(past_memories, 1):
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The loop index i is defined but never used in the loop body. You can simplify this by iterating directly over the list.

Suggested change
for i, rec in enumerate(past_memories, 1):
for rec in past_memories:
@@ -20,8 +20,12 @@ def trader_node(state, name):
if past_memories:
for i, rec in enumerate(past_memories, 1):
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The loop index i is defined but never used in the loop body. You can simplify this by iterating directly over the list.

Suggested change
for i, rec in enumerate(past_memories, 1):
for rec in past_memories:
Gemini review flagged that `if reasoning` would silently drop
reasoning_content when it equals an empty string. DeepSeek's API
requires the field be echoed back verbatim if it was provided, so
checking `is not None` is the safer guard.

Co-Authored-By: Octopus <liyuan851277048@icloud.com>
@octo-patch
Copy link
Copy Markdown
Author

Addressed in 67582de — switched to if reasoning is not None and isinstance(gen.message, AIMessage): so an empty reasoning_content is still echoed back per DeepSeek's API requirement.

The unused i loops in portfolio_manager, research_manager, bear_researcher, bull_researcher, and trader are pre-existing — outside the scope of this fix, leaving them for a separate cleanup.

Yijia-Xiao added a commit that referenced this pull request May 1, 2026
…class

Resolves #599: thinking-mode models require reasoning_content to be
echoed back across turns; multi-turn agent runs failed with HTTP 400.

The fix isolates DeepSeek's quirks (reasoning_content round-trip and
the deepseek-reasoner no-tool_choice limitation) into a subclass so
the general OpenAI-compatible client stays untouched. Adds DeepSeek
V4 Pro/Flash to the catalog. 9 new tests; rationale documented in
the class docstrings.

Design adapted from #600; #611 closed in favour of this approach.
@Yijia-Xiao
Copy link
Copy Markdown
Member

Thanks @octo-patch for the careful contribution! Closing in favour of the design that landed in 7e9e7b8. The reasoning_content propagation idea is the same; we used the alternative implementation because the agent-file changes in this PR overlap with the v0.2.4 memory redesign (the past_memories prompt branches were removed in #579/ebd2e12 and reintroducing them here would regress that work). Appreciate the contribution.

@Yijia-Xiao Yijia-Xiao closed this May 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants