<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Silent Tool Call Drop on RockB</title><link>https://baeseokjae.github.io/tags/silent-tool-call-drop/</link><description>Recent content in Silent Tool Call Drop on RockB</description><image><title>RockB</title><url>https://baeseokjae.github.io/images/og-default.png</url><link>https://baeseokjae.github.io/images/og-default.png</link></image><generator>Hugo</generator><language>en-us</language><lastBuildDate>Thu, 29 Oct 2026 00:17:00 +0000</lastBuildDate><atom:link href="https://baeseokjae.github.io/tags/silent-tool-call-drop/index.xml" rel="self" type="application/rss+xml"/><item><title>Ollama's Silent Tool-Call Failures: Conversation-History Round-Trip, Dropped Calls and Retyped Arguments</title><link>https://baeseokjae.github.io/posts/ollama-tool-call-history-corruption-2026/</link><pubDate>Thu, 29 Oct 2026 00:17:00 +0000</pubDate><guid>https://baeseokjae.github.io/posts/ollama-tool-call-history-corruption-2026/</guid><description>Four open Ollama reports: tool calls vanish or come back retyped — empty turns with done_reason stop, an integer returned as &amp;#34;30000.0&amp;#34;, prose-only replies.</description><content:encoded><![CDATA[<p>An agent loop calls a local Ollama server, the HTTP status is 200, the stream ends cleanly, and the assistant message contains nothing: no <code>content</code>, no <code>tool_calls</code>, <code>done_reason: &quot;stop&quot;</code>. The loop treats that as &ldquo;the model is done&rdquo; and stops. Nothing was done. No error was raised.</p>
<p>That is the failure this article is about. It is not one bug with one cause. Four separate, currently open reports against Ollama between versions 0.34.1 and 0.40.8 describe four different ways a tool call disappears or comes back mutated, and the source of tag v0.40.12 shows which of those paths are still present in the newest release at the time of writing.</p>
<p>The evidence boundary matters, so state it up front: four public issue reports written by other people, plus reads of the tagged source. No Ollama server was started for this article, so nothing here is a benchmark, and nothing here is our own reproduction. Where a number is quoted, it is the reporter&rsquo;s own count on their own harness, at the version they named.</p>
<h2 id="the-signature-to-recognise-before-anything-else">The signature to recognise before anything else</h2>
<p>A dropped tool call and a genuinely finished turn look identical in most clients, because both are <code>done_reason: &quot;stop&quot;</code> with an assistant message. The distinguishing field is <code>eval_count</code>. A dropped call still generated tokens: the model wrote a call, the server failed to return it as <code>tool_calls</code>, and the tokens are counted. The reporter on <code>qwen3:8b</code> saw <code>eval_count</code> between 20 and 160 on replies whose <code>content</code> and <code>tool_calls</code> were both empty.</p>
<p>So the operational rule is narrow and testable: an assistant turn with empty <code>content</code>, no <code>tool_calls</code>, <code>done_reason</code> of <code>stop</code> and a non-zero <code>eval_count</code> is a server-side loss, not a completed task. A turn with <code>eval_count: 0</code> may genuinely be empty output. Most agent frameworks collapse both into &ldquo;finished&rdquo; and move on.</p>
<p>The rest of this article is the four paths that produce the first case, then the guard.</p>
<h2 id="the-four-paths-in-one-table">The four paths in one table</h2>
<table>
	<thead>
			<tr>
					<th>Path</th>
					<th>Trigger</th>
					<th>Wire symptom</th>
					<th>Server signal</th>
					<th>Reported on</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>History retyping</td>
					<td>An integer argument from an earlier tool call is replayed through history</td>
					<td>Next call returns the argument as the string <code>&quot;30000.0&quot;</code>, client schema validation rejects it, and keeps rejecting it</td>
					<td>none</td>
					<td>0.40.8, <code>flint:40b-a2b-qat-int4</code></td>
			</tr>
			<tr>
					<td>Unregistered tool</td>
					<td>Model calls a function name that is not in the request&rsquo;s <code>tools</code> array</td>
					<td><code>content</code> empty, <code>tool_calls</code> absent, <code>eval_count</code> above zero</td>
					<td>none</td>
					<td>0.40.7 (also 0.40.4/0.40.5), <code>qwen3:8b</code>; 0.40.8, <code>qwen3:0.6b</code></td>
			</tr>
			<tr>
					<td>Unclosed list in arguments</td>
					<td>gemma4 leaves the <code>edits</code> list unclosed in a tool call</td>
					<td>Empty assistant turn, no error field</td>
					<td><code>gemma4 tool call parsing failed</code></td>
					<td>0.40.6 (Windows/CUDA), <code>gemma4:e4b</code></td>
			</tr>
			<tr>
					<td>Preamble before the call</td>
					<td>System prompt asks for prose, then a call</td>
					<td>Prose only, <code>tool_calls: null</code>, <code>done_reason: stop</code></td>
					<td>none</td>
					<td>0.34.1, <code>qwen3.8:27b-mlx</code></td>
			</tr>
	</tbody>
</table>
<p>Two of the four are parser or renderer defects. One is a type loss in the API decode that only becomes visible through a renderer. One is a model- and build-specific suppression that has not been shown to exist on newer versions. Treating them as a single &ldquo;Ollama drops tool calls&rdquo; defect would be wrong, and the scope of each is the reason the fixes are tracked as separate pull requests.</p>
<h2 id="path-1-history-replay-retypes-an-integer">Path 1: history replay retypes an integer</h2>
<p>The first report (<a href="https://github.com/ollama/ollama/issues/19191">issue #19191</a>) is precise enough to read as a specification. With <code>flint:40b-a2b-qat-int4</code> on Ollama 0.40.8 (<code>f580b54</code>), a request whose history contains an earlier tool call carrying <code>timeout_ms: 30000</code> returns the next call with <code>{&quot;command&quot;:&quot;pwd&quot;,&quot;timeout_ms&quot;:&quot;30000.0&quot;}</code>. Without that earlier call in history, the same model returns proper integers on nine of nine runs; with it, the wrong form appears on three of three.</p>
<p>Two layers produce that, and separating them matters if you are deciding what to patch.</p>
<p>The decode layer loses the integer type before any model sees the prompt. In <a href="https://github.com/ollama/ollama/blob/v0.40.12/api/types.go"><code>api/types.go</code> at tag v0.40.12</a>, <code>ToolCallFunctionArguments.UnmarshalJSON</code> rebuilds the arguments with <code>json.Unmarshal</code> into an ordered map, which decodes every JSON number as <code>float64</code>. <code>Message.UnmarshalJSON</code> wraps its alias type, so this path runs for every history message that carries tool calls. This is type loss, not value loss: <code>float64</code> holds integers exactly far beyond any plausible timeout value, but the fact that the value was an integer is gone by the time a renderer formats it.</p>
<p>The render layer decides whether the type loss becomes visible text. In <a href="https://github.com/ollama/ollama/blob/v0.40.12/model/renderers/flint_json.go"><code>model/renderers/flint_json.go</code> at the same tag</a>, <code>flintJSONValue</code> handles floats itself: it formats with <code>strconv.FormatFloat(..., 'g', -1, ...)</code>, appends <code>.0</code> whenever the result contains no <code>.</code>, <code>e</code> or <code>E</code>, and returns the string as a <code>json.Number</code>, so a <code>float64</code> of <code>30000</code> is turned into the literal <code>30000.0</code> before the shared writer sees it. <a href="https://github.com/ollama/ollama/blob/v0.40.12/model/renderers/flint.go"><code>flint.go</code></a> sends every non-string tool-call argument through that conversion on the way into <code>&lt;arg_value&gt;</code>, which is how <code>30000</code> becomes <code>&lt;arg_value&gt;30000.0&lt;/arg_value&gt;</code> in the Flint prompt. Flint then copies the form it was shown, and returns it as a string.</p>
<p>That <code>.0</code> is this renderer&rsquo;s, not a rule of Ollama&rsquo;s JSON handling in general. <code>pythonScalar</code> in <a href="https://github.com/ollama/ollama/blob/v0.40.12/model/renderers/json.go"><code>model/renderers/json.go</code> at the same tag</a> re-reads the marshalled value with <code>UseNumber</code> and returns a number literal that contains no <code>.</code>, <code>e</code> or <code>E</code> unchanged; only a literal that does contain one is parsed to <code>float64</code>, formatted with <code>'f'</code>, and given a trailing <code>.0</code> when that result has no dot. A raw <code>float64</code> of <code>30000</code> marshals to <code>30000</code> and comes out as <code>30000</code> there.</p>
<p>The downstream damage is durable rather than transient. Once a call carrying the string form is appended to history, every later call that sets the same argument is rendered from the same mutated history and fails the same validation, so an agent that survived turn two dies on turn three and stays dead for the session. The reporter&rsquo;s client rejected it with <code>invalid type: string &quot;30000.0&quot;, expected u64</code>, and their shell tool could not run any command for the rest of the session.</p>
<p>Both halves are addressed in open pull requests, neither merged into v0.40.12: <code>api: preserve integer types in decoded tool call arguments</code> (<a href="https://github.com/ollama/ollama/pull/19195">PR #19195</a>) switches the decode to preserve number representation, and <code>Fix tool calls rendering integer arguments as decimals</code> (<a href="https://github.com/ollama/ollama/pull/19252">PR #19252</a>) stops the renderer from rewriting the numeric form. If your client validates argument types, check your server version against both before assuming a fix is present.</p>
<h2 id="path-2-the-call-names-a-tool-that-is-not-in-tools">Path 2: the call names a tool that is not in <code>tools</code></h2>
<p>The second path is the one that most often hides inside a working agent, because the trigger is a design pattern people adopt deliberately: a tool offered on demand, named inside another tool&rsquo;s description, rather than listed in the request&rsquo;s <code>tools</code> array.</p>
<p>The report is <a href="https://github.com/ollama/ollama/issues/19187">issue #19187</a>. On Ollama 0.40.7 with <code>qwen3:8b</code>, <code>/api/chat</code> streaming, <code>num_ctx</code> 32768, a call to such a name returned no <code>content</code> and no <code>tool_calls</code>, with <code>eval_count</code> between 20 and 160. Replaying sixteen captured requests, the reporter got that empty reply on 28 of 32 runs. Generating raw text for the same requests with the model&rsquo;s own template produced a well-formed <code>&lt;tool_call&gt;</code> to the unlisted name every time, and adding the missing names to <code>tools</code> with empty schemas restored the call on 30 of 32. The model was calling; the server was dropping the call on the way out.</p>
<p>An independent reporter <a href="https://github.com/ollama/ollama/issues/19187#issuecomment-6333800763">reproduced it on 0.40.8</a> (Linux, CPU) with <code>qwen3:0.6b</code>, and their counts are cleaner: with the target tool absent from <code>tools</code>, eight of eight seeds returned no content and no <code>tool_calls</code> with <code>eval_count</code> 25; with it listed, eight of eight returned the call. They saw the same empty reply through <code>/v1/chat/completions</code>, with <code>finish_reason</code> <code>stop</code>, so switching endpoints does not route around it.</p>
<p>The mechanism proposed in <a href="https://github.com/ollama/ollama/pull/19240">PR #19240</a> is that the parser buffers a tagged call for a name it does not recognise, and the content accessor returns an empty string for non-JSON tags, so the API emits a successful response with neither content nor tool calls. That is the contributor&rsquo;s account, not a path confirmed by reading v0.40.12, so treat it as the best available explanation rather than a verified one. The PR is open.</p>
<p>The practical consequence: if your agent exposes tools dynamically by mentioning them in a description, the request that &ldquo;should&rdquo; work will silently produce nothing, and the retry loop will keep producing nothing. Listing the tool, even with an empty parameter schema, is the difference between 28 of 32 empty and 30 of 32 working.</p>
<h2 id="path-3-the-repair-closes-braces-never-brackets">Path 3: the repair closes braces, never brackets</h2>
<p>The third path is in gemma4 argument parsing (<a href="https://github.com/ollama/ollama/issues/19273">issue #19273</a>), and it is the clearest case of a one-line log message that looks harmless. The reporter&rsquo;s build is Ollama 0.40.6 on Windows with CUDA, and they note that <code>model/parsers/gemma4.go</code> has not changed since commit <code>a9d8953</code> (2026-09-22), so their observation extends to 0.40.11.</p>
<p>A gemma4 call that leaves the <code>edits</code> list unclosed still ends with its closing tag, so the model finished the call. The parser&rsquo;s repair candidates close a string delimiter, and they add a missing <code>}</code> only when another repair already changed the text. At v0.40.12, in <a href="https://github.com/ollama/ollama/blob/v0.40.12/model/parsers/gemma4.go"><code>model/parsers/gemma4.go</code></a>, the object-close repair returns immediately when the argument text already ends in <code>}</code>, and no repair step inserts a <code>]</code> at all. So a call of the shape <code>call:edit{edits:[{newText:S,oldText:S,path:S}</code> has nothing in the repair path that can rescue it. The parser logs <code>gemma4 tool call parsing failed</code> and returns a reply with no <code>content</code>, no <code>tool_calls</code>, <code>done_reason: &quot;stop&quot;</code>, and no error field.</p>
<p>The reporter&rsquo;s counts come from five days of agent evals with <code>gemma4:e4b</code> through OpenClaw: 30 dropped <code>edit</code> calls in the server log, every one leaving the <code>edits</code> list unclosed, across 16 failed runs in 28 rounds. Their four observed call shapes split on what is missing — <code>path</code> inside the item with both <code>]</code> and the outer <code>}</code> absent (20 occurrences), <code>]</code> missing before <code>,path</code> (5), <code>]</code> and outer <code>}</code> missing (4), and one case with two items where only the <code>]</code> was missing. Those are one reporter&rsquo;s task mix, not a benchmark, but the shape distribution is the useful part: a single missing bracket, not a mangled call.</p>
<p><a href="https://github.com/ollama/ollama/pull/19274">PR #19274</a> adds a repair that closes the lists a finished gemma4 call leaves open. It is open. Until it lands, a gemma4 agent should treat the log line <code>gemma4 tool call parsing failed</code> as an incident marker and re-ask, not as noise.</p>
<h2 id="path-4-the-announce-then-call-pattern-on-an-mlx-build">Path 4: the announce-then-call pattern on an MLX build</h2>
<p>The fourth path is the narrowest and the one most likely to be a version artifact, so it is stated with its environment attached.</p>
<p>The report is <a href="https://github.com/ollama/ollama/issues/19353">issue #19353</a>. On <code>qwen3.8:27b-mlx</code> with Ollama 0.34.1 (macOS 26.5.2, Apple M4 Pro, 48 GB, <code>qwen3_5</code> architecture, nvfp4), a system prompt that asks the model to say something in natural language before calling the tool produces prose only: <code>tool_calls</code> is <code>null</code> and <code>done_reason</code> is <code>stop</code>. The same request with a tool-directive prompt returns parsed calls, and a control model, <code>qwen3:4b</code>, returns <code>finish_reason: tool_calls</code> with a non-empty preamble on three of four runs of the identical prompt. Only the system prompt differs between the two cases.</p>
<p>This is the shape of an announce-then-call instruction, which is a common agent pattern, so the report matters even though the version does not overlap the 0.40.x series. What it does not support is any claim about v0.40.12. Nothing here shows the behaviour reproduced on a newer build, and no source shows it fixed. If you run a preamble-style prompt on an MLX build, check this first; if you run it on a current CPU or CUDA build, you have no evidence either way from this report.</p>
<h2 id="the-guard-an-empty-turn-is-an-error-not-a-completion">The guard: an empty turn is an error, not a completion</h2>
<p>Everything above is detectable client-side without patching the server, because the failure is a specific shape on the wire rather than an exception. The whole check is small enough to paste into the response handler:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">lost_tool_call</span>(msg, done_reason, eval_count):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> (done_reason <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;stop&#34;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">and</span> <span style="color:#f92672">not</span> (msg<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;content&#34;</span>) <span style="color:#f92672">or</span> <span style="color:#e6db74">&#34;&#34;</span>)<span style="color:#f92672">.</span>strip()
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">and</span> <span style="color:#f92672">not</span> msg<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;tool_calls&#34;</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">and</span> (eval_count <span style="color:#f92672">or</span> <span style="color:#ae81ff">0</span>) <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">0</span>)
</span></span></code></pre></div><p>When that predicate is true, treat the turn as a transport-level failure for the loop&rsquo;s purposes: do not append it to history as an assistant completion, do not fall through to a final answer, and either re-ask with the tool registered in <code>tools</code> or surface an error to the caller. Appending the empty message is the specific mistake that turns a recoverable drop into a session-ending stop, because the next request then replays a history in which the model appears to have said nothing.</p>
<p>Three supporting behaviours make the guard usable in practice:</p>
<ul>
<li>Log <code>done_reason</code> and <code>eval_count</code> on every turn, not only on errors. Without <code>eval_count</code> you cannot tell a dropped call from a genuinely empty reply, and you will spend the debugging session on the model.</li>
<li>Validate argument types against the tool schema on the way in, before you append a call to history. The integer-to-string mutation survives forever once replayed, so the cheapest place to catch it is the moment it arrives.</li>
<li>Before concluding a fix is missing or present, read the server version and compare it with the open pull requests listed above. The <a href="https://github.com/ollama/ollama/releases/tag/v0.40.12">v0.40.12 release</a> was published on 2026-10-27T00:07:12Z, and none of the four pull requests referenced here is merged into it.</li>
</ul>
<h2 id="where-to-look-first-when-a-turn-comes-back-empty">Where to look first when a turn comes back empty</h2>
<p>Order the checks by cost, cheapest first, and stop at the first one that explains the observation:</p>
<ol>
<li>History serialisation. Does the request you sent contain the argument types you intended, or did a previous response already hand you <code>&quot;30000.0&quot;</code>? If the latter, the server-side retype happened one turn earlier.</li>
<li>The <code>tools</code> array. Is every function name the model could plausibly call actually listed, including the ones you only mention inside descriptions?</li>
<li>The server log. One line, <code>gemma4 tool call parsing failed</code>, names the parser path outright; its absence does not clear the other paths, which log nothing.</li>
<li>The model and build. Preamble suppression and MLX render paths are model- and build-specific, and are the last thing to blame, not the first.</li>
</ol>
<p>For the surrounding mechanics, the <a href="https://baeseokjae.github.io/posts/ollama-api-local-llm-integration-developer-guide-2026/">Ollama API integration guide</a> covers the <code>/api/chat</code> request shape these probes use, and the <a href="https://baeseokjae.github.io/posts/gemma-4-local-setup-guide-2026/">gemma4 setup guide</a> covers installation and the errors that are not tool-call related. If a model looks worse rather than silent, <a href="https://baeseokjae.github.io/posts/local-llm-feels-dumber/">why a local LLM feels dumber</a> covers quantisation, context and sampling — a different failure class from state corruption.</p>
<h2 id="what-this-article-does-not-claim">What this article does not claim</h2>
<p>It does not claim that all Ollama parsers or renderers share one defect: each of the four paths is reported against one model-parser or model-renderer pair, and the code observations are specific files at one tag. It does not claim the paths were reproduced here; no server was run. It does not claim the reports are current for v0.40.12 in either direction — none of them shows the behaviour on that tag, and none of the four pull requests is merged. The counts quoted, including 28 of 32, 30 of 32, eight of eight, three of three, 30 dropped calls and 16 of 28 runs, are the reporters&rsquo; own on their own harnesses, at the versions they named, and are not normalised for hardware. No search-volume, keyword or traffic figure is claimed anywhere; the demand signal for this topic was repository issue incidence, which measures reported pain, not query volume.</p>
<p>The source records, fetched page bodies and code files behind this article are a private local author check kept beside the draft, not a downloadable dataset: the issue and pull-request payloads are third-party content, and republishing them is not something this article does. Every claim above links to the public page it came from, and those pages are the record to open.</p>
]]></content:encoded></item></channel></rss>