# Fixing interleaved thinking for local Claude Code

2026-07-23 · https://tomvaucourt.com/posts/llamacpp-interleaved-thinking/

There's [a thread on r/LocalLLaMA](https://www.reddit.com/r/LocalLLaMA/s/2yq8a7PraH) where someone builds a pet project with
Qwen3.5 35B A3B running inside Claude Code, tallies up what the session would
have cost on Sonnet 4.6 ($10.85, per ccusage), and lands on their actual bill:
two minutes of 400W electricity. That's the promise of pointing Claude Code at
your own hardware, and it mostly delivers.

Mostly. Because the same thread carries a recurring complaint: the model just
stops. It works for a while, makes a few tool calls, then goes silent right
where the next tool call should be. No error. The turn ends at the tail of a
thinking block, and Claude Code sits there waiting.

The workaround people converged on was to disable thinking. It works. It's
also the wrong lesson, and the actual bug sat in llama.cpp's Anthropic
compatibility layer, where I eventually found and fixed it.

## The symptom

I was running the model in Claude Code, testing how far its agentic coding
could go in a frontier harness. My first suspicion was the chat template: newly
released models ship with template bugs often enough that it's the reasonable
first guess. But the same model drove OpenClaw fine. Same weights, same server,
different harness. So I opened a Claude Code session with Opus, handed it my
llama.cpp container, and it had the cause in about fifteen minutes.

The model stopped abruptly before emitting a tool call, at the end of a
thinking block. An `<|im_end|>` where a `tool_use` should be, with empty
content. I hit it on both Qwen 3.5 models I run; nothing about it is
Qwen-specific, and without the fix it would most likely have hit Qwen 3.6 and
every other interleaved-thinking model the same way.

## What the server was actually doing

With thinking enabled, an agent's single logical turn is a chain: thinking →
tool_use → thinking → tool_use, all the way to the final answer. That's
interleaved thinking, and it's how the Anthropic API expects agentic loops to
run — Claude Code sends the whole chain back to the server on every request so
the model can see its own reasoning trail.

llama.cpp's `/v1/messages` endpoint speaks the Anthropic Messages format on
the wire — that's what Claude Code sends — but the rest of the server thinks
in OpenAI-style messages, so the endpoint's real job is translation. The
mapping is mostly mechanical: a `thinking` block should land in
`reasoning_content`, a `tool_use` block becomes an entry in `tool_calls`, and
a `tool_result` comes back as a `role: "tool"` message. Except the first one
didn't happen. The conversion dropped the thinking blocks. Every one of them:

<svg viewBox="0 0 704 296" role="img" aria-label="Diagram titled: one agent turn, Anthropic format in, OpenAI format out. Claude Code sends assistant messages with thinking and tool_use blocks and user messages with tool_result blocks. convert_anthropic_to_oai maps tool_use to tool_calls and tool_result to a role-tool message, but reasoning_content is never populated. On the next step the model emits EOS instead of a tool call" style="max-width:100%;height:auto">
  <style>
    .f1-title { font-size: 14px; font-weight: 600; fill: currentColor; }
    .f1-lbl { font-size: 12.5px; font-weight: 600; fill: currentColor; }
    .f1-role { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10.5px; fill: var(--fg-muted); }
    .f1-chip { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; fill: currentColor; }
    .f1-mutchip { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; fill: var(--fg-muted); }
    .f1-mut { font-size: 12px; fill: var(--fg-muted); }
    .f1-msg { fill: none; stroke: var(--border); }
    .f1-think { fill: var(--accent); fill-opacity: 0.12; stroke: var(--accent); }
    .f1-tool { fill: var(--code-bg); stroke: var(--border); }
    .f1-gone { fill: none; stroke: var(--fg-muted); stroke-dasharray: 4 3; }
    .f1-bad { font-size: 12px; font-weight: 600; fill: var(--accent); }
    .f1-map { stroke: var(--fg-muted); stroke-dasharray: 2 3; }
    .f1-maphead { fill: var(--fg-muted); }
  </style>
  <text x="10" y="18" class="f1-title">One agent turn: Anthropic format in, OpenAI format out</text>
  <text x="10" y="46" class="f1-lbl">What Claude Code sends (Anthropic Messages)</text>
  <text x="10" y="64" class="f1-role">assistant</text>
  <text x="184" y="64" class="f1-role">user</text>
  <text x="302" y="64" class="f1-role">assistant</text>
  <rect x="10" y="68" width="166" height="30" rx="6" class="f1-msg"/>
  <rect x="15" y="73" width="76" height="20" rx="4" class="f1-think"/><text x="53" y="87" text-anchor="middle" class="f1-chip">thinking</text>
  <rect x="97" y="73" width="76" height="20" rx="4" class="f1-tool"/><text x="135" y="87" text-anchor="middle" class="f1-chip">tool_use</text>
  <rect x="184" y="68" width="110" height="30" rx="6" class="f1-msg"/>
  <rect x="189" y="73" width="100" height="20" rx="4" class="f1-tool"/><text x="239" y="87" text-anchor="middle" class="f1-chip">tool_result</text>
  <rect x="302" y="68" width="166" height="30" rx="6" class="f1-msg"/>
  <rect x="307" y="73" width="76" height="20" rx="4" class="f1-think"/><text x="345" y="87" text-anchor="middle" class="f1-chip">thinking</text>
  <rect x="389" y="73" width="76" height="20" rx="4" class="f1-tool"/><text x="427" y="87" text-anchor="middle" class="f1-chip">tool_use</text>
  <text x="478" y="87" class="f1-mut">→ …</text>
  <line x1="53" y1="102" x2="80" y2="144" class="f1-map"/><polygon points="77,144 83,144 80,151" class="f1-maphead"/>
  <line x1="135" y1="102" x2="192" y2="144" class="f1-map"/><polygon points="189,144 195,144 192,151" class="f1-maphead"/>
  <line x1="239" y1="102" x2="288" y2="144" class="f1-map"/><polygon points="285,144 291,144 288,151" class="f1-maphead"/>
  <text x="480" y="126" class="f1-role">convert_anthropic_to_oai()</text>
  <text x="10" y="150" class="f1-role">assistant</text>
  <text x="246" y="150" class="f1-role">tool</text>
  <text x="338" y="150" class="f1-role">assistant</text>
  <rect x="10" y="156" width="228" height="30" rx="6" class="f1-msg"/>
  <rect x="15" y="161" width="130" height="20" rx="4" class="f1-gone"/><text x="80" y="175" text-anchor="middle" class="f1-mutchip">reasoning_content</text>
  <rect x="151" y="161" width="82" height="20" rx="4" class="f1-tool"/><text x="192" y="175" text-anchor="middle" class="f1-chip">tool_calls</text>
  <rect x="246" y="156" width="84" height="30" rx="6" class="f1-msg"/>
  <rect x="251" y="161" width="74" height="20" rx="4" class="f1-tool"/><text x="288" y="175" text-anchor="middle" class="f1-chip">content</text>
  <rect x="338" y="156" width="228" height="30" rx="6" class="f1-msg"/>
  <rect x="343" y="161" width="130" height="20" rx="4" class="f1-gone"/><text x="408" y="175" text-anchor="middle" class="f1-mutchip">reasoning_content</text>
  <rect x="479" y="161" width="82" height="20" rx="4" class="f1-tool"/><text x="520" y="175" text-anchor="middle" class="f1-chip">tool_calls</text>
  <text x="574" y="175" class="f1-mut">→ …</text>
  <text x="10" y="208"><tspan class="f1-lbl">The OpenAI-side result: </tspan><tspan class="f1-bad">reasoning_content never populated — the thinking is discarded ✗</tspan></text>
  <text x="10" y="242" class="f1-lbl">So on the next step</text>
  <rect x="10" y="252" width="76" height="30" rx="6" class="f1-think"/><text x="48" y="271" text-anchor="middle" class="f1-chip">thinking</text>
  <text x="102" y="271" class="f1-bad">→ EOS — turn ends where the next tool_use should be</text>
</svg>

So here's what the model saw: a transcript where its previous tool calls in
the current turn appear to have happened with no thinking lead-in, because it
was deleted. The conversion rewrites the chat history so the model believes it
never emitted thinking. Then, when it's time for the next tool call, the model
begins a fresh thinking block, trying to re-establish its plan. But the prompt
now teaches it that tool calls happen without the reasoning scaffolding. That mismatch shifts the distribution: instead of transitioning
from thinking into a tool_use, it often terminates the message right there.
EOS.

From the user's perspective that reads as "Claude Code stalls after tool
calls." The mechanical cause is that dropping interleaved thinking blocks
breaks the learned cadence that leads into the next tool call, so the model
ends its turn prematurely.

This is also why disabling thinking "fixes" it: with no thinking blocks to
drop, the tool-call sequence stays internally consistent, and the model keeps
emitting tool calls instead of bailing out. You've traded the model's
reasoning for its stability, and you shouldn't have to.

There's a template subtlety underneath that confused the diagnosis further.
Qwen's chat template doesn't keep all historical thinking: it strips `<think>`
blocks from older turns while keeping them for the current tool-use chain.
That's correct behavior: the model was trained that way. The bug was upstream
of the template: the endpoint stripped thinking *everywhere*, so the template
never even got the blocks it needed for the active chain.

<svg viewBox="0 0 704 404" role="img" aria-label="Diagram: a two-turn agent conversation as user and assistant messages. The older turn's thinking block is drawn dotted because the chat template correctly strips it; the current chain's thinking block is solid because the template keeps it. The endpoint bug dropped thinking from every assistant message, including the one the template needed." style="max-width:100%;height:auto">
  <style>
    .f2-title { font-size: 14px; font-weight: 600; fill: currentColor; }
    .f2-hdr { font-size: 12px; font-weight: 600; fill: currentColor; }
    .f2-role { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; fill: var(--fg-muted); }
    .f2-chip { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; fill: currentColor; }
    .f2-mut { font-size: 11.5px; fill: var(--fg-muted); }
    .f2-ok { font-size: 11.5px; font-weight: 600; fill: var(--fg-muted); }
    .f2-bad { font-size: 11.5px; font-weight: 600; fill: var(--accent); }
    .f2-msg { fill: none; stroke: var(--border); }
    .f2-think { fill: var(--accent); fill-opacity: 0.12; stroke: var(--accent); }
    .f2-think-stripped { fill: none; stroke: var(--accent); stroke-dasharray: 3 2.5; opacity: 0.65; }
    .f2-chip-gone { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; fill: var(--fg-muted); }
    .f2-tool { fill: var(--code-bg); stroke: var(--border); }
    .f2-txt { fill: none; stroke: var(--border); stroke-dasharray: 2 2; }
    .f2-div { stroke: var(--fg-muted); stroke-dasharray: 4 3; }
    .f2-lead { stroke: var(--border); stroke-dasharray: 2 3; }
  </style>
  <text x="10" y="18" class="f2-title">Dropping thinking: where it's correct, where it was the bug</text>
  <g transform="translate(0,28)">
  <text x="82" y="16" class="f2-role">the request history</text>
  <text x="400" y="16" text-anchor="middle" class="f2-hdr">template (correct)</text>
  <text x="580" y="16" text-anchor="middle" class="f2-hdr">endpoint (the bug)</text>
  <text x="72" y="49" text-anchor="end" class="f2-role">user</text>
  <rect x="82" y="30" width="64" height="30" rx="6" class="f2-msg"/><rect x="87" y="35" width="54" height="20" rx="4" class="f2-txt"/><text x="114" y="49" text-anchor="middle" class="f2-chip">text</text>
  <text x="72" y="89" text-anchor="end" class="f2-role">assistant</text>
  <rect x="82" y="70" width="166" height="30" rx="6" class="f2-msg"/>
  <rect x="87" y="75" width="76" height="20" rx="4" class="f2-think-stripped"/><text x="125" y="89" text-anchor="middle" class="f2-chip-gone">thinking</text>
  <rect x="167" y="75" width="76" height="20" rx="4" class="f2-tool"/><text x="205" y="89" text-anchor="middle" class="f2-chip">tool_use</text>
  <line x1="256" y1="85" x2="336" y2="85" class="f2-lead"/><text x="400" y="89" text-anchor="middle" class="f2-ok">strips thinking ✓</text>
  <text x="580" y="89" text-anchor="middle" class="f2-mut">drops thinking</text>
  <text x="72" y="129" text-anchor="end" class="f2-role">user</text>
  <rect x="82" y="110" width="110" height="30" rx="6" class="f2-msg"/><rect x="87" y="115" width="100" height="20" rx="4" class="f2-tool"/><text x="137" y="129" text-anchor="middle" class="f2-chip">tool_result</text>
  <text x="72" y="169" text-anchor="end" class="f2-role">assistant</text>
  <rect x="82" y="150" width="64" height="30" rx="6" class="f2-msg"/><rect x="87" y="155" width="54" height="20" rx="4" class="f2-txt"/><text x="114" y="169" text-anchor="middle" class="f2-chip">text</text>
  <line x1="10" y1="200" x2="694" y2="200" class="f2-div"/>
  <text x="352" y="219" text-anchor="middle" class="f2-mut">current chain — everything after the last user prompt</text>
  <text x="72" y="253" text-anchor="end" class="f2-role">user</text>
  <rect x="82" y="234" width="64" height="30" rx="6" class="f2-msg"/><rect x="87" y="239" width="54" height="20" rx="4" class="f2-txt"/><text x="114" y="253" text-anchor="middle" class="f2-chip">text</text>
  <text x="72" y="293" text-anchor="end" class="f2-role">assistant</text>
  <rect x="82" y="274" width="166" height="30" rx="6" class="f2-msg"/>
  <rect x="87" y="279" width="76" height="20" rx="4" class="f2-think"/><text x="125" y="293" text-anchor="middle" class="f2-chip">thinking</text>
  <rect x="167" y="279" width="76" height="20" rx="4" class="f2-tool"/><text x="205" y="293" text-anchor="middle" class="f2-chip">tool_use</text>
  <line x1="256" y1="289" x2="336" y2="289" class="f2-lead"/><text x="400" y="293" text-anchor="middle" class="f2-ok">keeps thinking ✓</text>
  <text x="580" y="293" text-anchor="middle" class="f2-bad">drops thinking ✗</text>
  <text x="72" y="333" text-anchor="end" class="f2-role">user</text>
  <rect x="82" y="314" width="110" height="30" rx="6" class="f2-msg"/><rect x="87" y="319" width="100" height="20" rx="4" class="f2-tool"/><text x="137" y="333" text-anchor="middle" class="f2-chip">tool_result</text>
  <text x="82" y="364" class="f2-mut">→ next assistant message: thinking, tool_use, …</text>
  </g>
</svg>

## The fix

[PR #20120](https://github.com/ggml-org/llama.cpp/pull/20120): in
`convert_anthropic_to_oai()`, collect `thinking` blocks into
`reasoning_content` on the converted message instead of discarding them, and
stop dropping assistant messages whose only content was thinking. The diff is
8 lines added, 2 removed. The tests are 133 lines.

<svg viewBox="0 0 704 108" role="img" aria-label="Diagram titled: after the fix, thinking lands in reasoning_content. The same conversion now maps the assistant message's thinking blocks into reasoning_content and tool_use into tool_calls; nothing is dropped" style="max-width:100%;height:auto">
  <style>
    .f3-title { font-size: 14px; font-weight: 600; fill: currentColor; }
    .f3-role { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10.5px; fill: var(--fg-muted); }
    .f3-chip { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; fill: currentColor; }
    .f3-ok { font-size: 11.5px; font-weight: 600; fill: var(--fg-muted); }
    .f3-msg { fill: none; stroke: var(--border); }
    .f3-think { fill: var(--accent); fill-opacity: 0.12; stroke: var(--accent); }
    .f3-tool { fill: var(--code-bg); stroke: var(--border); }
    .f3-map { stroke: var(--fg-muted); stroke-dasharray: 2 3; }
    .f3-maphead { fill: var(--fg-muted); }
  </style>
  <text x="10" y="18" class="f3-title">After the fix: thinking lands in reasoning_content</text>
  <text x="10" y="44" class="f3-role">assistant</text>
  <text x="304" y="44" class="f3-role">assistant</text>
  <rect x="10" y="50" width="166" height="30" rx="6" class="f3-msg"/>
  <rect x="15" y="55" width="76" height="20" rx="4" class="f3-think"/><text x="53" y="69" text-anchor="middle" class="f3-chip">thinking</text>
  <rect x="97" y="55" width="76" height="20" rx="4" class="f3-tool"/><text x="135" y="69" text-anchor="middle" class="f3-chip">tool_use</text>
  <line x1="184" y1="65" x2="288" y2="65" class="f3-map"/><polygon points="288,61 288,69 296,65" class="f3-maphead"/>
  <text x="237" y="97" text-anchor="middle" class="f3-role">convert_anthropic_to_oai()</text>
  <rect x="304" y="50" width="228" height="30" rx="6" class="f3-msg"/>
  <rect x="309" y="55" width="130" height="20" rx="4" class="f3-think"/><text x="374" y="69" text-anchor="middle" class="f3-chip">reasoning_content</text>
  <rect x="445" y="55" width="82" height="20" rx="4" class="f3-tool"/><text x="486" y="69" text-anchor="middle" class="f3-chip">tool_calls</text>
  <text x="544" y="69" class="f3-ok">✓ nothing dropped</text>
</svg>

It merged fast, and another user independently confirmed it also resolved the
autoparser 500 errors they'd been seeing. Those 133 lines only exist because a
maintainer reminded me to write tests. I shouldn't have needed reminding: the
whole point of a fix this small is pinning the behavior down so it can't
quietly regress.

## If your local agent "randomly" stalls

Check what your serving layer does to the conversation before the model sees
it. The failure mode generalizes: any proxy or compatibility layer that
silently edits chat history is gaslighting the model — showing it a version
of its own behavior that never happened, then acting surprised when its
next-token distribution goes somewhere strange. Reasoning models make this
worse, because the part most likely to be "cleaned up" in conversion is
exactly the part that carries the model's plan.

Model releases always come with quirks like this one, and every one of them
makes local models look weaker than they are. The only way they get fixed is
the people actually running this stuff sending the patch upstream. Fix a few,
and you find out the local models were far more capable than they looked at
first sight.
