Skip to content

Fix Qwen3.5 reader-stage failure: apply enable_thinking suppression manually - #58

Merged
hardcoreerik merged 4 commits into
masterfrom
fix/qwen35-thinking-suppression
Jul 15, 2026
Merged

Fix Qwen3.5 reader-stage failure: apply enable_thinking suppression manually#58
hardcoreerik merged 4 commits into
masterfrom
fix/qwen35-thinking-suppression

Conversation

@hardcoreerik

@hardcoreerik hardcoreerik commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Summary

  • CF-7 reader calls against Qwen3.5-9B reported 0/128 segment acceptance on every run, even after PR Fix NoKvSlot/OOM crash on recurrent-architecture models (Qwen3.5) #56's SeqMax runtime fix. Root cause: Qwen3.5's GGUF chat template implements the standard enable_thinking opt-out pattern (append an empty, pre-closed <think></think> seed unless enable_thinking is explicitly true) — but LLamaSharp's LLamaTemplate (a minimal Jinja subset) renders through <|im_start|>assistant\n without error, never evaluating that trailing conditional. With no seed, the model free-generates its trained default: full reasoning mode, consuming the reader's entire ~2000-token completion budget before any JSON is emitted.
  • The previously-reported "16/120 and 1/120, both NO-GO" Qwen3.5 scores were not a capability measurement — with zero segments ever ingested, B3 could only "pass" the 16 Unanswerable questions in the held-out set (correctly abstaining), so the score just reflected the Unanswerable-question count, not model capability. ModelAdmissionGate had already flagged this exact risk when admitting Qwen3.5 as Provisional ("Visible reasoning traces can consume the response budget or precede the required JSON object").
  • Fix: LLamaSharpRuntime.SupportsThinkingSuppression detects the pattern generically from the raw tokenizer.chat_template metadata (not a filename/family check — covers future reasoning models), and ApplyThinkingSuppression appends the same empty think-block the official template would render. Both are pure/static functions with direct unit test coverage.
  • Docs: CONTEXT_FABRIC_BUG_HISTORY.md §7c (new failure mode), CONTEXT_FABRIC_INFRASTRUCTURE_NOTES.md compatibility table updated.

Test plan

  • dotnet test — 541/541 pass (7 new tests for the two pure functions)
  • Live 3-question smoke test against Qwen3.5-9B-Q8_0: segment acceptance 0/128 → 123/128, <think>-prefixed outputs 128/128 → 0/128, mean completion tokens 2054 → 294 (~7x faster per reader call)
  • Full 120-question re-score (tracked separately — this PR is the infra fix, not the re-run)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Improved support for thinking-mode models by automatically suppressing unnecessary reasoning blocks during prompt generation.
    • Enhanced compatibility with Qwen3.5-style chat templates, improving segment acceptance and reducing unwanted <think> output.
  • Documentation

    • Added guidance for diagnosing thinking-mode compatibility issues and validating model behavior.
    • Updated model compatibility notes and troubleshooting recommendations.

…anually

CF-7 reader calls against Qwen3.5-9B were reporting 0/128 segment
acceptance on every run despite the SeqMax runtime fix (PR #56). Root
cause: Qwen3.5's own GGUF chat template implements the standard
enable_thinking opt-out pattern -- its trailing add_generation_prompt
block appends an empty, pre-closed <think></think> seed whenever
enable_thinking isn't explicitly true, causing the model to skip straight
to its real answer. LLamaSharp's LLamaTemplate (a minimal Jinja subset)
renders through "<|im_start|>assistant\n" without error but never
evaluates that trailing conditional, so the seed never gets applied and
the model free-generates its trained default: full reasoning mode,
consuming the reader's entire ~2000-token completion budget before any
JSON is emitted.

The previously-reported "16/120 and 1/120, both NO-GO" scores were not a
capability measurement -- with zero segments ever ingested, B3 could only
"pass" the 16 Unanswerable questions in the held-out set (correctly
abstaining), meaning the score just reflected how many questions are
Unanswerable, not model capability. ModelAdmissionGate had already
flagged this exact risk when admitting Qwen3.5 as Provisional: "Visible
reasoning traces can consume the response budget or precede the required
JSON object."

Fix: detect enable_thinking support generically from the raw
tokenizer.chat_template metadata (not a filename/family check, so it
covers future reasoning models using the same pattern) and append the
same empty think-block the official template would render. Both new
functions are pure/static with direct unit test coverage.

Verified live (Qwen3.5-9B-Q8_0, 3-question smoke test): segment
acceptance 0/128 -> 123/128, <think>-prefixed outputs 128/128 -> 0/128,
mean completion tokens 2054 -> 294 (~7x faster per call, since none of
the completion budget is wasted on reasoning).
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@hardcoreerik, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 24 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cadfb8e9-2d5a-4c9c-a447-004c4706da14

📥 Commits

Reviewing files that changed from the base of the PR and between 3ddcf74 and 7a1cd6f.

📒 Files selected for processing (3)
  • OrchestratorIDE.UnitTests/LLamaSharpRuntimeThinkingSuppressionTests.cs
  • OrchestratorIDE/Core/Runtime/LLamaSharpRuntime.cs
  • docs/CONTEXT_FABRIC_BUG_HISTORY.md
📝 Walkthrough

Walkthrough

Changes

Thinking suppression

Layer / File(s) Summary
Template detection and runtime state
OrchestratorIDE/Core/Runtime/LLamaSharpRuntime.cs, OrchestratorIDE.UnitTests/LLamaSharpRuntimeThinkingSuppressionTests.cs
The runtime caches enable_thinking template support, resets it on disposal, and tests supported and unsupported template inputs.
Rendered prompt suppression
OrchestratorIDE/Core/Runtime/LLamaSharpRuntime.cs, OrchestratorIDE.UnitTests/LLamaSharpRuntimeThinkingSuppressionTests.cs
Rendered prompts conditionally receive an empty <think> block when ending in a newline; tests cover malformed tails and repeated manual application.
Model compatibility records
docs/CONTEXT_FABRIC_BUG_HISTORY.md, docs/CONTEXT_FABRIC_INFRASTRUCTURE_NOTES.md
Documentation records the thinking-mode failure, suppression behavior, Qwen3.5 verification results, and diagnostic guidance.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GGUF
  participant LLamaSharpRuntime
  participant ApplyEmbeddedTemplate
  participant RenderedPrompt
  GGUF->>LLamaSharpRuntime: tokenizer.chat_template
  LLamaSharpRuntime->>ApplyEmbeddedTemplate: cached suppression decision
  ApplyEmbeddedTemplate->>RenderedPrompt: render prompt
  ApplyEmbeddedTemplate->>RenderedPrompt: append empty think block when prompt ends with newline
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: manually applying Qwen3.5 enable_thinking suppression to fix reader-stage failures.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/qwen35-thinking-suppression

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
OrchestratorIDE/Core/Runtime/LLamaSharpRuntime.cs (1)

502-504: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make suppression idempotent.

ApplyThinkingSuppression unconditionally appends the block if the string ends with a newline, meaning it will double-append if called twice or if the prompt already has the suppression block. While the runtime currently only calls it once per render, making it idempotent is safer and prevents accidental double-append bugs in the future.

♻️ Proposed refactor
     internal static string ApplyThinkingSuppression(string renderedPrompt) =>
-        renderedPrompt.EndsWith('\n') ? renderedPrompt + "<think>\n\n</think>\n\n" : renderedPrompt;
+        renderedPrompt.EndsWith('\n') && !renderedPrompt.EndsWith("<think>\n\n</think>\n\n", StringComparison.Ordinal)
+            ? renderedPrompt + "<think>\n\n</think>\n\n"
+            : renderedPrompt;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@OrchestratorIDE/Core/Runtime/LLamaSharpRuntime.cs` around lines 502 - 504,
Update ApplyThinkingSuppression so it first detects whether renderedPrompt
already ends with the suppression block and returns it unchanged in that case;
otherwise preserve the existing newline-gated append behavior.
OrchestratorIDE.UnitTests/LLamaSharpRuntimeThinkingSuppressionTests.cs (1)

79-91: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Align test with idempotency refactor.

The current test name (Idempotent_Guard_Does_Not_Apply_Twice_Manually) contradicts its assertions (which verify that the method double-appends and is not idempotent).

If you apply the idempotency refactor suggested in LLamaSharpRuntime.cs, update this test to actually assert idempotency.

♻️ Proposed refactor
     [Test]
-    public void ApplyThinkingSuppression_Idempotent_Guard_Does_Not_Apply_Twice_Manually()
+    public void ApplyThinkingSuppression_IsIdempotent_DoesNotApplyTwice()
     {
-        // Not a code guarantee (the runtime only calls this once per render via the
-        // cached _templateSupportsThinkingSuppression flag) -- documents that calling it
-        // twice WOULD double-append, so any future caller must not do that.
         var once = LLamaSharpRuntime.ApplyThinkingSuppression("<|im_start|>assistant\n");
         var twice = LLamaSharpRuntime.ApplyThinkingSuppression(once);
 
-        Assert.That(twice, Is.Not.EqualTo(once));
-        Assert.That(twice, Does.Contain("<think>\n\n</think>\n\n<think>\n\n</think>\n\n"));
+        Assert.That(twice, Is.EqualTo(once));
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@OrchestratorIDE.UnitTests/LLamaSharpRuntimeThinkingSuppressionTests.cs`
around lines 79 - 91, The test
ApplyThinkingSuppression_Idempotent_Guard_Does_Not_Apply_Twice_Manually must
match the idempotent behavior of LLamaSharpRuntime.ApplyThinkingSuppression:
update the implementation as needed so applying suppression twice produces the
same result as once, then revise the test name, comments, and assertions to
verify equality and prevent duplicate suppression markers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@OrchestratorIDE.UnitTests/LLamaSharpRuntimeThinkingSuppressionTests.cs`:
- Around line 79-91: The test
ApplyThinkingSuppression_Idempotent_Guard_Does_Not_Apply_Twice_Manually must
match the idempotent behavior of LLamaSharpRuntime.ApplyThinkingSuppression:
update the implementation as needed so applying suppression twice produces the
same result as once, then revise the test name, comments, and assertions to
verify equality and prevent duplicate suppression markers.

In `@OrchestratorIDE/Core/Runtime/LLamaSharpRuntime.cs`:
- Around line 502-504: Update ApplyThinkingSuppression so it first detects
whether renderedPrompt already ends with the suppression block and returns it
unchanged in that case; otherwise preserve the existing newline-gated append
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 63a669bc-0ef5-4ec2-9c11-259c9d083e00

📥 Commits

Reviewing files that changed from the base of the PR and between 6622a38 and 3ddcf74.

📒 Files selected for processing (4)
  • OrchestratorIDE.UnitTests/LLamaSharpRuntimeThinkingSuppressionTests.cs
  • OrchestratorIDE/Core/Runtime/LLamaSharpRuntime.cs
  • docs/CONTEXT_FABRIC_BUG_HISTORY.md
  • docs/CONTEXT_FABRIC_INFRASTRUCTURE_NOTES.md

1. ApplyThinkingSuppression is now idempotent: no-ops if the prompt
   already contains a <think> marker, guarding against double-application
   and against a future LLamaSharp version that DOES evaluate the
   template's own seed.
2. Wrapped the thinking-suppression detection/application in its own
   try/catch inside ApplyEmbeddedTemplate. A failure there was previously
   uncaught, propagating to BuildPromptForLoadedModel's outer catch, which
   treats ANY exception from this method as "the embedded template
   doesn't work" and permanently falls back to ChatML for the rest of the
   session -- a much worse regression than just skipping suppression once.
3. Bug History §7c claimed PR #58 included "the first full-scale re-run"
   -- it doesn't yet; corrected to match Infrastructure Notes, which
   already said the re-score hadn't run.
…eview)

SupportsThinkingSuppression previously matched on "enable_thinking" alone
-- a template could reference that variable name for unrelated semantics
or a differently-shaped seed, and blindly appending Qwen's exact
<think>\n\n</think>\n\n text would be wrong for such a template, not just
redundant. Now requires the literal seed text too, tying detection
directly to the mechanism being replicated. Re-verified live against
Qwen3.5-9B-Q8_0: identical results (123/128 segment acceptance, 0/128
think-prefixed, 294 mean completion tokens) confirming the tightened
check still matches the real GGUF template.
…rage (grok review)

1. ApplyThinkingSuppression's idempotency guard scanned the WHOLE prompt
   for "<think>" -- any earlier message (conversation history, a document
   being analyzed) mentioning that literal text for unrelated reasons
   would false-positive and skip suppression entirely, re-enabling full
   reasoning mode for the whole call. Narrowed to check only the trimmed
   tail, which is the actual idempotency concern (double-application /
   template's own seed).
2. Bug History §7c described detection as matching only the
   "enable_thinking" marker; code (8cc8920) already requires both that
   AND the literal empty-seed text. Corrected the narrative.
3. Added regression tests: dual-gate rejects enable_thinking-present/
   seed-absent templates, and the tail-only fix (item 1) doesn't
   false-positive on think-mentioning history content.
@hardcoreerik

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@hardcoreerik
hardcoreerik merged commit 8efa7fd into master Jul 15, 2026
2 checks passed
@hardcoreerik
hardcoreerik deleted the fix/qwen35-thinking-suppression branch July 18, 2026 02:23
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