Skip to content

chat : keep unparsed Qwen3-Coder output as content instead of dropping it - #235

Open
danielhanchen wants to merge 3 commits into
qwen3-coder-unparsed-tail-upstream-basefrom
qwen3-coder-unparsed-tail
Open

danielhanchen wants to merge 3 commits into
qwen3-coder-unparsed-tail-upstream-basefrom
qwen3-coder-unparsed-tail

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Problem

In Unsloth Studio, a Qwen3.8-27B reply ended in the middle of a code block (const PIPE_SPEED: f32 =) and came back as a normal finish_reason: "stop" with no tool call and no error, so there was nothing to continue from. The server had generated about 15 more tokens than the client received, which is what the bug below produces.

The Qwen3-Coder parser (also used for Qwen3.5 / 3.6 / 3.8, Nemotron Nano 3 and StepFun) builds its top level as

reasoning << p.content(p.until_one_of(tool_call_starts)) << tool_calls

with nothing requiring the end of input. When the text after <tool_call> does not parse as a call, the final parse still succeeds on the prefix and common_chat_peg_parse maps only that prefix. The rest is silently discarded:

Model output after ...f32 = Content returned Tool calls
(EOS) ...f32 = 0
<tool_call>\n<function= then EOS ...f32 =\n (tail dropped) 0
<tool_call> then EOS ...f32 =\n (tail dropped) 0
complete call to a tool that is not in tools ...f32 =\n (whole call dropped) 0

The same happens on upstream master (checked at ed7ac35).

Reproduced against a live server with the shipped b11160 mix build: a continuation whose prefill holds an unknown call generated 19 tokens and streamed 0 characters, finish_reason: "stop".

Change

  • common/parsers/qwen3-coder.cpp: with tool_choice auto, append p.content(p.rest()) after the tool call section, so output that is not a valid call is returned as content instead of dropped. The lazy grammar is built from the trigger rule only, so it is unchanged. tool_choice required keeps the old parser, since its non-lazy grammar is built from the whole parser and would otherwise accept trailing text.
  • common/chat.cpp: a final (non-partial) parse is still lenient, so an unfinished <tool_call>\n<function= at EOS returns need-more-input and never reaches that fallback. When a final parse needs more input and recovered no tool call, retry it without the lenient flag and use that result if it covers the whole output. Formats without a fallback fail the strict parse and keep the old result. A partial call that the lenient parse did recover (for example a named call with truncated arguments) is kept as before. Also folds the duplicated mapper selection into one lambda.

Before / after

Parser output with the Qwen3.8 template and a search_conversation tool, final parse:

Case Before After
plain text, EOS unchanged unchanged
<tool_call>\n<function= at EOS tail dropped kept as content
<tool_call> at EOS tail dropped kept as content
unknown tool, complete call whole call dropped kept as content
valid call 1 tool call 1 tool call (unchanged)
valid call, arguments cut off 1 partial tool call 1 partial tool call (unchanged)

The generated grammars for tool_choice auto and required are byte-identical before and after.

End to end, the shipped b11160 CUDA server with only libllama-common rebuilt from the same source plus this patch:

Continuation Before After
plain code 81 chars, length 81 chars, length
unknown call in prefill 19 tokens generated, 0 chars delivered, stop 19 tokens, 66 chars delivered, stop

Performance

The grammar is unchanged, so sampling is unaffected. Parse cost, measured on an 8K char code reply streamed in 4 char chunks the way the server parses it (every prefix as partial, then the final parse), b11160 source with and without this PR, identical build flags, median of 6 interleaved rounds:

Reply ends with Partial parse per token, before / after Final parse, before / after
plain text 46.5 / 44.9 us 79.0 / 76.7 us
valid tool call 50.5 / 41.4 us 96.9 / 82.3 us
unknown tool call 45.8 / 46.5 us 80.6 / 80.2 us (tail now kept)
<tool_call>\n<function= at EOS 46.8 / 54.2 us 83.0 / 199.4 us (tail now kept)

The per token differences are noise on a loaded machine (the arms swap order between rounds). The strict retry runs only when the lenient result lost the end of the output, which is the last row, once per request. The second commit adds that check: without it a plain reply also ended the lenient parse on need-more-input and every final parse ran twice (79 -> 183 us).

Tests

  • New test_qwen3_coder_unparsed_tail in tests/test-chat.cpp for Qwen3.5-4B.jinja and Qwen3-Coder.jinja: the unfinished and unknown-tool tails are kept, streamed deltas add up to the final message, a valid call still parses, and text after a valid call is kept. It parses directly rather than through peg_tester, because that harness also matches the input against the triggered grammar, which rejects exactly these calls.
  • The new test fails on the base commit and passes with the change. The full test-chat passes.
  • Both commits pass the full test-chat.
  • Based on b11160, the base of the current mix build, so it carries only this change. Composed the way the nightly does (b11160, then every pin in pr-set.json in order, then this commit) it merges cleanly. On b11205 it also merges; the only conflicts there come from two existing pins (ggml-org#24423 and mtmd: test that every projector is registered and uniquely named #176).

Not covered

If the model streams the start of a valid call and then breaks its syntax (for example <parameter=query>PIPE with no newline), the server has already streamed a tool call name that the final parse no longer finds, and the request fails with Invalid diff: now finding less tool calls. That happens before and after this change and is left for a separate fix.

…g it

The Qwen3-Coder parser has no end-of-input check. When the model writes a
tool call that does not parse (a tool that is not offered, or a <tool_call>
cut off by EOS), the final parse succeeds on the text before it and the rest
is dropped. The client gets a reply that stops mid-sentence with
finish_reason "stop" and no tool call.

- qwen3-coder: with tool_choice auto, keep anything after the tool call
  section as content. The lazy grammar is built from the trigger rule only,
  so it is unchanged. tool_choice required is left alone.
- common_chat_peg_parse: a final parse that still needs more input and
  recovered no tool call is retried without the lenient flag, so the
  fallback above can take the unfinished call.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-28T11:20:09.432696Z 1ba5b7b PR opened
🔒 Security Review ✅ Completed 2026-09-28T11:19:32.890687Z 1ba5b7b PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: 1ba5b7b7bf

ℹ️ 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".

Comment thread common/chat.cpp
common_chat_msg strict_msg;
strict_msg.role = "assistant";
make_mapper(strict_msg)->from_ast(strict_ctx.ast, strict);
msg = std::move(strict_msg);

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 Preserve truncated reasoning during the strict retry

When a reasoning-capable Qwen response reaches EOS or max_tokens before </think>, the lenient parse has the prefix in reasoning_content and no tool call, so this retry runs; the strict parser rejects the incomplete reasoning branch, accepts the raw-content fallback, and this assignment replaces the parsed reasoning with <think>... in normal assistant content. Non-streaming responses consequently expose reasoning as the answer, while streaming responses emit it again as content after already emitting it as reasoning. Only use the fallback when it does not reclassify fields already recovered by the lenient parse, or scope it specifically to the unparsed tool-call-tail case.

Useful? React with 👍 / 👎.

Comment thread common/chat.cpp Outdated

// a final parse that needs more input ended inside a construct and drops the rest
// retry strictly so a parser with a fallback for it (e.g. qwen3-coder) can keep the whole output
if (!is_partial && result.need_more_input() && msg.tool_calls.empty()) {

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 Retry malformed tails after earlier valid tool calls

When parallel_tool_calls is enabled and a valid call is followed by a tail cut inside the next call, such as </tool_call>\n<tool_call>\n<function= at EOS, the lenient repetition returns need_more_input with the first call already mapped. This condition then skips the strict retry solely because msg.tool_calls is nonempty, so the new trailing rest() parser is never reached and the incomplete second tail is still silently discarded. The retry should also be considered when existing tool calls are present, provided the strict result preserves those calls.

Useful? React with 👍 / 👎.

A plain reply also ends the lenient parse on need-more-input, so the strict
retry ran on every request and doubled the final parse (about 80 -> 190 us
on an 8K char reply). Skip it when the output still ends with the parsed
content or reasoning, which means nothing was dropped. The retry now runs
only for output cut inside a construct, such as <tool_call>\n<function= at
EOS.

This branch has not been deployed

No deployments
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