Give a routed turn back the reasoning its target produced - #1173
Merged
Conversation
A DeepSeek-routed session dies mid-task with "The `reasoning_content` in the thinking mode must be passed back to the API." DeepSeek's thinking mode refuses a replayed assistant turn that carries tool calls without the reasoning it produced for that turn — measured: the same turn is accepted the moment the field is present, even when it is empty. The router could not supply it. The chat-completions dialect dropped reasoning in both directions: responses were decoded without it, and canonical thinking blocks were discarded when emitting a request. The harness cannot cover for that either — Anthropic-speaking Claude Code has no field for another vendor's reasoning, so by the time it replays the conversation the reasoning is gone. DeepSeek recovers it from a recently issued tool-call id for a while, which is why a session works for several turns and then fails for good. So the router keeps it. Reasoning a target streams is captured against that turn's tool-call ids and restored onto the turn when the harness replays it. A turn the harness carried reasoning for is left alone; a turn no longer remembered is sent with an empty reasoning, which the target accepts — nothing is invented. The memory is bounded per session and the reasoning is never re-encoded into what the harness reads. Marked per target, measured rather than assumed (spec 0160's rule), and an arm that needs it always rebuilds the request body, since byte forwarding cannot add a field. Verified against the live API: the exact body the router now emits is accepted, and the same body with the reasoning stripped reproduces the 400.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
A session pinned to DeepSeek from the native picker fails mid-task with:
Root cause
Measured against the live API, not read from the docs:
reasoning_contentreasoning_contentreasoning_content, tool-call id DeepSeek issued and still resolvesreasoning_contentreasoning_contentDeepSeek v4 is thinking-by-default, and it refuses a replayed tool-calling turn whose reasoning it cannot see. Every such turn needs the field — reasoning on the last turn only still 400s.
The router could not supply it. In the chat-completions dialect, reasoning was dropped in both directions:
reasoning_contentwas never decoded from a target's stream, and canonical thinking blocks were discarded when emitting a request. The harness cannot cover for it either — Claude Code speaks Anthropic, which has no field for another vendor's reasoning, so it is gone by the time the conversation is replayed.That leaves DeepSeek's own recovery from a recently issued tool-call id, which holds early in a conversation and lapses later — exactly the "worked for a few turns, then failed permanently" shape reported.
The fix
The router is the only participant that sees both the target's response and the request that replays it, so it keeps the reasoning:
Marked per target and measured rather than assumed (spec 0160's rule); an arm that needs the echo always rebuilds the request body, since byte-forwarding cannot add a field. Recorded as
specs/0181-routed-turns-keep-their-reasoning.md.Verification
The exact body the patched router emits for a Claude-Code-shaped conversation was posted to the live DeepSeek API:
The restored turn carries the reasoning DeepSeek actually produced; a turn outside the memo carries
"".Full workspace suite green (one unrelated CLI timing test flaked under concurrent build load and passes on its own).
Binaries
Only
crates/daemonchanges → the relevant binary isconstruct(the daemon lives in it):/Users/moon/agentd/.claude/worktrees/router-echo-reasoning/target/debug/constructNo user-visible surface changed, so no recording.