fix(retry): make DeepSeek reasoning-content replay 400 retryable - #3835
Conversation
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
There was a problem hiding this comment.
💡 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; |
There was a problem hiding this comment.
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 👍 / 👎.
| if (repairReasoningContentReplayHistory(retained)) { | ||
| if (rejectedCommitted) currentContext.messages.splice(rejectedIndex, 1); | ||
| continue; |
There was a problem hiding this comment.
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 👍 / 👎.
Summary
DeepSeek V4 (and reasoning-capable siblings on any OpenAI-compatible proxy) reject every follow-up turn with:
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`)
Agent loop (`packages/agent`)
Test plan
Changelog