reasoning_content handling bugs and fixes across providers

NousResearch/hermes-agent · cluster 13848 · agent

Data as of

5 open items. Fix merged in #14018. Check it covers your case.

The short answer

4open issues
1open PR
61items grouped
Known?
Yes. 61 related items grouped here, 22 issues already closed.
Fix event
A fix was merged to main in PR #14018. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #14018, #15407, #15478, #16500, #17510, #18045, #22798, #26648. That is separate from the fix event.
Still open
4 issues and 1 PR.

This cluster concerns bugs and proposed fixes around preserving, sanitizing, and correctly replaying reasoning_content (thinking blocks) in multi-turn sessions for DeepSeek and Kimi models. Issues span HTTP 400 errors from missing or malformed reasoning_content on tool-call and assistant turns, with fixes targeting extraction, persistence, and provider-specific sanitization.

Open issues4

  • Issue#17992DeepSeek V4 /anthropic: signed thinking blocks stripped, causing HTTP 400
  • Issue#24443MiMo reasoning models may fail in Hermes because reasoning_content is not preserved in chat history
  • Issue#29607Third-party relay (PackyAPI) + DeepSeek: thinking blocks stripped causing HTTP 400
  • Issue#58117Thinking blocks cause blank text responses in CLI mode + infinite heartbeat loops

Open PRs1

  • PR#43947Fix/deepsfix(deepseek): omitting tool_choice auto/any and preserving signed thinking blocks for /anthropic endpointeek anthropic compat

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #14018 fix(kimi): preserve reasoning_content in Anthropic adapter for /coding endpoint (salvage #13897)

    merged to main

    files: agent/anthropic_adapter.py, scripts/release.py

    Show the diff
    • agent/anthropic_adapter.py
      @@ -1083,6 +1083,31 @@ def convert_messages_to_anthropic(
                           "name": fn.get("name", ""),
                           "input": parsed_args,
                       })
      +            # Kimi's /coding endpoint (Anthropic protocol) requires assistant
      +            # tool-call messages to carry reasoning_content when thinking is
      +            # enabled server-side.  Preserve it as a thinking block so Kimi
      +            # can validate the message history.  See hermes-agent#13848.
      +            #
      +            # Accept empty string "" — _copy_reasoning_content_for_api()
      +            # injects "" as a tier-3 fallback for Kimi tool-call messages
      +            # that had no reasoning.  Kimi requires the field to exist, even
      +            # if empty.
      +            #
      +            # Prepend (not append): Anthropic protocol requires thinking
      +            # blocks before text and tool_use blocks.
      +            #
      +            # Guard: only add when reasoning_details didn't already contribute
      +            # thinking blocks.  On native Anthropic, reasoning_details produces
      +            # signed thinking blocks — adding another unsigned one from
      +            # reasoning_content would create a duplicate (same text) that gets
      +            # downgraded to a spurious text block on the last assistant message.
      +            reasoning_content = m.get("reasoning_content")
      +            _already_has_thinking = any(
      +                isinstance(b, dict) and b.get("type") in ("thinking", "redacted_thinking")
      +                for b in blocks
      +            )
      +            if isinstance(reasoning_content, str) and not _already_has_thinking:
      +                blocks.insert(0, {"type": "thinking", "thinking": reasoning_content})
                   # Anthropic rejects empty assistant content
                   effective = blocks or content
                   if not effective or effective == "":
      @@ -1238,6 +1263,7 @@ def convert_messages_to_anthropic(
           #    cache markers can interfere with signature validation.
           _THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
           _is_third_party = _is_third_party_anthropic_endpoint(base_url)
      +    _is_kimi = _is_kimi_coding_endpoint(base_url)
       
           last_assistant_idx = None
           for i in range(len(result) - 1, -1, -1):
      @@ -1249,7 +1275,25 @@ def convert_messages_to_anthropic(
               if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
                   continue
       
      -        if _is_third_party or idx != last_assistant_idx:
      +        if _is_kimi:
      +            # Kimi's /coding endpoint enables thinking server-side and
      +            # requires unsigned thinking blocks on replayed assistant
      +            # tool-call messages.  Strip signed Anthropic blocks (Kimi
      +            # can't validate signatures) but preserve the unsigned ones
      +            # we synthesised from reasoning_content above.
      +            new_content = []
      +            for b in m["content"]:
      +                if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
      +                    new_content.append(b)
      +                    continue
      +                if b.get("signature") or b.get("data"):
      +                    # Anthropic-signed block — Kimi can't validate, strip
      +                    continue
      +                # Unsigned thinking (synthesised from reasoning_content) —
      +                # keep it: Kimi needs it for message-history validation.
      +                new_content.append(b)
      +            m["content"] = new_content or [{"type": "text", "text": "(empty)"}]
      +        elif _is_third_party or idx != last_assistant_idx:
                   # Third-party endpoint: strip ALL thinking blocks from every
                   # assistant message — signatures are Anthropic-proprietary.
                   # Direct Anthropic: strip from non-latest assistant messages only.
    • scripts/release.py
      @@ -95,6 +95,8 @@
           "[redacted:email]": "sgaofen",
           "[redacted:email]": "nocoo",
           "[redacted:email]": "n-WN",
      +    "[redacted:email]": "hiddenpuppy",
      +    "[redacted:email]": "HiddenPuppy",
           "[redacted:email]": "keyuyuan",
           "[redacted:email]": "Only-Code-A",
           "[redacted:email]": "TroyMitchell911",
  • #15407 fix: DeepSeek V4 thinking mode reasoning_content echo on tool-call messages

    merged to main

    files: run_agent.py, scripts/release.py, tests/run_agent/test_deepseek_reasoning_content_echo.py

    Show the diff
    • run_agent.py
      @@ -7625,6 +7625,12 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
                   raw_reasoning_content = getattr(assistant_message, "reasoning_content", None)
                   if raw_reasoning_content is not None:
                       msg["reasoning_content"] = _sanitize_surrogates(raw_reasoning_content)
      +            elif msg.get("tool_calls") and self._needs_deepseek_tool_reasoning():
      +                # DeepSeek thinking mode requires reasoning_content on every
      +                # assistant tool-call message. Without it, replaying the
      +                # persisted message causes HTTP 400. Include empty string
      +                # as a defensive compatibility fallback (refs #15250).
      +                msg["reasoning_content"] = ""
       
               if hasattr(assistant_message, 'reasoning_details') and assistant_message.reasoning_details:
                   # Pass reasoning_details back unmodified so providers (OpenRouter,
      @@ -7700,6 +7706,35 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
       
               return msg
       
      +    def _needs_kimi_tool_reasoning(self) -> bool:
      +        """Return True when the current provider is Kimi / Moonshot thinking mode.
      +
      +        Kimi ``/coding`` and Moonshot thinking mode both require
      +        ``reasoning_content`` on every assistant tool-call message; omitting
      +        it causes the next replay to fail with HTTP 400.
      +        """
      +        return (
      +            self.provider in {"kimi-coding", "kimi-coding-cn"}
      +            or base_url_host_matches(self.base_url, "api.kimi.com")
      +            or base_url_host_matches(self.base_url, "moonshot.ai")
      +            or base_url_host_matches(self.base_url, "moonshot.cn")
      +        )
      +
      +    def _needs_deepseek_tool_reasoning(self) -> bool:
      +        """Return True when the current provider is DeepSeek thinking mode.
      +
      +        DeepSeek V4 thinking mode requires ``reasoning_content`` on every
      +        assistant tool-call turn; omitting it causes HTTP 400 when the
      +        message is replayed in a subsequent API request (#15250).
      +        """
      +        provider = (self.provider or "").lower()
      +        model = (self.model or "").lower()
      +        return (
      +            provider == "deepseek"
      +            or "deepseek" in model
      +            or base_url_host_matches(self.base_url, "api.deepseek.com")
      +        )
      +
           def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> None:
               """Copy provider-facing reasoning fields onto an API replay message."""
               if source_msg.get("role") != "assistant":
      @@ -7715,13 +7750,14 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No
                   api_msg["reasoning_content"] = normalized_reasoning
                   return
       
      -        kimi_requires_reasoning = (
      -            self.provider in {"kimi-coding", "kimi-coding-cn"}
      -            or base_url_host_matches(self.base_url, "api.kimi.com")
      -            or base_url_host_matches(self.base_url, "moonshot.ai")
      -            or base_url_host_matches(self.base_url, "moonshot.cn")
      -        )
      -        if kimi_requires_reasoning and source_msg.get("tool_calls"):
      +        # Providers that require an echoed reasoning_content on every
      +        # assistant tool-call turn. Detection logic lives in the per-provider
      +        # helpers so both the creation path (_build_assistant_message) and
      +        # this replay path stay in sync.
      +        if source_msg.get("tool_calls") and (
      +            self._needs_kimi_tool_reasoning()
      +            or self._needs_deepseek_tool_reasoning()
      +        ):
                   api_msg["reasoning_content"] = ""
       
           @staticmethod
      @@ -9095,6 +9131,7 @@ def _handle_max_iterations(self, messages: list, api_call_count: int) -> str:
                   api_messages = []
                   for msg in messages:
                       api_msg = msg.copy()
      +                self._copy_reasoning_content_for_api(msg, api_msg)
                       for internal_field in ("reasoning", "finish_reason", "_thinking_prefill"):
                           api_msg.pop(internal_field, None)
                       if _needs_sanitize:
    • scripts/release.py
      @@ -499,6 +499,7 @@
           "[redacted:email]": "zhangxicen",
           "[redacted:email]": "teknium1",
           "[redacted:email]": "teknium1",
      +    "[redacted:email]": "chen1749144759",
       }
       
       
    • tests/run_agent/test_deepseek_reasoning_content_echo.py
      @@ -0,0 +1,213 @@
      +"""Regression test: DeepSeek V4 thinking mode reasoning_content echo.
      +
      +DeepSeek V4-flash / V4-pro thinking mode requires ``reasoning_content`` on
      +every assistant message that carries ``tool_calls``. When a persisted
      +session replays an assistant tool-call turn that was recorded without the
      +field, DeepSeek rejects the next request with HTTP 400::
      +
      +    The reasoning_content in the thinking mode must be passed back to the API.
      +
      +Fix covers three paths:
      +
      +1. ``_build_assistant_message`` — new tool-call messages without raw
      +   reasoning_content get ``""`` pinned at creation time so nothing gets
      +   persisted poisoned.
      +2. ``_copy_reasoning_content_for_api`` — already-poisoned history replays
      +   with ``reasoning_content=""`` injected defensively.
      +3. Detection covers three signals: ``provider == "deepseek"``,
      +   ``"deepseek" in model``, and ``api.deepseek.com`` host match. The third
      +   catches custom-provider setups pointing at DeepSeek.
      +
      +Refs #15250 / #15353.
      +"""
      +
      +from __future__ import annotations
      +
      +import pytest
      +
      +from run_agent import AIAgent
      +
      +
      +def _make_agent(provider: str = "", model: str = "", base_url: str = "") -> AIAgent:
      +    agent = object.__new__(AIAgent)
      +    agent.provider = provider
      +    agent.model = model
      +    agent.base_url = base_url
      +    return agent
      +
      +
      +class TestNeedsDeepSeekToolReasoning:
      +    """_needs_deepseek_tool_reasoning() recognises all three detection signals."""
      +
      +    def test_provider_deepseek(self) -> None:
      +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
      +        assert agent._needs_deepseek_tool_reasoning() is True
      +
      +    def test_model_substring(self) -> None:
      +        # Custom provider pointing at DeepSeek with provider='custom'
      +        agent = _make_agent(provider="custom", model="deepseek-v4-pro")
      +        assert agent._needs_deepseek_tool_reasoning() is True
      +
      +    def test_base_url_host(self) -> None:
      +        agent = _make_agent(
      +            provider="custom",
      +            model="some-aliased-name",
      +            base_url="https://api.deepseek.com/v1",
      +        )
      +        assert agent._needs_deepseek_tool_reasoning() is True
      +
      +    def test_provider_case_insensitive(self) -> None:
      +        agent = _make_agent(provider="DeepSeek", model="")
      +        assert agent._needs_deepseek_tool_reasoning() is True
      +
      +    def test_non_deepseek_provider(self) -> None:
      +        agent = _make_agent(
      +            provider="openrouter",
      +            model="anthropic/claude-sonnet-4.6",
      +            base_url="https://openrouter.ai/api/v1",
      +        )
      +        assert agent._needs_deepseek_tool_reasoning() is False
      +
      +    def test_empty_everything(self) -> None:
      +        agent = _make_agent()
      +        assert agent._needs_deepseek_tool_reasoning() is False
      +
      +
      +class TestCopyReasoningContentForApi:
      +    """_copy_reasoning_content_for_api pads reasoning_content for DeepSeek tool-calls."""
      +
      +    def test_deepseek_tool_call_poisoned_history_gets_empty_string(self) -> None:
      +        """Already-poisoned history (no reasoning_content, no reasoning) gets ''."""
      +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
      +        source = {
      +            "role": "assistant",
      +            "content": "",
      +            "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}],
      +        }
      +        api_msg: dict = {}
      +        agent._copy_reasoning_content_for_api(source, api_msg)
      +        assert api_msg.get("reasoning_content") == ""
      +
      +    def test_deepseek_assistant_no_tool_call_left_alone(self) -> None:
      +        """Plain assistant turns without tool_calls don't get padded."""
      +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
      +        source = {"role": "assistant", "content": "hello"}
      +        api_msg: dict = {}
      +        agent._copy_reasoning_content_for_api(source, api_msg)
      +        assert "reasoning_content" not in api_msg
      +
      +    def test_deepseek_explicit_reasoning_content_preserved(self) -> None:
      +        """When reasoning_content is already set, it's copied verbatim."""
      +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
      +        source = {
      +            "role": "assistant",
      +            "reasoning_content": "<think>real chain of thought</think>",
      +            "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}],
      +        }
      +        api_msg: dict = {}
      +        agent._copy_reasoning_content_for_api(source, api_msg)
      +        assert api_msg["reasoning_content"] == "<think>real chain of thought</think>"
      +
      +    def test_deepseek_reasoning_field_promoted(self) -> None:
      +        """When only 'reasoning' is set, it gets promoted to reasoning_content."""
      +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
      +        source = {
      +            "role": "assistant",
      +            "reasoning": "thought trace",
      +            "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}],
      +        }
      +        api_msg: dict = {}
      +        agent._copy_reasoning_content_for_api(source, api_msg)
      +        assert api_msg["reasoning_content"] == "thought trace"
      +
      +    def test_kimi_path_still_works(self) -> None:
      +        """Existing Kimi detection still pads reasoning_content."""
      +        agent = _make_agent(provider="kimi-coding", model="kimi-k2.5")
      +        source = {
      +            "role": "assistant",
      +            "content": "",
      +            "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}],
      +        }
      +        api_msg: dict = {}
      +        agent._copy_reasoning_content_for_api(source, api_msg)
      +        assert api_msg.get("reasoning_content") == ""
      +
      +    def test_kimi_moonshot_base_url(self) -> None:
      +        agent = _make_agent(
      +            provider="custom", model="kimi-k2", base_url="https://api.moonshot.ai/v1"
      +        )
      +        source = {
      +            "role": "assistant",
      +            "content": "",
      +            "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}],
      +        }
      +        api_msg: dict = {}
      +        agent._copy_reasoning_content_for_api(source, api_msg)
      +        assert api_msg.get("reasoning_content") == ""
      +
      +    def test_non_thinking_provider_not_padded(self) -> None:
      +        """Providers that don't require the echo are untouched."""
      +        agent = _make_agent(
      +            provider="openrouter",
      +            model="anthropic/claude-sonnet-4.6",
      +            base_url="https://openrouter.ai/api/v1",
      +        )
      +        source = {
      +            "role": "assistant",
      +            "content": "",
      +            "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}],
      +        }
      +        api_msg: dict = {}
      +        agent._copy_reasoning_content_for_api(source, api_msg)
      +        assert "reasoning_content" not in api_msg
      +
      +    def test_deepseek_custom_base_url(self) -> None:
      +        """Custom provider pointing at api.deepseek.com is detected via host."""
      +        agent = _make_agent(
      +            provider="custom",
      +            model="whatever",
      +            base_url="https://api.deepseek.com/v1",
      +        )
      +        source = {
      +            "role": "assistant",
      +            "content": "",
      +            "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}],
      +        }
      +        api_msg: dict = {}
      +        agent._copy_reasoning_content_for_api(source, api_msg)
      +        assert api_msg.get("reasoning_content") == ""
      +
      +    def test_non_assistant_role_ignored(self) -> None:
      +        """User/tool messages are left alone."""
      +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
      +        source = {"role": "user", "content": "hi"}
      +        api_msg: dict = {}
      +        agent._copy_reasoning_content_for_api(source, api_msg)
      +        assert "reasoning_content" not in api_msg
      +
      +
      +class TestNeedsKimiToolReasoning:
      +    """The extracted _needs_kimi_tool_reasoning() helper keeps Kimi behavior intact."""
      +
      +    @pytest.mark.parametrize(
      +        "provider,base_url",
      +        [
      +            ("kimi-coding", ""),
      +            ("kimi-coding-cn", ""),
      +            ("custom", "https://api.kimi.com/v1"),
      +            ("custom", "https://api.moonshot.ai/v1"),
      +            ("custom", "https://api.moonshot.cn/v1"),
      +        ],
      +    )
      +    def test_kimi_signals(self, provider: str, base_url: str) -> None:
      +        agent = _make_agent(provider=provider, model="kimi-k2", base_url=base_url)
      +        assert agent._needs_kimi_tool_reasoning() is True
      +
      +    def test_non_kimi_provider(self) -> None:
      +        agent = _make_agent(
      +            provider="openrouter",
      +            model="moonshotai/kimi-k2",
      +            base_url="https://openrouter.ai/api/v1",
      +        )
      +        # model name contains 'moonshot' but host is openrouter — should be False
      +        assert agent._needs_kimi_tool_reasoning() is False
  • #15478 fix: DeepSeek/Kimi thinking mode requires reasoning_content on ALL assistant messages

    merged to main

    files: run_agent.py, tests/run_agent/test_deepseek_reasoning_content_echo.py

    Show the diff
    • run_agent.py
      @@ -7785,7 +7785,17 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No
                   api_msg["reasoning_content"] = normalized_reasoning
                   return
       
      -        # 4. reasoning_content was present but not a string (e.g. None after
      +        # 4. DeepSeek / Kimi thinking mode: all assistant messages need
      +        # reasoning_content. Inject "" to satisfy the provider's requirement
      +        # when no explicit reasoning content is present.
      +        if (
      +            self._needs_kimi_tool_reasoning()
      +            or self._needs_deepseek_tool_reasoning()
      +        ):
      +            api_msg["reasoning_content"] = ""
      +            return
      +
      +        # 5. reasoning_content was present but not a string (e.g. None after
               # context compaction).  Don't pass null to the API.
               api_msg.pop("reasoning_content", None)
       
    • tests/run_agent/test_deepseek_reasoning_content_echo.py
      @@ -88,13 +88,13 @@ def test_deepseek_tool_call_poisoned_history_gets_empty_string(self) -> None:
               agent._copy_reasoning_content_for_api(source, api_msg)
               assert api_msg.get("reasoning_content") == ""
       
      -    def test_deepseek_assistant_no_tool_call_left_alone(self) -> None:
      -        """Plain assistant turns without tool_calls don't get padded."""
      +    def test_deepseek_assistant_no_tool_call_gets_padded(self) -> None:
      +        """DeepSeek thinking mode pads ALL assistant turns, even without tool_calls."""
               agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
               source = {"role": "assistant", "content": "hello"}
               api_msg: dict = {}
               agent._copy_reasoning_content_for_api(source, api_msg)
      -        assert "reasoning_content" not in api_msg
      +        assert api_msg.get("reasoning_content") == ""
       
           def test_deepseek_explicit_reasoning_content_preserved(self) -> None:
               """When reasoning_content is already set, it's copied verbatim."""
  • #16500 fix(agent): block cross-provider reasoning leak to DeepSeek/Kimi (#15748)

    merged to main

    files: run_agent.py, tests/run_agent/test_deepseek_reasoning_content_echo.py

  • #17510 fix(anthropic): preserve DeepSeek /anthropic thinking blocks on replay (#16748)

    merged to main

    files: agent/anthropic_adapter.py, tests/agent/test_deepseek_anthropic_thinking.py

    Show the diff
    • agent/anthropic_adapter.py
      @@ -420,6 +420,33 @@ def _is_kimi_family_endpoint(base_url: str | None, model: str | None = None) ->
           return False
       
       
      +def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool:
      +    """Return True for DeepSeek's Anthropic-compatible endpoint.
      +
      +    DeepSeek's ``/anthropic`` route speaks the Anthropic Messages protocol
      +    but, when thinking mode is enabled, requires the ``thinking`` blocks
      +    from prior assistant turns to round-trip on subsequent requests — the
      +    generic third-party path strips them and triggers HTTP 400::
      +
      +        The content[].thinking in the thinking mode must be passed back
      +        to the API.
      +
      +    Per DeepSeek's published compatibility matrix the blocks are unsigned
      +    (no Anthropic-proprietary signature, no ``redacted_thinking`` support),
      +    so this endpoint is handled with the same strip-signed / keep-unsigned
      +    policy used for Kimi's ``/coding`` endpoint.  The match is pinned to
      +    the ``/anthropic`` path so the OpenAI-compatible ``api.deepseek.com``
      +    base URL (which never reaches this adapter) is not misclassified.
      +    See hermes-agent#16748.
      +    """
      +    if not base_url_host_matches(base_url or "", "api.deepseek.com"):
      +        return False
      +    normalized = _normalize_base_url_text(base_url)
      +    if not normalized:
      +        return False
      +    return "/anthropic" in normalized.rstrip("/").lower()
      +
      +
       def _requires_bearer_auth(base_url: str | None) -> bool:
           """Return True for Anthropic-compatible providers that require Bearer auth.
       
      @@ -1569,7 +1596,16 @@ def convert_messages_to_anthropic(
           #    cache markers can interfere with signature validation.
           _THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
           _is_third_party = _is_third_party_anthropic_endpoint(base_url)
      -    _is_kimi = _is_kimi_family_endpoint(base_url, model)
      +    # Kimi /coding and DeepSeek /anthropic share a contract: both speak the
      +    # Anthropic Messages protocol upstream but require that thinking blocks
      +    # synthesised from reasoning_content round-trip on subsequent turns when
      +    # thinking is enabled.  Signed Anthropic blocks still have to be stripped
      +    # (neither endpoint can validate Anthropic's signatures); unsigned blocks
      +    # are preserved.  See hermes-agent#13848 (Kimi) and #16748 (DeepSeek).
      +    _preserve_unsigned_thinking = (
      +        _is_kimi_family_endpoint(base_url, model)
      +        or _is_deepseek_anthropic_endpoint(base_url)
      +    )
       
           last_assistant_idx = None
           for i in range(len(result) - 1, -1, -1):
      @@ -1581,22 +1617,22 @@ def convert_messages_to_anthropic(
               if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
                   continue
       
      -        if _is_kimi:
      -            # Kimi's /coding endpoint enables thinking server-side and
      -            # requires unsigned thinking blocks on replayed assistant
      -            # tool-call messages.  Strip signed Anthropic blocks (Kimi
      -            # can't validate signatures) but preserve the unsigned ones
      -            # we synthesised from reasoning_content above.
      +        if _preserve_unsigned_thinking:
      +            # Kimi's /coding and DeepSeek's /anthropic endpoints both enable
      +            # thinking server-side and require unsigned thinking blocks on
      +            # replayed assistant tool-call messages.  Strip signed Anthropic
      +            # blocks (neither upstream can validate Anthropic signatures) but
      +            # preserve the unsigned ones we synthesised from reasoning_content.
                   new_content = []
                   for b in m["content"]:
                       if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
                           new_content.append(b)
                           continue
                       if b.get("signature") or b.get("data"):
      -                    # Anthropic-signed block — Kimi can't validate, strip
      +                    # Anthropic-signed block — upstream can't validate, strip
                           continue
                       # Unsigned thinking (synthesised from reasoning_content) —
      -                # keep it: Kimi needs it for message-history validation.
      +                # keep it: the upstream needs it for message-history validation.
                       new_content.append(b)
                   m["content"] = new_content or [{"type": "text", "text": "(empty)"}]
               elif _is_third_party or idx != last_assistant_idx:
    • tests/agent/test_deepseek_anthropic_thinking.py
      @@ -0,0 +1,242 @@
      +"""Regression guard: preserve thinking blocks on DeepSeek's /anthropic endpoint.
      +
      +DeepSeek's ``api.deepseek.com/anthropic`` route speaks the Anthropic Messages
      +protocol but, when thinking mode is enabled, requires ``thinking`` blocks from
      +prior assistant turns to round-trip on subsequent requests.  The generic
      +third-party path strips them (signatures are Anthropic-proprietary and other
      +proxies cannot validate them), so without a DeepSeek-specific carve-out the
      +next tool-call turn fails with HTTP 400::
      +
      +    The content[].thinking in the thinking mode must be passed back to the
      +    API.
      +
      +DeepSeek's compatibility matrix lists ``thinking`` as supported but
      +``redacted_thinking`` and ``cache_control`` on thinking blocks as not
      +supported.  Handling is the same as Kimi's ``/coding`` endpoint: strip
      +Anthropic-signed blocks (DeepSeek can't validate them) but preserve unsigned
      +blocks that Hermes synthesises from ``reasoning_content``.
      +
      +See hermes-agent#16748.
      +"""
      +
      +from __future__ import annotations
      +
      +import pytest
      +
      +
      +class TestDeepSeekAnthropicPreservesThinking:
      +    """convert_messages_to_anthropic must replay DeepSeek thinking blocks."""
      +
      +    @pytest.mark.parametrize(
      +        "base_url",
      +        [
      +            "https://api.deepseek.com/anthropic",
      +            "https://api.deepseek.com/anthropic/",
      +            "https://api.deepseek.com/anthropic/v1",
      +            "https://API.DeepSeek.com/anthropic",
      +        ],
      +    )
      +    def test_unsigned_thinking_block_survives_replay(self, base_url: str) -> None:
      +        """Unsigned thinking (synthesised from reasoning_content) must be preserved."""
      +        from agent.anthropic_adapter import convert_messages_to_anthropic
      +
      +        messages = [
      +            {"role": "user", "content": "hi"},
      +            {
      +                "role": "assistant",
      +                "reasoning_content": "planning the tool call",
      +                "tool_calls": [
      +                    {
      +                        "id": "call_1",
      +                        "type": "function",
      +                        "function": {"name": "skill_view", "arguments": "{}"},
      +                    }
      +                ],
      +            },
      +            {"role": "tool", "tool_call_id": "call_1", "content": "ok"},
      +        ]
      +        _system, converted = convert_messages_to_anthropic(
      +            messages, base_url=base_url
      +        )
      +
      +        assistant_msg = next(m for m in converted if m["role"] == "assistant")
      +        thinking_blocks = [
      +            b for b in assistant_msg["content"]
      +            if isinstance(b, dict) and b.get("type") == "thinking"
      +        ]
      +        assert len(thinking_blocks) == 1, (
      +            f"DeepSeek /anthropic ({base_url}) must preserve unsigned thinking "
      +            "blocks synthesised from reasoning_content — upstream rejects "
      +            "replayed tool-call messages without them."
      +        )
      +        assert thinking_blocks[0]["thinking"] == "planning the tool call"
      +        # Synthesised block — never has a signature
      +        assert "signature" not in thinking_blocks[0]
      +
      +    def test_unsigned_thinking_preserved_on_non_latest_assistant_turn(self) -> None:
      +        """DeepSeek validates history across every prior assistant turn, not just last."""
      +        from agent.anthropic_adapter import convert_messages_to_anthropic
      +
      +        messages = [
      +            {"role": "user", "content": "q1"},
      +            {
      +                "role": "assistant",
      +                "reasoning_content": "r1",
      +                "tool_calls": [
      +                    {
      +                        "id": "call_1",
      +                        "type": "function",
      +                        "function": {"name": "f", "arguments": "{}"},
      +                    }
      +                ],
      +            },
      +            {"role": "tool", "tool_call_id": "call_1", "content": "ok"},
      +            {"role": "user", "content": "q2"},
      +            {
      +                "role": "assistant",
      +                "reasoning_content": "r2",
      +                "tool_calls": [
      +                    {
      +                        "id": "call_2",
      +                        "type": "function",
      +                        "function": {"name": "f", "arguments": "{}"},
      +                    }
      +                ],
      +            },
      +            {"role": "tool", "tool_call_id": "call_2", "content": "ok"},
      +        ]
      +        _system, converted = convert_messages_to_anthropic(
      +            messages, base_url="https://api.deepseek.com/anthropic"
      +        )
      +
      +        assistants = [m for m in converted if m["role"] == "assistant"]
      +        assert len(assistants) == 2
      +        for assistant, expected in zip(assistants, ("r1", "r2")):
      +            thinking = [
      +                b for b in assistant["content"]
      +                if isinstance(b, dict) and b.get("type") == "thinking"
      +            ]
      +            assert len(thinking) == 1
      +            assert thinking[0]["thinking"] == expected
      +
      +    def test_signed_anthropic_thinking_block_is_stripped(self) -> None:
      +        """Anthropic-signed blocks (that leaked through) must still be stripped.
      +
      +        DeepSeek issues its own signatures and cannot validate Anthropic's —
      +        the strip-signed / keep-unsigned split matches the Kimi policy.
      +        """
      +        from agent.anthropic_adapter import convert_messages_to_anthropic
      +
      +        messages = [
      +            {"role": "user", "content": "hi"},
      +            {
      +                "role": "assistant",
      +                "content": [
      +                    {
      +                        "type": "thinking",
      +                        "thinking": "anthropic-signed payload",
      +                        "signature": "anthropic-sig-xyz",
      +                    },
      +                    {"type": "text", "text": "hello"},
      +                ],
      +            },
      +            {"role": "user", "content": "again"},
      +        ]
      +        _system, converted = convert_messages_to_anthropic(
      +            messages, base_url="https://api.deepseek.com/anthropic"
      +        )
      +
      +        assistant_msg = next(m for m in converted if m["role"] == "assistant")
      +        thinking_blocks = [
      +            b for b in assistant_msg["content"]
      +            if isinstance(b, dict) and b.get("type") == "thinking"
      +        ]
      +        assert thinking_blocks == [], (
      +            "Signed Anthropic thinking blocks must be stripped on DeepSeek — "
      +            "DeepSeek cannot validate Anthropic-proprietary signatures."
      +        )
      +
      +    def test_cache_control_stripped_from_thinking_block(self) -> None:
      +        """cache_control must still be stripped even when the block is preserved.
      +
      +        DeepSeek's compatibility matrix lists cache_control on thinking blocks
      +        as ignored — cache markers interfere with signature validation on
      +        upstreams that do check them, so Hermes strips them everywhere.
      +        """
      +        from agent.anthropic_adapter import convert_messages_to_anthropic
      +
      +        messages = [
      +            {"role": "user", "content": "hi"},
      +            {
      +                "role": "assistant",
      +                "reasoning_content": "r1",
      +                "tool_calls": [
      +                    {
      +                        "id": "call_1",
      +                        "type": "function",
      +                        "function": {"name": "f", "arguments": "{}"},
      +                    }
      +                ],
      +            },
      +            {"role": "tool", "tool_call_id": "call_1", "content": "ok"},
      +        ]
      +        # Inject cache_control on the synthesised thinking block after-the-fact
      +        # by running conversion once, mutating, then re-running would be
      +        # indirect.  Instead check the simpler invariant: no thinking block in
      +        # the converted output carries cache_control.
      +        _system, converted = convert_messages_to_anthropic(
      +            messages, base_url="https://api.deepseek.com/anthropic"
      +        )
      +        for m in converted:
      +            if not isinstance(m.get("content"), list):
      +                continue
      +            for b in m["content"]:
      +                if isinstance(b, dict) and b.get("type") in ("thinking", "redacted_thinking"):
      +                    assert "cache_control" not in b
      +
      +    def test_openai_compat_deepseek_base_is_not_matched(self) -> None:
      +        """The OpenAI-compatible ``api.deepseek.com`` base must NOT trigger the
      +        DeepSeek /anthropic branch — it never reaches this adapter, but the
      +        detector should still fail closed so an accidental misuse doesn't
      +        quietly send signed Anthropic blocks to an OpenAI endpoint.
      +        """
      +        from agent.anthropic_adapter import _is_deepseek_anthropic_endpoint
      +
      +        assert _is_deepseek_anthropic_endpoint("https://api.deepseek.com") is False
      +        assert _is_deepseek_anthropic_endpoint("https://api.deepseek.com/v1") is False
      +        assert _is_deepseek_anthropic_endpoint("https://api.deepseek.com/anthropic") is True
      +        assert _is_deepseek_anthropic_endpoint("https://api.deepseek.com/anthropic/v1") is True
      +
      +    def test_non_deepseek_third_party_still_strips_all_thinking(self) -> None:
      +        """MiniMax and other third-party Anthropic endpoints must keep the
      +        generic strip-all behaviour (they reject unsigned blocks outright).
      +        """
      +        from agent.anthropic_adapter import convert_messages_to_anthropic
      +
      +        messages = [
      +            {"role": "user", "content": "hi"},
      +            {
      +                "role": "assistant",
      +                "reasoning_content": "r1",
      +                "tool_calls": [
      +                    {
      +                        "id": "call_1",
      +                        "type": "function",
      +                        "function": {"name": "f", "arguments": "{}"},
      +                    }
      +                ],
      +            },
      +            {"role": "tool", "tool_call_id": "call_1", "content": "ok"},
      +        ]
      +        _system, converted = convert_messages_to_anthropic(
      +            messages, base_url="https://api.minimax.io/anthropic"
      +        )
      +        assistant_msg = next(m for m in converted if m["role"] == "assistant")
      +        thinking_blocks = [
      +            b for b in assistant_msg["content"]
      +            if isinstance(b, dict) and b.get("type") == "thinking"
      +        ]
      +        assert thinking_blocks == [], (
      +            "Non-DeepSeek third-party endpoints must keep the generic "
      +            "strip-all-thinking behaviour — unsigned blocks get rejected."
      +        )
  • #18045 fix(agent): preserve reasoning_content replay on DeepSeek v4 + Kimi/Moonshot thinking

    merged to main

    files: agent/transports/chat_completions.py, run_agent.py, scripts/release.py, tests/agent/transports/test_chat_completions.py, tests/run_agent/test_deepseek_reasoning_content_echo.py, tests/run_agent/test_deepseek_v4_thinking_live.py

    Show the diff
    • agent/transports/chat_completions.py
      @@ -477,9 +477,13 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
               # so keep them apart in provider_data rather than merging.
               reasoning = getattr(msg, "reasoning", None)
               reasoning_content = getattr(msg, "reasoning_content", None)
      +        if reasoning_content is None and hasattr(msg, "model_extra"):
      +            model_extra = getattr(msg, "model_extra", None) or {}
      +            if isinstance(model_extra, dict) and "reasoning_content" in model_extra:
      +                reasoning_content = model_extra["reasoning_content"]
       
               provider_data: Dict[str, Any] = {}
      -        if reasoning_content:
      +        if reasoning_content is not None:
                   provider_data["reasoning_content"] = reasoning_content
               rd = getattr(msg, "reasoning_details", None)
               if rd:
    • run_agent.py
      @@ -8501,6 +8501,7 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
               Handles reasoning extraction, reasoning_details, and optional tool_calls
               so both the tool-call path and the final-response path share one builder.
               """
      +        assistant_tool_calls = getattr(assistant_message, "tool_calls", None)
               reasoning_text = self._extract_reasoning(assistant_message)
               _from_structured = bool(reasoning_text)
       
      @@ -8560,16 +8561,22 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
                   "finish_reason": finish_reason,
               }
       
      -        if hasattr(assistant_message, "reasoning_content"):
      -            raw_reasoning_content = getattr(assistant_message, "reasoning_content", None)
      -            if raw_reasoning_content is not None:
      -                msg["reasoning_content"] = _sanitize_surrogates(raw_reasoning_content)
      -            elif msg.get("tool_calls") and self._needs_deepseek_tool_reasoning():
      -                # DeepSeek thinking mode requires reasoning_content on every
      -                # assistant tool-call message. Without it, replaying the
      -                # persisted message causes HTTP 400. Include empty string
      -                # as a defensive compatibility fallback (refs #15250).
      -                msg["reasoning_content"] = ""
      +        raw_reasoning_content = getattr(assistant_message, "reasoning_content", None)
      +        if raw_reasoning_content is None and hasattr(assistant_message, "model_extra"):
      +            model_extra = getattr(assistant_message, "model_extra", None) or {}
      +            if isinstance(model_extra, dict) and "reasoning_content" in model_extra:
      +                raw_reasoning_content = model_extra["reasoning_content"]
      +        if raw_reasoning_content is not None:
      +            msg["reasoning_content"] = _sanitize_surrogates(raw_reasoning_content)
      +        elif assistant_tool_calls and self._needs_thinking_reasoning_pad():
      +            # DeepSeek v4 thinking mode and Kimi / Moonshot thinking mode
      +            # both require reasoning_content on every assistant tool-call
      +            # message. Without it, replaying the persisted message causes
      +            # HTTP 400 ("The reasoning_content in the thinking mode must
      +            # be passed back to the API"). Include streamed reasoning
      +            # text when captured; otherwise pad with empty string.
      +            # Refs #15250, #17400.
      +            msg["reasoning_content"] = reasoning_text or ""
       
               # Additive fallback (refs #16844, #16884). Streaming-only providers
               # (glm, MiniMax, gpt-5.x via aigw, Anthropic via openai-compat shims)
      @@ -8626,9 +8633,9 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
               if codex_message_items:
                   msg["codex_message_items"] = codex_message_items
       
      -        if assistant_message.tool_calls:
      +        if assistant_tool_calls:
                   tool_calls = []
      -            for tool_call in assistant_message.tool_calls:
      +            for tool_call in assistant_tool_calls:
                       raw_id = getattr(tool_call, "id", None)
                       call_id = getattr(tool_call, "call_id", None)
                       if not isinstance(call_id, str) or not call_id.strip():
      @@ -8677,6 +8684,18 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
       
               return msg
       
      +    def _needs_thinking_reasoning_pad(self) -> bool:
      +        """Return True when the active provider enforces reasoning_content echo-back.
      +
      +        DeepSeek v4 thinking and Kimi / Moonshot thinking both reject replays
      +        of assistant tool-call messages that omit ``reasoning_content`` (refs
      +        #15250, #17400).
      +        """
      +        return (
      +            self._needs_deepseek_tool_reasoning()
      +            or self._needs_kimi_tool_reasoning()
      +        )
      +
           def _needs_kimi_tool_reasoning(self) -> bool:
               """Return True when the current provider is Kimi / Moonshot thinking mode.
       
      @@ -8719,20 +8738,17 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No
                   api_msg["reasoning_content"] = existing
                   return
       
      -        needs_thinking_pad = (
      -            self._needs_kimi_tool_reasoning()
      -            or self._needs_deepseek_tool_reasoning()
      -        )
      +        needs_thinking_pad = self._needs_thinking_reasoning_pad()
       
               # 2. Cross-provider poisoned history (#15748): on DeepSeek/Kimi,
               # if the source turn has tool_calls AND a 'reasoning' field but no
               # 'reasoning_content' key, the 'reasoning' text was written by a
               # prior provider (e.g. MiniMax) — DeepSeek's own _build_assistant_message
      -        # always pins reasoning_content="" at creation time for tool-call turns,
      -        # so the shape (reasoning set, reasoning_content absent, tool_calls
      -        # present) is unreachable from same-provider DeepSeek history. Inject
      -        # "" to satisfy the API without leaking another provider's chain of
      -        # thought to DeepSeek/Kimi.
      +        # pins reasoning_content at creation time for tool-call turns, so the
      +        # shape (reasoning set, reasoning_content absent, tool_calls present)
      +        # is unreachable from same-provider DeepSeek history after this fix.
      +        # Inject "" to satisfy the API without leaking another provider's
      +        # chain of thought to DeepSeek/Kimi.
               normalized_reasoning = source_msg.get("reasoning")
               if (
                   needs_thinking_pad
      @@ -8745,9 +8761,9 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No
       
               # 3. Healthy session: promote 'reasoning' field to 'reasoning_content'
               # for providers that use the internal 'reasoning' key.
      -        # This must happen BEFORE the DeepSeek/Kimi tool-call check so that
      -        # genuine reasoning content is not overwritten by the empty-string
      -        # fallback (#15812 regression in PR #15478).
      +        # This must happen before the unconditional empty-string fallback so
      +        # genuine reasoning content is not overwritten (#15812 regression in
      +        # PR #15478).
               if isinstance(normalized_reasoning, str) and normalized_reasoning:
                   api_msg["reasoning_content"] = normalized_reasoning
                   return
    • scripts/release.py
      @@ -53,6 +53,9 @@
           "sr@samirusani": "samrusani",
           "[redacted:email]": "angel12",
           "[redacted:email]": "charles-brooks",
      +    # DeepSeek v4 + Kimi thinking-mode reasoning_content salvage (April 2026)
      +    "[redacted:email]": "lsdsjy",
      +    "[redacted:email]": "season179",
           "[redacted:email]": "heathley",
           "[redacted:email]": "dandaka",
           "[redacted:email]": "cyclingwithelephants",
    • tests/agent/transports/test_chat_completions.py
      @@ -620,6 +620,41 @@ def test_reasoning_content_preserved_separately(self, transport):
               assert nr.reasoning == "summary text"
               assert nr.provider_data == {"reasoning_content": "detailed scratchpad"}
       
      +    def test_empty_reasoning_content_preserved(self, transport):
      +        """DeepSeek can require an explicit empty reasoning_content replay field."""
      +        r = SimpleNamespace(
      +            choices=[SimpleNamespace(
      +                message=SimpleNamespace(
      +                    content=None,
      +                    tool_calls=None,
      +                    reasoning=None,
      +                    reasoning_content="",
      +                ),
      +                finish_reason="stop",
      +            )],
      +            usage=None,
      +        )
      +        nr = transport.normalize_response(r)
      +        assert nr.provider_data == {"reasoning_content": ""}
      +        assert nr.reasoning_content == ""
      +
      +    def test_reasoning_content_preserved_from_model_extra(self, transport):
      +        """OpenAI SDK can expose provider-specific DeepSeek fields via model_extra."""
      +        r = SimpleNamespace(
      +            choices=[SimpleNamespace(
      +                message=SimpleNamespace(
      +                    content=None,
      +                    tool_calls=None,
      +                    reasoning=None,
      +                    model_extra={"reasoning_content": "model-extra scratchpad"},
      +                ),
      +                finish_reason="stop",
      +            )],
      +            usage=None,
      +        )
      +        nr = transport.normalize_response(r)
      +        assert nr.provider_data == {"reasoning_content": "model-extra scratchpad"}
      +
       
       class TestChatCompletionsCacheStats:
       
    • tests/run_agent/test_deepseek_reasoning_content_echo.py
      @@ -23,6 +23,8 @@
       
       from __future__ import annotations
       
      +from types import SimpleNamespace
      +
       import pytest
       
       from run_agent import AIAgent
      @@ -33,9 +35,36 @@ def _make_agent(provider: str = "", model: str = "", base_url: str = "") -> AIAg
           agent.provider = provider
           agent.model = model
           agent.base_url = base_url
      +    agent.verbose_logging = False
      +    agent.reasoning_callback = None
      +    agent.stream_delta_callback = None
      +    agent._stream_callback = None
           return agent
       
       
      +_ATTR_ABSENT = object()
      +_EXPECT_NOT_PRESENT = object()
      +
      +
      +def _sdk_tool_call(call_id: str = "c1", name: str = "terminal", arguments: str = "{}"):
      +    """Minimal SDK-shaped tool_call object that satisfies the builder's iteration."""
      +    return SimpleNamespace(
      +        id=call_id,
      +        call_id=call_id,
      +        type="function",
      +        function=SimpleNamespace(name=name, arguments=arguments),
      +        extra_content=None,
      +    )
      +
      +
      +def _build_sdk_message(reasoning_content=_ATTR_ABSENT, **extra):
      +    """SDK-shaped assistant message; ``reasoning_content`` defaults to absent."""
      +    kwargs = {"content": "", **extra}
      +    if reasoning_content is not _ATTR_ABSENT:
      +        kwargs["reasoning_content"] = reasoning_content
      +    return SimpleNamespace(**kwargs)
      +
      +
       class TestNeedsDeepSeekToolReasoning:
           """_needs_deepseek_tool_reasoning() recognises all three detection signals."""
       
      @@ -109,16 +138,7 @@ def test_deepseek_explicit_reasoning_content_preserved(self) -> None:
               assert api_msg["reasoning_content"] == "<think>real chain of thought</think>"
       
           def test_deepseek_reasoning_field_promoted(self) -> None:
      -        """When only 'reasoning' is set (no tool_calls), it gets promoted to reasoning_content.
      -
      -        On DeepSeek/Kimi, tool-call turns with 'reasoning' but no
      -        'reasoning_content' are treated as cross-provider poisoned history
      -        (#15748) and padded with "" instead of promoted. Same-provider
      -        DeepSeek tool-call turns always have reasoning_content pinned at
      -        creation time by _build_assistant_message, so the (reasoning-set,
      -        reasoning_content-absent, tool_calls-present) shape is unreachable
      -        from same-provider history.
      -        """
      +        """When only 'reasoning' is set, it gets promoted to reasoning_content."""
               agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
               source = {
                   "role": "assistant",
      @@ -135,8 +155,8 @@ def test_deepseek_poisoned_cross_provider_history_padded(self) -> None:
       
               If the source turn has tool_calls AND a 'reasoning' field but NO
               'reasoning_content' key, it's from a prior provider (the DeepSeek
      -        build path always pins reasoning_content="" at creation). Inject
      -        "" instead of forwarding the prior provider's chain of thought.
      +        build path pins reasoning_content at creation). Inject "" instead
      +        of forwarding the prior provider's chain of thought.
               """
               agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
               source = {
      @@ -228,6 +248,172 @@ def test_non_assistant_role_ignored(self) -> None:
               assert "reasoning_content" not in api_msg
       
       
      +class TestBuildAssistantMessageDeepSeekReasoningContent:
      +    """_build_assistant_message pins replay-safe DeepSeek tool-call state."""
      +
      +    def test_deepseek_tool_call_reasoning_is_backfilled_into_reasoning_content(self) -> None:
      +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
      +        assistant_message = SimpleNamespace(
      +            content=None,
      +            reasoning="DeepSeek tool-call reasoning",
      +            reasoning_content=None,
      +            reasoning_details=None,
      +            codex_reasoning_items=None,
      +            codex_message_items=None,
      +            tool_calls=[
      +                SimpleNamespace(
      +                    id="call_1",
      +                    call_id=None,
      +                    response_item_id=None,
      +                    type="function",
      +                    function=SimpleNamespace(name="terminal", arguments="{}"),
      +                )
      +            ],
      +        )
      +
      +        msg = agent._build_assistant_message(assistant_message, "tool_calls")
      +
      +        assert msg["reasoning_content"] == "DeepSeek tool-call reasoning"
      +        assert msg["tool_calls"][0]["id"] == "call_1"
      +
      +    def test_deepseek_model_extra_reasoning_content_is_preserved(self) -> None:
      +        """OpenAI SDK stores unknown provider fields in model_extra."""
      +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
      +        assistant_message = SimpleNamespace(
      +            content=None,
      +            reasoning=None,
      +            reasoning_content=None,
      +            model_extra={"reasoning_content": "DeepSeek model_extra reasoning"},
      +            reasoning_details=None,
      +            codex_reasoning_items=None,
      +            codex_message_items=None,
      +            tool_calls=[
      +                SimpleNamespace(
      +                    id="call_1",
      +                    call_id=None,
      +                    response_item_id=None,
      +                    type="function",
      +                    function=SimpleNamespace(name="terminal", arguments="{}"),
      +                )
      +            ],
      +        )
      +
      +        msg = agent._build_assistant_message(assistant_message, "tool_calls")
      +
      +        assert msg["reasoning_content"] == "DeepSeek model_extra reasoning"
      +
      +    def test_deepseek_tool_call_without_raw_reasoning_content_gets_empty_string(self) -> None:
      +        agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
      +        assistant_message = SimpleNamespace(
      +            content=None,
      +            reasoning=None,
      +            reasoning_content=None,
      +            reasoning_details=None,
      +            codex_reasoning_items=None,
      +            codex_message_items=None,
      +            tool_calls=[
      +                SimpleNamespace(
      +                    id="call_1",
      +                    call_id=None,
      +                    response_item_id=None,
      +                    type="function",
      +                    function=SimpleNamespace(name="terminal", arguments="{}"),
      +                )
      +            ],
      +        )
      +
      +        msg = agent._build_assistant_message(assistant_message, "tool_calls")
      +
      +        assert msg["reasoning_content"] == ""
      +        assert msg["tool_calls"][0]["id"] == "call_1"
      +
      +
      +class TestBuildAssistantMessagePadsStrictProviders:
      +    """Regression for #17400: _build_assistant_message must pin reasoning_content
      +    on tool-call turns when the active provider enforces echo-back, regardless
      +    of whether the SDK exposed reasoning_content as None, omitted it entirely,
      +    or returned an empty thinking block.
      +
      +    Prior to the fix, the pad branch was guarded by ``msg.get("tool_calls")``,
      +    which was always falsy because tool_calls were assigned later in the same
      +    method. Persisted history accumulated assistant tool-call turns with no
      +    reasoning_content; the next replay 400'd on DeepSeek/Kimi.
      +    """
      +
      +    @pytest.mark.parametrize(
      +        "provider,model,base_url,sdk_reasoning_content,expected",
      +        [
      +            pytest.param(
      +                "deepseek", "deepseek-v4-pro", "",
      +                None, "",
      +                id="deepseek-attr-none",
      +            ),
      +            pytest.param(
      +                "deepseek", "deepseek-v4-pro", "",
      +                _ATTR_ABSENT, "",
      +                id="deepseek-attr-absent",
      +            ),
      +            pytest.param(
      +                "kimi-coding", "kimi-k2.6", "",
      +                None, "",
      +                id="kimi-attr-none",
      +            ),
      +            pytest.param(
      +                "custom", "kimi-k2", "https://api.moonshot.ai/v1",
      +                _ATTR_ABSENT, "",
      +                id="moonshot-base-url",
      +            ),
      +            pytest.param(
      +                "openrouter", "anthropic/claude-sonnet-4.6", "https://openrouter.ai/api/v1",
      +                _ATTR_ABSENT, _EXPECT_NOT_PRESENT,
      +                id="openrouter-no-pad",
      +            ),
      +        ],
      +    )
      +    def test_tool_call_reasoning_content_pad(
      +        self, provider, model, base_url, sdk_reasoning_content, expected,
      +    ) -> None:
      +        agent = _make_agent(provider=provider, model=model, base_url=base_url)
      +        msg_in = _build_sdk_message(
      +            reasoning_content=sdk_reasoning_content,
      +            tool_calls=[_sdk_tool_call()],
      +        )
      +        msg = agent._build_assistant_message(msg_in, finish_reason="tool_calls")
      +        if expected is _EXPECT_NOT_PRESENT:
      +            assert "reasoning_content" not in msg
      +        else:
      +            assert msg["reasoning_content"] == expected
      +
      +    def test_tool_call_preserves_real_reasoning_content(self) -> None:
      +        agent = _make_agent(provider="deepseek", model="deepseek-v4-pro")
      +        msg_in = _build_sdk_message(
      +            reasoning_content="actual chain of thought",
      +            tool_calls=[_sdk_tool_call()],
      +        )
      +        msg = agent._build_assistant_message(msg_in, finish_reason="tool_calls")
      +        assert msg["reasoning_content"] == "actual chain of thought"
      +
      +    def test_text_only_turn_not_padded_by_tool_call_branch(self) -> None:
      +        """Plain-text turns rely on _copy_reasoning_content_for_api at replay
      +        time, not on this builder's tool-call pad."""
      +        agent = _make_agent(provider="deepseek", model="deepseek-v4-pro")
      +        msg_in = SimpleNamespace(content="hello", tool_calls=None)
      +        msg = agent._build_assistant_message(msg_in, finish_reason="stop")
      +        assert "tool_calls" not in msg
      +        assert "reasoning_content" not in msg
      +
      +    def test_streamed_reasoning_text_promoted_over_pad(self) -> None:
      +        """When ``.reasoning`` carries streamed thinking, it must be promoted
      +        to reasoning_content rather than overwritten with the empty pad."""
      +        agent = _make_agent(provider="deepseek", model="deepseek-v4-pro")
      +        msg_in = _build_sdk_message(
      +            reasoning="streamed thoughts",
      +            tool_calls=[_sdk_tool_call()],
      +        )
      +        msg = agent._build_assistant_message(msg_in, finish_reason="tool_calls")
      +        assert msg["reasoning_content"] == "streamed thoughts"
      +
      +
       class TestNeedsKimiToolReasoning:
           """The extracted _needs_kimi_tool_reasoning() helper keeps Kimi behavior intact."""
       
    • tests/run_agent/test_deepseek_v4_thinking_live.py
      @@ -0,0 +1,245 @@
      +"""Live DeepSeek V4 thinking-mode tool-call replay smoke test.
      +
      +Opt-in only:
      +    HERMES_LIVE_TESTS=1 pytest tests/run_agent/test_deepseek_v4_thinking_live.py -q
      +
      +Requires DEEPSEEK_API_KEY in the process environment. The key is captured at
      +module import time because tests/conftest.py intentionally removes credential
      +environment variables before each test body runs.
      +"""
      +
      +from __future__ import annotations
      +
      +import json
      +import os
      +import sys
      +from typing import Any
      +
      +import pytest
      +
      +
      +LIVE = os.environ.get("HERMES_LIVE_TESTS") == "1"
      +DEEPSEEK_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
      +LIVE_MODELS = ("deepseek-v4-flash", "deepseek-v4-pro")
      +LIVE_BASE_URL = "https://api.deepseek.com"
      +
      +pytestmark = [
      +    pytest.mark.skipif(not LIVE, reason="live-only: set HERMES_LIVE_TESTS=1"),
      +    pytest.mark.skipif(not DEEPSEEK_KEY, reason="DEEPSEEK_API_KEY not configured"),
      +]
      +
      +TOOL_NAME = "lookup_ticket_status"
      +TOOLS = [
      +    {
      +        "type": "function",
      +        "function": {
      +            "name": TOOL_NAME,
      +            "description": "Return the status for a test ticket id.",
      +            "parameters": {
      +                "type": "object",
      +                "properties": {
      +                    "ticket_id": {
      +                        "type": "string",
      +                        "description": "The ticket id to look up.",
      +                    },
      +                },
      +                "required": ["ticket_id"],
      +                "additionalProperties": False,
      +            },
      +        },
      +    }
      +]
      +
      +
      +def _thinking_kwargs() -> dict:
      +    return {
      +        "reasoning_effort": "high",
      +        "extra_body": {"thinking": {"type": "enabled"}},
      +    }
      +
      +
      +def _jsonable(value: Any) -> Any:
      +    if hasattr(value, "model_dump"):
      +        return value.model_dump(mode="json")
      +    if isinstance(value, dict):
      +        return {k: _jsonable(v) for k, v in value.items()}
      +    if isinstance(value, list):
      +        return [_jsonable(v) for v in value]
      +    return value
      +
      +
      +def _print_trace(label: str, value: Any) -> None:
      +    sys.__stdout__.write(f"\n--- {label} ---\n")
      +    sys.__stdout__.write(
      +        json.dumps(_jsonable(value), ensure_ascii=False, indent=2, sort_keys=True)
      +    )
      +    sys.__stdout__.write("\n")
      +    sys.__stdout__.flush()
      +
      +
      +def _message_snapshot(message) -> dict:
      +    return {
      +        "content": getattr(message, "content", None),
      +        "reasoning": getattr(message, "reasoning", None),
      +        "reasoning_content": _raw_reasoning_content(message),
      +        "model_extra": getattr(message, "model_extra", None),
      +        "tool_calls": _jsonable(getattr(message, "tool_calls", None)),
      +    }
      +
      +
      +def _make_live_client():
      +    from openai import OpenAI
      +
      +    return OpenAI(api_key=[redacted:secret-assignment] base_url=LIVE_BASE_URL)
      +
      +
      +def _make_agent_for_message_building(model: str):
      +    from run_agent import AIAgent
      +
      +    agent = object.__new__(AIAgent)
      +    agent.provider = "deepseek"
      +    agent.model = model
      +    agent.base_url = LIVE_BASE_URL
      +    agent.verbose_logging = False
      +    agent.reasoning_callback = None
      +    agent.stream_delta_callback = None
      +    agent._stream_callback = None
      +    return agent
      +
      +
      +def _raw_reasoning_content(message):
      +    direct = getattr(message, "reasoning_content", None)
      +    if direct is not None:
      +        return direct
      +    model_extra = getattr(message, "model_extra", None) or {}
      +    if isinstance(model_extra, dict) and "reasoning_content" in model_extra:
      +        return model_extra["reasoning_content"]
      +    return None
      +
      +
      [redacted:email]("live_model", LIVE_MODELS)
      +def test_deepseek_v4_thinking_tool_call_replay_round_trip(live_model: str):
      +    """Hit DeepSeek twice and replay the assistant tool-call turn.
      +
      +    The first request forces a tool call with thinking enabled. The second
      +    request replays that assistant message with content, reasoning_content,
      +    and tool_calls, then appends the tool result. DeepSeek accepting the
      +    second request is the live guardrail for the V4 thinking replay contract.
      +    """
      +
      +    client = _make_live_client()
      +    agent = _make_agent_for_message_building(live_model)
      +
      +    first_request = {
      +        "model": live_model,
      +        "messages": [
      +            {
      +                "role": "user",
      +                "content": (
      +                    "You must use the provided lookup_ticket_status tool "
      +                    "exactly once with ticket_id 'DS-4242'. Do not answer "
      +                    "directly."
      +                ),
      +            }
      +        ],
      +        "tools": TOOLS,
      +        "max_tokens": 1024,
      +        "timeout": 90,
      +        **_thinking_kwargs(),
      +    }
      +    _print_trace(f"{live_model} first request", first_request)
      +    first = client.chat.completions.create(**first_request)
      +    _print_trace(f"{live_model} first raw response", first)
      +
      +    first_choice = first.choices[0]
      +    first_message = first_choice.message
      +    _print_trace(
      +        f"{live_model} first assistant message",
      +        {
      +            "finish_reason": first_choice.finish_reason,
      +            **_message_snapshot(first_message),
      +        },
      +    )
      +    assert first_message.tool_calls, "DeepSeek did not return a tool call"
      +    first_tool_call = first_message.tool_calls[0]
      +    assert first_tool_call.function.name == TOOL_NAME
      +    assert isinstance(json.loads(first_tool_call.function.arguments or "{}"), dict)
      +
      +    raw_reasoning_content = _raw_reasoning_content(first_message)
      +    assert raw_reasoning_content is not None, (
      +        "DeepSeek did not return reasoning_content; the thinking payload may "
      +        "not have been honored"
      +    )
      +
      +    stored_assistant = agent._build_assistant_message(
      +        first_message,
      +        first_choice.finish_reason or "tool_calls",
      +    )
      +    _print_trace(f"{live_model} stored assistant message", stored_assistant)
      +    assert stored_assistant["reasoning_content"] == raw_reasoning_content
      +
      +    replay_assistant = {
      +        "role": "assistant",
      +        "content": stored_assistant.get("content") or "",
      +        "tool_calls": stored_assistant["tool_calls"],
      +    }
      +    agent._copy_reasoning_content_for_api(stored_assistant, replay_assistant)
      +    _print_trace(f"{live_model} replay assistant message", replay_assistant)
      +
      +    tool_call_id = stored_assistant["tool_calls"][0]["id"]
      +    messages = [
      +        {
      +            "role": "user",
      +            "content": (
      +                "You must use the provided lookup_ticket_status tool "
      +                "exactly once with ticket_id 'DS-4242'. Do not answer "
      +                "directly."
      +            ),
      +        },
      +        replay_assistant,
      +        {
      +            "role": "tool",
      +            "tool_call_id": tool_call_id,
      +            "content": json.dumps(
      +                {"ticket_id": "DS-4242", "status": "green", "source": "live-test"},
      +                separators=(",", ":"),
      +            ),
      +        },
      +    ]
      +
      +    from agent.transports.chat_completions import ChatCompletionsTransport
      +
      +    api_messages = ChatCompletionsTransport().convert_messages(messages)
      +    _print_trace(
      +        f"{live_model} second request messages after transport conversion",
      +        api_messages,
      +    )
      +    assert api_messages[1]["reasoning_content"] == raw_reasoning_content
      +    assert "call_id" not in api_messages[1]["tool_calls"][0]
      +    assert "response_item_id" not in api_messages[1]["tool_calls"][0]
      +
      +    second_request = {
      +        "model": live_model,
      +        "messages": api_messages,
      +        "max_tokens": 1024,
      +        "timeout": 90,
      +        **_thinking_kwargs(),
      +    }
      +    _print_trace(f"{live_model} second request", second_request)
      +    second = client.chat.completions.create(**second_request)
      +    _print_trace(f"{live_model} second raw response", second)
      +    _print_trace(
      +        f"{live_model} second assistant message",
      +        {
      +            "finish_reason": second.choices[0].finish_reason,
      +            **_message_snapshot(second.choices[0].message),
      +        },
      +    )
      +
      +    second_message = second.choices[0].message
      +    final_content = second_message.content or ""
      +    final_reasoning = _raw_reasoning_content(second_message) or ""
      +    assert second.choices[0].finish_reason == "stop"
      +    assert final_content.strip() or final_reasoning.strip(), (
      +        "DeepSeek returned neither visible content nor reasoning_content"
      +    )
  • #22798 fix(agent): extract thinking from content-list blocks for DeepSeek V4 Pro (salvage #22643)

    merged to main

    files: run_agent.py, tests/run_agent/test_run_agent.py

    Show the diff
    • run_agent.py
      @@ -3529,6 +3529,19 @@ def _extract_reasoning(self, assistant_message) -> Optional[str]:
               # instead of returning structured reasoning fields.  Only fall back
               # to inline extraction when no structured reasoning was found.
               content = getattr(assistant_message, "content", None)
      +        if not reasoning_parts and isinstance(content, list):
      +            # DeepSeek V4 Pro (and compatible providers) return content as a
      +            # list of typed blocks, e.g.:
      +            #   [{"type": "thinking", "thinking": "..."}, {"type": "output", ...}]
      +            # Without this branch the thinking text is silently dropped and the
      +            # next turn fails with HTTP 400 ("thinking must be passed back").
      +            # Refs #21944.
      +            for block in content:
      +                if isinstance(block, dict) and block.get("type") == "thinking":
      +                    thinking_text = block.get("thinking") or block.get("text") or ""
      +                    thinking_text = thinking_text.strip()
      +                    if thinking_text and thinking_text not in reasoning_parts:
      +                        reasoning_parts.append(thinking_text)
               if not reasoning_parts and isinstance(content, str) and content:
                   inline_patterns = (
                       r"<think>(.*?)</think>",
    • tests/run_agent/test_run_agent.py
      @@ -517,6 +517,42 @@ def test_inline_reasoning_blocks_fallback(self, agent, content, expected):
               msg = _mock_assistant_msg(content=content)
               assert agent._extract_reasoning(msg) == expected
       
      +    def test_content_list_thinking_blocks_extracted(self, agent):
      +        """DeepSeek V4 Pro returns content as a typed-block list (issue #21944).
      +
      +        Without this branch thinking text is silently dropped → HTTP 400 on
      +        the next turn ("thinking must be passed back to the API").
      +        """
      +        msg = _mock_assistant_msg(
      +            content=[
      +                {"type": "thinking", "thinking": "deep analysis here"},
      +                {"type": "output", "text": "final answer"},
      +            ]
      +        )
      +        result = agent._extract_reasoning(msg)
      +        assert result == "deep analysis here"
      +
      +    def test_content_list_non_thinking_blocks_ignored(self, agent):
      +        """Non-thinking blocks in a content list must not be treated as reasoning."""
      +        msg = _mock_assistant_msg(
      +            content=[
      +                {"type": "text", "text": "just a regular response"},
      +            ]
      +        )
      +        assert agent._extract_reasoning(msg) is None
      +
      +    def test_content_list_thinking_prefers_structured_field(self, agent):
      +        """Structured ``reasoning`` field wins over content-list thinking blocks."""
      +        msg = _mock_assistant_msg(
      +            reasoning="from structured field",
      +            content=[
      +                {"type": "thinking", "thinking": "from content list"},
      +            ],
      +        )
      +        result = agent._extract_reasoning(msg)
      +        # structured field was found first → content-list branch skipped
      +        assert result == "from structured field"
      +
       
       class TestCleanSessionContent:
           def test_none_passthrough(self):
  • #26648 fix(deepseek): wire thinking-mode via DeepSeekProfile (closes #15700, #17212, #17825)

    merged to main

    files: plugins/model-providers/deepseek/__init__.py, scripts/release.py, tests/plugins/model_providers/test_deepseek_profile.py

26 PRs closed without merging

From the close comments: done elsewhere 10 · merged via another change 5 · superseded by another PR 5 · duplicate 4 · stale or abandoned 1 · other 1

  • #13897 fix(anthropic_adapter): preserve reasoning_content for Kimi /coding tool-call messages · merged via another change → #14018
  • #14941 fix: set empty reasoning_content for DeepSeek when tool_calls present · done elsewhere → 93a2d6b, d58b305
  • #14973 fix: extract DeepSeek reasoning_content from model_extra · done elsewhere → 93a2d6b, d58b305, ad0ac89, 5ae6081, 9daa062, 63bf7a2, #16500
  • #15354 fix: add DeepSeek reasoning_content echo for tool-call messages (fixes #15353) · merged via another change → 93a2d6b, #15407, #15250, #15353
  • #15446 fix(agent): comprehensive DeepSeek V4 support — context windows, thinking mode, reasoning replay · done elsewhere → 93a2d6b, d58b305, ad0ac89, 5ae6081, 9daa062, 63bf7a2, #16500
Show 21 more
  • #15688 fix(auxiliary): sanitize reasoning_content for DeepSeek/Kimi tool-call replay · other
  • #15760 Fix DeepSeek reasoning_content propagation · done elsewhere → 93a2d6b, d58b305, ad0ac89, 5ae6081, 9daa062, 63bf7a2, #16500
  • #15792 fix(deepseek): pad reasoning_content on plain assistant turns in thinking mode (#15741) · superseded by another PR → 5ae6081, ad0ac89, 63bf7a2
  • #15804 fix: extract reasoning_content from model_extra for DeepSeek/Moonshot · done elsewhere → 93a2d6b, d58b305, ad0ac89, 5ae6081, 9daa062, 63bf7a2, #16500
  • #16614 fix(transport): emit deepseek-v4 thinking.type and reasoning_effort on non-OpenRouter routes · duplicate → #14958, #15577, #16448, #15251
  • #16781 fix: preserve DeepSeek thinking blocks on Anthropic replay (#16748) · merged via another change → fd5479a, #17510
  • #17223 fix(anthropic_adapter): preserve unsigned thinking blocks for DeepSeek /anthropic (#16748) · done elsewhere → a7e7921, #16748
  • #17225 fix(agent): send thinking control to DeepSeek direct API to prevent 400 on tool-call replay · stale or abandoned
  • #17489 fix(agent): pad reasoning_content on DeepSeek/Kimi tool-call turns · merged via another change → 76edc40, #18045, #16855
  • #18218 fix: preserve DeepSeek V4 signed thinking blocks on /anthropic endpoint · done elsewhere
  • #21033 fix(agent): rebuild api_messages after provider fallback to apply reasoning requirements · done elsewhere → b5495db
  • #21668 fix(deepseek): send explicit thinking:disabled when reasoning is off · done elsewhere → cd9470f, 068c24f, #17225
  • #21986 Fix DeepSeek thinking content array replay · superseded by another PR → #21989
  • #21989 Fix DeepSeek thinking content array replay · done elsewhere
  • #22643 fix(agent): extract thinking from content-list blocks for DeepSeek V4 Pro · merged via another change → #22798, #21946
  • #24603 fix(agent): preserve reasoning_content for Xiaomi MiMo thinking mode (#24443) · duplicate → #24465, #24603, #24605, #24443
  • #24605 fix(run_agent): enroll Xiaomi MiMo in reasoning_content echo-back enforcement · superseded by another PR → #24662
  • #24638 fix(agent): add MiMo to reasoning_content echo-back providers in _needs_thinking_reasoning_pad · superseded by another PR → #24662
  • #24662 fix(agent): add MiMo to reasoning_content echo-back providers in _needs_thinking_reasoning_pad · superseded by another PR → #24465, #24443
  • #24887 feat: add Xiaomi MiMo Anthropic-compatible endpoint support for thinking mode · duplicate → #24465, #24603, #24784, #24810, #24884, #24443
  • #58148 fix: surface reasoning/thinking content as response instead of '(empty)' · duplicate → #50397
Closed issues · 22
  • Closed#13848[Bug]: 400 error for kimi-for-coding
  • Closed#14933[BUG] DeepSeek V4 thinking mode fails with reasoning_content error
  • Closed#14938DeepSeek V4 Flash Toolcall Fails with reasoning_content Error
  • Closed#15213[Bug]: HTTP 400 "reasoning_content must be passed back" with deepseek-v4-pro in cron/auxiliary path (thinking mode works in main loop, breaks elsewhere)
  • Closed#15250[Bug]: DeepSeek V4 Flash Discord session poisoned when tool-call assistant lacks reasoning_content
  • Closed#15353DeepSeek V4 thinking mode: missing reasoning_content on tool-call messages causes 400
  • Closed#15679[Bug]: DeepSeek reasoning models not supported (reasoning_content missing)
  • Closed#15717[Bug]: DeepSeek API 400 error: "reasoning_content" in thinking mode must be passed back to the API
  • Closed#15741[Bug]: cron path still emits HTTP 400 reasoning_content error on deepseek-v4-flash after #15213 closure
  • Closed#15748[Bug] _copy_reasoning_content_for_api: cross-provider reasoning promotion leaks stale content to DeepSeek/Kimi
  • Closed#16021[Bug report] DeepSeek V4: reasoning_content missing on non-tool-call turns causes HTTP 400
  • Closed#16135[BUG] DeepSeek v4-pro reasoning mode breaks with "reasoning_content must be passed back" error
  • Closed#16137[Bug]: DeepSeek thinking mode requires reasoning_content in API replay
  • Closed#16388DeepSeek tool-calling turns missing reasoning_content cause HTTP 400 — Kimi-only compensation should generalise
  • Closed#16748DeepSeek /anthropic (V4 thinking): stripped thinking blocks cause HTTP 400 on replay
  • Closed#17212DeepSeek direct API 400 "reasoning_content must be passed back" on multi-turn tool calls
  • Closed#17400reasoning_content dropped in multi-turn tool calls with DeepSeek v4 (causes HTTP 400)
  • Closed#17825[Bug]: DeepSeek reasoning models crash with HTTP 400 on multi-turn sessions — reasoning_content dropped during session reload
  • Closed#21944Bug: DeepSeek V4 Pro thinking content not persisted in session (HTTP 400 on multi-turn)
  • Closed#21946Bug: DeepSeek V4 Pro thinking content not persisted in session (HTTP 400 on multi-turn)
  • Closed#22313bug: DeepSeek Anthropic-compatible API — HTTP 400: thinking blocks stripped from message history
  • Closed#34519[Bug]: Fallback provider activation reuses stale api_messages — reasoning_content not injected for DeepSeek/Kimi thinking mode
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.04: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#14018, #15407, #15478, #16500, #17510, #18045, #22798, #26648
Closed issues
22
Attempts
26 PRs closed without merging
Fix link checked
16 May 2026

For your agent

The same facts, typed.