hermes - ✅(Solved) Fix [Bug] _copy_reasoning_content_for_api: cross-provider reasoning promotion leaks stale content to DeepSeek/Kimi [3 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
NousResearch/hermes-agent#15748Fetched 2026-04-26 05:25:20
View on GitHub
Comments
1
Participants
2
Timeline
10
Reactions
0
Author
Participants
Timeline (top)
labeled ×5cross-referenced ×4commented ×1

Root Cause

In _copy_reasoning_content_for_api (run_agent.py), the normalized_reasoning promotion path returns before the DeepSeek/Kimi empty-string guard can execute:

# Current (buggy) ordering in origin/main:
explicit_reasoning = source_msg.get("reasoning_content")
if isinstance(explicit_reasoning, str):        # False when switching from MiniMax
    api_msg["reasoning_content"] = explicit_reasoning
    return

normalized_reasoning = source_msg.get("reasoning")  # "MiniMax chain of thought..."
if isinstance(normalized_reasoning, str) and normalized_reasoning:
    api_msg["reasoning_content"] = normalized_reasoning  # ← LEAKED
    return                                            # ← DeepSeek/Kimi guard never reached

# This guard is unreachable for cross-provider histories
if source_msg.get("tool_calls") and (self._needs_kimi...() or self._needs_deepseek...()):
    api_msg["reasoning_content"] = ""

When the session history contains a tool-call message from MiniMax with reasoning="MiniMax thinking..." and no reasoning_content, switching to DeepSeek causes:

  1. reasoning_content key absent → first check returns False
  2. reasoning key present → promoted to reasoning_content
  3. DeepSeek receives MiniMax's reasoning → HTTP 400

Fix Action

Fix

Reorder the logic so that:

  1. reasoning_content already set → preserve verbatim (including DeepSeek's own "" placeholder written at creation time)
  2. Tool-call turns with neither reasoning_content nor reasoning → inject "" for DeepSeek/Kimi (poisoned history from prior provider)
  3. reasoning present → promote to reasoning_content (healthy same-provider path)

The key addition is not has_reasoning in the guard, which distinguishes "this provider has reasoning to promote" from "a prior provider left reasoning that should not be forwarded".

PR fix notes

PR #15760: Fix DeepSeek reasoning_content propagation

Description (problem / solution / changelog)

This PR fixes two issues with DeepSeek reasoning_content. 1. Extracts reasoning_content from model_extra for ZenMux responses. 2. Adds empty string fallback for DeepSeek to non-tool-call messages.

Changed files

  • run_agent.py (modified, +32/-10)

PR #15761: fix: ensure DeepSeek reasoning_content guard runs after tool_calls are populated

Description (problem / solution / changelog)

Summary

Fixes recurring HTTP 400 error when using DeepSeek thinking mode:

The reasoning_content in the thinking mode must be passed back to the API.

Root Cause

The _build_assistant_message method in run_agent.py had a timing bug: the DeepSeek reasoning_content guard at L7642 checked msg.get("tool_calls") but tool_calls were only added to msg at L7672 — 30 lines later. This rendered the guard permanently dead.

Consequence: DeepSeek tool-call messages were persisted to the session store WITHOUT reasoning_content. On session reload + replay, _copy_reasoning_content_for_api injected an empty-string fallback (""), which DeepSeek"s API rejects with HTTP 400.

Changes

run_agent.py — 2 fixes

  1. Move the DeepSeek guard to AFTER tool_calls are populated (after msg["tool_calls"] = tool_calls). This ensures the guard actually fires when needed, pinning reasoning_content: "" at creation time for tool-call messages that lack raw reasoning from the API response.

  2. Remove empty-string injection from _copy_reasoning_content_for_api. The reply path no longer injects "" for poisoned history replays — the creation-time pin (fix 1) prevents new poison, and empty strings are rejected by DeepSeek anyway.

tests/run_agent/test_deepseek_reasoning_content_echo.py — 4 test updates

Updated tests to expect reasoning_content ABSENCE rather than empty-string injection:

  • test_deepseek_tool_call_poisoned_history_skipped (was _gets_empty_string)
  • test_kimi_path_left_alone (was test_kimi_path_still_works)
  • test_kimi_moonshot_base_url_left_alone (was test_kimi_moonshot_base_url)
  • test_deepseek_custom_base_url_left_alone (was test_deepseek_custom_base_url)

Test Plan

  • All 21 tests in test_deepseek_reasoning_content_echo.py pass
  • All 11 TestBuildAssistantMessage tests in test_run_agent.py pass

Notes

  • Existing sessions already poisoned by the old bug may need /new to reset — the persisted messages still lack reasoning_content. New messages will not be poisoned.
  • The same fix also covers Kimi/Moonshot thinking mode behaviour via the shared _copy_reasoning_content_for_api path.

Refs #15250, #15353

Changed files

  • run_agent.py (modified, +21/-15)
  • tests/run_agent/test_deepseek_reasoning_content_echo.py (modified, +17/-15)

PR #15830: fix(reasoning): promote reasoning field before empty-pad for DeepSeek/Kimi (#15812)

Description (problem / solution / changelog)

Summary

  • _copy_reasoning_content_for_api had a step-ordering bug introduced by PR #15749: the tool-call empty-string injection (old step 2) ran before the reasoning field promotion (old step 3)
  • A tool-call message with a valid reasoning field but no reasoning_content would hit the empty-string path, get reasoning_content="" injected, and return early — the reasoning value was silently discarded
  • Fix: move the reasoningreasoning_content promotion to step 2, before the empty-string fallbacks

The bug

run_agent.py_copy_reasoning_content_for_api (pre-fix):

# Step 1: explicit reasoning_content → preserve ✓
# Step 2: tool_calls + DeepSeek/Kimi → inject ""  ← fires here
#         RETURNS EARLY
# Step 3: reasoning field → promote               ← never reached

A message like {"role": "assistant", "reasoning": "thought trace", "tool_calls": [...]} on a DeepSeek session would return reasoning_content="" instead of reasoning_content="thought trace".

The fix

Reorder steps 2 and 3 so reasoning promotion runs first:

# Step 1: explicit reasoning_content → preserve
# Step 2: reasoning field present → promote to reasoning_content  ← moved up
# Step 3: tool_calls + DeepSeek/Kimi + no reasoning → inject ""
# Step 4: all other DeepSeek/Kimi + no reasoning → inject ""
# Step 5: non-string cleanup

Empty-string injection now only fires when neither reasoning_content nor reasoning is present — the poisoned-history case it was designed for.

Test plan

  • Before: test_deepseek_reasoning_field_promotedAssertionError: assert '' == 'thought trace'
  • After: all 21 tests in test_deepseek_reasoning_content_echo.py pass
  • Regression guard: reverted run_agent.pytest_deepseek_reasoning_field_promoted failed with '' != 'thought trace'; restored → 21 passing
  • Broader suite: tests/run_agent/ — 1048 passed, 1 pre-existing baseline failure (test_inf_stays_string_for_integer_only reproduces on clean origin/main)

Related

  • Fixes #15812
  • Regression introduced by #15749
  • Related: #15748 (same code path)

🤖 Generated with Claude Code

Changed files

  • run_agent.py (modified, +14/-12)

Code Example

# Current (buggy) ordering in origin/main:
explicit_reasoning = source_msg.get("reasoning_content")
if isinstance(explicit_reasoning, str):        # False when switching from MiniMax
    api_msg["reasoning_content"] = explicit_reasoning
    return

normalized_reasoning = source_msg.get("reasoning")  # "MiniMax chain of thought..."
if isinstance(normalized_reasoning, str) and normalized_reasoning:
    api_msg["reasoning_content"] = normalized_reasoning  # ← LEAKED
    return                                            # ← DeepSeek/Kimi guard never reached

# This guard is unreachable for cross-provider histories
if source_msg.get("tool_calls") and (self._needs_kimi...() or self._needs_deepseek...()):
    api_msg["reasoning_content"] = ""
RAW_BUFFERClick to expand / collapse

Bug: Cross-provider reasoning promotion leaks stale content to DeepSeek/Kimi thinking mode

Background

DeepSeek V4 and Kimi/Moonshot thinking modes require reasoning_content="" on every assistant tool-call message. If the field is missing on replay, the API returns HTTP 400:

The reasoning_content in the thinking mode must be passed back to the API.

When a session switches providers mid-conversation (e.g. MiniMax → DeepSeek), the internal reasoning field from the prior provider can leak into the reasoning_content field sent to the new provider, causing HTTP 400.

Root Cause

In _copy_reasoning_content_for_api (run_agent.py), the normalized_reasoning promotion path returns before the DeepSeek/Kimi empty-string guard can execute:

# Current (buggy) ordering in origin/main:
explicit_reasoning = source_msg.get("reasoning_content")
if isinstance(explicit_reasoning, str):        # False when switching from MiniMax
    api_msg["reasoning_content"] = explicit_reasoning
    return

normalized_reasoning = source_msg.get("reasoning")  # "MiniMax chain of thought..."
if isinstance(normalized_reasoning, str) and normalized_reasoning:
    api_msg["reasoning_content"] = normalized_reasoning  # ← LEAKED
    return                                            # ← DeepSeek/Kimi guard never reached

# This guard is unreachable for cross-provider histories
if source_msg.get("tool_calls") and (self._needs_kimi...() or self._needs_deepseek...()):
    api_msg["reasoning_content"] = ""

When the session history contains a tool-call message from MiniMax with reasoning="MiniMax thinking..." and no reasoning_content, switching to DeepSeek causes:

  1. reasoning_content key absent → first check returns False
  2. reasoning key present → promoted to reasoning_content
  3. DeepSeek receives MiniMax's reasoning → HTTP 400

Fix

Reorder the logic so that:

  1. reasoning_content already set → preserve verbatim (including DeepSeek's own "" placeholder written at creation time)
  2. Tool-call turns with neither reasoning_content nor reasoning → inject "" for DeepSeek/Kimi (poisoned history from prior provider)
  3. reasoning present → promote to reasoning_content (healthy same-provider path)

The key addition is not has_reasoning in the guard, which distinguishes "this provider has reasoning to promote" from "a prior provider left reasoning that should not be forwarded".

Affected Scenarios

ScenarioBefore fixAfter fix
DeepSeek tool_call with reasoning_content="" setpreservedpreserved
DeepSeek tool_call with reasoning="DeepSeek reasoning"promotedpromoted
DeepSeek tool_call after MiniMax (MiniMax reasoning present)MiniMax content sent → 400"" injected
DeepSeek tool_call after MiniMax (both fields absent)"" injected"" injected

Related Issues

  • #15250 — DeepSeek V4 Flash Discord session poisoned when tool-call assistant lacks reasoning_content
  • #15213 — HTTP 400 in cron/auxiliary path (same root cause, different trigger)
  • PR #15228 — earlier fix that introduced this ordering issue
  • PR #14973 — extract DeepSeek reasoning_content from model_extra

Test Coverage

All 21 existing tests in tests/run_agent/test_deepseek_reasoning_content_echo.py pass with this fix.

extent analysis

TL;DR

Reorder the logic in _copy_reasoning_content_for_api to prioritize checking for reasoning_content and injecting an empty string when necessary, before promoting reasoning to reasoning_content.

Guidance

  • Verify that the reasoning_content field is being set correctly for each provider, especially when switching between providers like MiniMax and DeepSeek.
  • Check the tool_calls history to ensure that the reasoning_content field is not being overwritten with stale content from a previous provider.
  • Update the _copy_reasoning_content_for_api function to follow the reordered logic:
    1. Preserve reasoning_content if already set.
    2. Inject "" for DeepSeek/Kimi if neither reasoning_content nor reasoning is present.
    3. Promote reasoning to reasoning_content if reasoning is present.
  • Review the test coverage in tests/run_agent/test_deepseek_reasoning_content_echo.py to ensure that all scenarios are properly tested.

Example

def _copy_reasoning_content_for_api(source_msg, api_msg):
    # Check if reasoning_content is already set
    if "reasoning_content" in source_msg:
        api_msg["reasoning_content"] = source_msg["reasoning_content"]
        return

    # Check if tool-call turns have neither reasoning_content nor reasoning
    if source_msg.get("tool_calls") and (self._needs_kimi() or self._needs_deepseek()):
        api_msg["reasoning_content"] = ""
        return

    # Promote reasoning to reasoning_content if present
    normalized_reasoning = source_msg.get("reasoning")
    if isinstance(normalized_reasoning, str) and normalized_reasoning:
        api_msg["reasoning_content"] = normalized_reasoning

Notes

This fix assumes that the reasoning_content

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING