Skip to content

fix(retry): make DeepSeek reasoning-content replay 400 retryable - #3835

Merged
Yeachan-Heo merged 1 commit into
devfrom
fix/retryable-400
Aug 5, 2026
Merged

fix(retry): make DeepSeek reasoning-content replay 400 retryable#3835
Yeachan-Heo merged 1 commit into
devfrom
fix/retryable-400

Conversation

@Yeachan-Heo

Copy link
Copy Markdown
Owner

Summary

DeepSeek V4 (and reasoning-capable siblings on any OpenAI-compatible proxy) reject every follow-up turn with:

`400 Error from provider (Console): Upstream request failed: [invalid_request_error] The `reasoning_content` in the thinking mode must be passed back to the API.`

once a prior assistant turn carried reasoning the proxy stripped to an empty `encrypted_content`. Resending the identical history re-triggers this deterministic 400, so naive session auto-retry just burns the budget. Previously this was classified `terminal` (the message contains `invalid_request_error`) and surfaced immediately with no repair.

This makes it retryable via a bounded, strip-only circuit breaker that mirrors the existing `invalid_prompt` poisoned-history breaker (#2282).

Root cause

Confirmed from the saved 400 request log: all 136 `reasoning` items in the history carried `encrypted_content: ""` (proxy-stripped). DeepSeek in thinking mode requires the reasoning blob to be passed back; replaying the empty blob 400s on every turn.

Fix

On the first reasoning-content replay rejection of a run, strip the unusable `reasoning` items from the Responses history `providerPayload` in place (never dropping text, tool-call, or tool-output items) and resend exactly once so the model re-reasons. If nothing can be stripped, fail fast. Budget = one repaired resend.

Shared utilities (`@gajae-code/ai/utils`)

  • `isReasoningContentReplayError` — shape-tolerant classifier (string / message / errorMessage carriers), matches the DeepSeek phrasing across newlines.
  • `stripUnusableReasoningItems` — removes only `reasoning` items with missing/null/empty `encrypted_content`; preserves all non-reasoning history verbatim.

Agent loop (`packages/agent`)

  • New `repairReasoningContentReplayHistory` repair + a `reasoningContentRepairAttempted` budget flag, wired as a second bounded circuit breaker alongside the existing `invalid_prompt` breaker.

Test plan

  • `packages/agent/test/agent-loop-reasoning-content-replay-breaker.test.ts` — 6 cases: strips + resends once, does not replay rejected turn, fail-fast when nothing to strip, budget=1 recursion cap, negative (non-reasoning error untouched), preserves valid encrypted reasoning.
  • `packages/ai/test/reasoning-content-replay.test.ts` — 12 cases: classifier across carrier shapes + multi-line, negatives, and the strip function (empty/missing/null/non-empty preservation, order/identity).
  • Existing suites green: `agent-loop-invalid-prompt-breaker`, `agent-loop-recovery-coverage`, `invalid-prompt-classification`, `deepseek-reasoning-content`, `issue-883-repro`.
  • `bun --cwd=packages/agent run check` and `bun --cwd=packages/ai run check` clean.

Changelog

  • `packages/agent/CHANGELOG.md` (Unreleased › Fixed)
  • `packages/ai/CHANGELOG.md` (Unreleased › Fixed)

DeepSeek V4 (and reasoning-capable siblings on any OpenAI-compatible proxy)
reject every follow-up turn with "The reasoning_content in the thinking mode
must be passed back to the API" once a prior assistant turn carried reasoning
the proxy stripped to an empty encrypted_content. Resending the identical
history re-triggers this deterministic 400, so naive session auto-retry just
burns the budget.

Add a bounded, strip-only circuit breaker mirroring the existing invalid_prompt
poisoned-history breaker: on the first such rejection of a run, strip the
unusable reasoning items from the Responses history payload in place (never
dropping text, tool-call, or tool-output items) and resend exactly once so the
model re-reasons. If nothing can be stripped, fail fast. Budget = one repaired
resend.

New shared utilities in @gajae-code/ai/utils:
- isReasoningContentReplayError: detects the error across message carrier shapes
- stripUnusableReasoningItems: removes only reasoning items with empty/missing
  encrypted_content, preserving all non-reasoning history

Lore-id: 4b3211d3
Confidence: high
Scope-risk: narrow
Reversibility: trivial
Tested: agent-loop circuit-breaker matrix (6 cases), classifier + strip unit
  suite (12 cases), existing invalid_prompt/recovery/deepseek suites green
Not-tested: live DeepSeek proxy replay (no credentials in CI)
Supersedes: none

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6b75c14eef

ℹ️ About Codex in GitHub

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

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

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

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

for (const message of messages) {
const payload = (message as { providerPayload?: { type?: string; items?: Array<Record<string, unknown>> } })
.providerPayload;
if (payload?.type !== "openaiResponsesHistory" || !Array.isArray(payload.items)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Repair the chat-completions DeepSeek history too

For the bundled DeepSeek V4 providers, the models are registered through openAiCompletionsDescriptor and require reasoning_content replay (packages/ai/src/provider-models/openai-compat.ts:2366-2390), so their assistant turns do not carry openaiResponsesHistory provider payloads. With this guard, repairReasoningContentReplayHistory() returns false for the exact DeepSeek 400 on the default deepseek-v4-pro/deepseek-v4-flash path, and the new breaker falls through without retrying. Please either repair the OpenAI-completions history shape as well, or narrow this classifier/claim to Responses-only.

Useful? React with 👍 / 👎.

Comment on lines +1745 to +1747
if (repairReasoningContentReplayHistory(retained)) {
if (rejectedCommitted) currentContext.messages.splice(rejectedIndex, 1);
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Roll back the emitted failed turn before retrying

When this repair branch succeeds, streamAssistantResponse() has already emitted message_end for the rejected assistant, and Agent.prompt() appends every message_end to #state.messages (packages/agent/src/agent.ts:1746-1758). Splicing only currentContext.messages means Agent users who hit this repair keep the stale error turn in durable state even if the retry succeeds, so the next prompt can replay an assistant error that the low-level loop removed. The retry path needs to suppress/rollback that emitted message or reconcile Agent state before continuing.

Useful? React with 👍 / 👎.

@Yeachan-Heo
Yeachan-Heo merged commit b014ec1 into dev Aug 5, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant