Skip to content

fix: replay DeepSeek reasoning_content on tool-turn history#71146

Closed
snowzlm wants to merge 5 commits into
openclaw:mainfrom
snowzlm:fix/deepseek-thinking-replay
Closed

fix: replay DeepSeek reasoning_content on tool-turn history#71146
snowzlm wants to merge 5 commits into
openclaw:mainfrom
snowzlm:fix/deepseek-thinking-replay

Conversation

@snowzlm

@snowzlm snowzlm commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Under DeepSeek V4 thinking mode, when historical assistant tool-call messages lack the reasoning_content field, the API returns a 400 error:

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

Root Cause

convertMessages does not generate a reasoning_content field when converting assistant tool-call history to OpenAI format. DeepSeek thinking mode strictly requires this field to be present.

Fix

In buildOpenAICompletionsParams, after convertMessages, insert injectDeepSeekReasoningReplay to inject reasoning_content as a fallback for DeepSeek model assistant tool-call messages.

Fallback priority (highest to lowest):

  1. Existing non-empty reasoning_content → keep as-is
  2. reasoning field → copy
  3. reasoning_text field → copy
  4. Replay signature key content extraction
  5. Assistant content string → copy
  6. None of the above → empty string "" (verified safe against live API)

Only applied to models with provider=deepseek or baseUrl containing deepseek.com.

Verification

  • Unit tests cover all 5 fallback paths
  • Production dry-run: 1493 tool-turns, missingAfterPatch: 0
  • Production live test: 0 errors after applying the patch

Related

Requires companion fix in @mariozechner/pi-ai buildParams for the pi-ai streaming path: snowzlm/pi-mono#1

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: M labels Apr 24, 2026
@greptile-apps

greptile-apps Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR injects a reasoning_content fallback into assistant tool-call messages before they are sent to DeepSeek thinking-mode APIs, preventing 400 errors caused by the field being absent. The core logic and test coverage are solid, but there is one gap: the documented Priority 6 "empty string fallback" is never actually written to the message when all other content sources are absent (content: null, no thinking block, no reasoning/reasoning_text fields), so the original 400 can still be triggered for cross-model history with no text.

  • P1: When reasoning_content is absent and all fallbacks yield nothing (e.g., an OpenAI tool-call turn with content: null), the field stays absent and DeepSeek still returns 400. A final record.reasoning_content = record.reasoning_content ?? \"\"; after line 1794 would close this gap.
  • P2: extractReplayReasoningContent returns early on the first non-empty replay-signature key, leaving any subsequent matching keys out of keysToDelete and unsanitized in the outgoing message.

Confidence Score: 4/5

Safe to merge after addressing the missing empty-string fallback, which can still trigger a 400 for cross-model tool-call history with no text content.

A P1 gap exists: the final fallback to "" is documented in the PR description but never assigned in code, leaving the original 400 possible for tool-call turns without any text content. All other logic is correct and the test suite covers the primary paths.

src/agents/openai-transport-stream.ts — specifically the end of injectDeepSeekReasoningReplay where the "" fallback is missing.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/openai-transport-stream.ts
Line: 1792-1795

Comment:
**Missing empty-string fallback when all content sources are absent**

The PR description explicitly states Priority 6 is "None of the above → empty string `""` (verified safe against live API)", but the implementation never assigns `""`. If `record.reasoning_content` is absent (which is the whole problem being fixed), `record.reasoning` / `record.reasoning_text` are absent, no replay-signature key is present, and `record.content` is `null` or not a string (common for OpenAI tool-call messages with `content: null`), the function returns without setting `reasoning_content` at all. The field remains absent and DeepSeek will still return the 400 error this PR is meant to prevent. A final unconditional assignment of `record.reasoning_content = record.reasoning_content ?? "";` after the last `if` block would close the gap.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/openai-transport-stream.ts
Line: 1723-1737

Comment:
**Incomplete `keysToDelete` when multiple replay-signature keys exist**

`extractReplayReasoningContent` early-returns as soon as it finds the first non-empty replay-signature key, leaving any subsequent matching keys out of the returned `keysToDelete` array. Those keys will survive the cleanup loop and be forwarded to the DeepSeek API. Concretely, if a message had two keys whose names satisfy `parseReplayReasoningSignatureKey` (e.g., two `rs_`-prefixed reasoning blocks), only the first and any empty-valued predecessors are deleted; the rest are orphaned. The fix is to collect all matching keys first, then pick the first non-empty value.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix: normalize DeepSeek reasoning replay..." | Re-trigger Greptile

Comment on lines +1792 to +1795
if (typeof record.content === "string" && record.content.trim().length > 0) {
record.reasoning_content = record.content;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Missing empty-string fallback when all content sources are absent

The PR description explicitly states Priority 6 is "None of the above → empty string "" (verified safe against live API)", but the implementation never assigns "". If record.reasoning_content is absent (which is the whole problem being fixed), record.reasoning / record.reasoning_text are absent, no replay-signature key is present, and record.content is null or not a string (common for OpenAI tool-call messages with content: null), the function returns without setting reasoning_content at all. The field remains absent and DeepSeek will still return the 400 error this PR is meant to prevent. A final unconditional assignment of record.reasoning_content = record.reasoning_content ?? ""; after the last if block would close the gap.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/openai-transport-stream.ts
Line: 1792-1795

Comment:
**Missing empty-string fallback when all content sources are absent**

The PR description explicitly states Priority 6 is "None of the above → empty string `""` (verified safe against live API)", but the implementation never assigns `""`. If `record.reasoning_content` is absent (which is the whole problem being fixed), `record.reasoning` / `record.reasoning_text` are absent, no replay-signature key is present, and `record.content` is `null` or not a string (common for OpenAI tool-call messages with `content: null`), the function returns without setting `reasoning_content` at all. The field remains absent and DeepSeek will still return the 400 error this PR is meant to prevent. A final unconditional assignment of `record.reasoning_content = record.reasoning_content ?? "";` after the last `if` block would close the gap.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/agents/openai-transport-stream.ts Outdated
Comment on lines +1723 to +1737
function extractReplayReasoningContent(record: Record<string, unknown>): {
content?: string;
keysToDelete: string[];
} {
const keysToDelete: string[] = [];
for (const [key, value] of Object.entries(record)) {
if (!parseReplayReasoningSignatureKey(key)) {
continue;
}
keysToDelete.push(key);
if (typeof value === "string" && value.trim().length > 0) {
return { content: value, keysToDelete };
}
}
return { keysToDelete };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Incomplete keysToDelete when multiple replay-signature keys exist

extractReplayReasoningContent early-returns as soon as it finds the first non-empty replay-signature key, leaving any subsequent matching keys out of the returned keysToDelete array. Those keys will survive the cleanup loop and be forwarded to the DeepSeek API. Concretely, if a message had two keys whose names satisfy parseReplayReasoningSignatureKey (e.g., two rs_-prefixed reasoning blocks), only the first and any empty-valued predecessors are deleted; the rest are orphaned. The fix is to collect all matching keys first, then pick the first non-empty value.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/openai-transport-stream.ts
Line: 1723-1737

Comment:
**Incomplete `keysToDelete` when multiple replay-signature keys exist**

`extractReplayReasoningContent` early-returns as soon as it finds the first non-empty replay-signature key, leaving any subsequent matching keys out of the returned `keysToDelete` array. Those keys will survive the cleanup loop and be forwarded to the DeepSeek API. Concretely, if a message had two keys whose names satisfy `parseReplayReasoningSignatureKey` (e.g., two `rs_`-prefixed reasoning blocks), only the first and any empty-valued predecessors are deleted; the rest are orphaned. The fix is to collect all matching keys first, then pick the first non-empty value.

How can I resolve this? If you propose a fix, please make it concise.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a0a8a27f99

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

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

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

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/openai-transport-stream.ts Outdated
Comment on lines +1733 to +1735
if (typeof value === "string" && value.trim().length > 0) {
return { content: value, keysToDelete };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge Delete all replay-signature keys before returning content

extractReplayReasoningContent returns as soon as it finds the first non-empty replay value, so any later replay-signature keys on the same assistant tool-call message are never collected into keysToDelete. This can happen when a single turn contains multiple reasoning replay items, and it leaves stray JSON-encoded keys in the outgoing message payload, which defeats the scrub step and can still trigger DeepSeek request validation errors on replayed history.

Useful? React with 👍 / 👎.

Addresses Greptile review feedback:

P1: When all fallback sources are absent (e.g., OpenAI tool-call with
content: null replayed against DeepSeek), reasoning_content remained
undefined, still triggering 400. Add unconditional empty-string fallback.

P2: extractReplayReasoningContent early-returned on the first non-empty
replay-signature key, leaving subsequent matching keys unsanitized.
Collect all matching keys before picking the first non-empty value.
@steipete

Copy link
Copy Markdown
Contributor

Thanks for the contribution. Closing this as superseded by #71105, which has now landed the DeepSeek V4 catalog/default update plus the provider-owned reasoning_content replay fix for DeepSeek V4 thinking/tool turns.

The landed path keeps the behavior inside the DeepSeek plugin boundary, preserves legacy deepseek-chat / deepseek-reasoner entries, and was verified with live DeepSeek V4 Flash/Pro calls.

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

Labels

agents Agent runtime and tooling size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants