server: route unterminated reasoning to reasoning_content, not content - #665
Open
datanerdie wants to merge 1 commit into
Open
server: route unterminated reasoning to reasoning_content, not content#665datanerdie wants to merge 1 commit into
datanerdie wants to merge 1 commit into
Conversation
When generation hits the token cap before </think> arrives, the unterminated reasoning buffer was emitted as `content`, so a truncated chain-of-thought reached clients looking exactly like a finished answer. Clients that do not inspect finish_reason cannot tell the difference, and agent loops may consume abandoned reasoning as if it were a result. split_reasoning_content() cannot make this distinction on its own: an unterminated reasoning buffer and a legitimate non-thinking answer both lack </think>, and the stored text does not retain the opening <think> either. The discrimination has to happen at the two call sites that already know thinking was expected, via require_thinking_closed. Both the DeepSeek and GLM parsers had the same behaviour, so both are updated to keep them symmetric. content is set to "" rather than NULL: json_escape() dereferences its argument without a NULL check, and while the response writer guards with `text ? text : ""`, not every consumer in this file was audited. An empty answer is already unmistakably not an answer, and finish_reason plus a populated reasoning_content carry the rest of the signal. Verified on Apple M4 Max / Metal with DeepSeek-V4-Flash-0731: truncated thinking finish=length content="" reasoning=333B completed thinking finish=stop content="12 x 12 = 144" reasoning=186B non-thinking finish=stop content="12 x 12 = 144" reasoning="" The latter two are unchanged from before the patch. ds4_test reports an identical set of assertion failures with and without this change on my machine, so it introduces no new ones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Problem
When generation hits the token cap before
</think>arrives, the unterminated reasoning buffer is emitted ascontent. A truncated chain-of-thought therefore reaches clients looking exactly like a finished answer.The server already detects the condition and logs it:
…but the message it then builds is indistinguishable from a normal reply. Observed on
DeepSeek-V4-Flash-0731:finish_reasoncontentreasoning_contentstoplengthAny client that does not inspect
finish_reasonwill treat 40 KB of abandoned deliberation as the assistant's answer. That matters most for agent loops, where a delegate may consume the result without a human ever seeing it.Why not fix
split_reasoning_content()It cannot make the distinction on its own. An unterminated reasoning buffer and a legitimate non-thinking answer both lack
</think>, and the stored text does not retain the opening<think>either — so there is no discriminator in the text. It also has other call sites where the current behaviour is correct; changing it there would route ordinary non-thinking answers intoreasoning_content.The information exists only at the call sites that already know thinking was expected, via
require_thinking_closed. That is where this patch acts.Change
A small helper, used at the two
require_thinking_closed && !think_endsites — one inparse_deepseek_generated_message_ex, one inparse_glm_generated_message_ex, so the two parsers stay symmetric:Truncation becomes self-describing on the wire for every consumer, rather than only for those that check
finish_reason.On
""vsNULLcontentis set to""rather thanNULLdeliberately.json_escape()dereferences its argument without a NULL check; the response writer guards withtext ? text : "", but I did not audit every consumer. An empty answer is already unmistakably not an answer, andfinish_reasonplus a populatedreasoning_contentcarry the rest of the signal. Happy to switch to a literal JSONnullif you would prefer that — it is one further line in the response writer.The helper name carries a
ds4_local_prefix because it began as a local patch; rename as you see fit.Verification
Apple M4 Max, Metal,
DeepSeek-V4-Flash-0731q2-q4-imatrix:finishcontentreasoning_contentlength""stop"12 x 12 = 144."stop"12 x 12 = 144."""The latter two are unchanged from before the patch — including the non-thinking case, which is the one that would break if
split_reasoning_content()were changed directly.ds4_test(which#includesds4_server.c, so it compiles the change) reports an identical set of assertion failures with and without this patch on my machine — adiffof the sorted failure lists is empty. Those failures appear to be pre-existing in my environment rather than related to this change.test_q4k_dot(4/4),ds4_agent_test, andds4-eval --self-test-extractorsall pass.🤖 Generated with Claude Code