I run a self-hosted AI agent called Atlas. It lives in Slack, runs on my own Kubernetes cluster, and thinks on a pair of 24 GB datacenter GPUs in my basement — no cloud model in the path.
Yesterday I asked it to do something routine: download a YouTube video into the media vault, transcribe it, and draft a blog post from the transcript.
Everything worked except the ending
The video downloaded. The subtitle file was found. The transcript generated, the summary produced.
Atlas drafted the post, created the row in my notes database, and started writing the body in.
Then Slack said: “The model provider failed after retries.”
Twice. The second time it stopped for good, mid-sentence, and never told me it had already finished most of the work.
The real error
That Slack message is the agent being polite. It means the local model server returned something the agent didn’t want to paste into a public channel.
The actual string was in the logs:
no user query found in messages
The request that produced it contained a user query. I have the bytes.
This is the story of an error that was true and false at the same time — and of a fix that found the right mechanism and drew the wrong conclusion from it.
A fix had already shipped
Here’s what makes this worth writing down: this exact error had been diagnosed and fixed three hours before it happened to me.
The earlier investigation reasoned like this.
The agent estimates how large a request is before sending it, and compacts old conversation when that estimate crosses a threshold. The estimator undercounts requests carrying a lot of tool definitions — roughly eight percent low.
The margin between the compaction trigger and the model’s hard context limit was about 1,500 tokens. Well inside that error bar.
So a request could slip through: estimated under the line, actually over it.
And when a request overruns the context window, the server doesn’t reject it. It silently drops messages off the front until the thing fits. Drop enough, and you drop the question.
That reasoning was correct
I want to be clear about this, because the person who wrote it got there independently, and got there before anyone upstream had been consulted. It is an accurate description of the mechanism.
The remedy was to widen the margin from ~1,500 tokens to ~13,000, and to defer a large block of tool definitions so the fixed overhead shrank. Both are sensible changes on their own terms.
The trouble is that the process running my request had already restarted with that fix in place.
It crashed anyway. Twice. An hour later.
Reading the bytes
The agent dumps any request that exhausts its retries, so I didn’t have to theorize. I pulled both.
The first failing request held sixteen messages. Message zero was the system prompt. Message one was a normal, non-empty user message — 1,807 characters, my actual question, sitting exactly where it belonged.
Seventy-four tool definitions attached. The last message was a tool result of 50,060 characters: the video transcript Atlas had just read.
So the question was there. The server said it wasn’t.
The number that killed the theory
Seconds before that request went out, compaction had run and cut the session from 27 messages to 14 — bringing the estimate to about 36,700 tokens against a 65,536-token window.
Nowhere near overflow.
The overflow theory couldn’t explain this one, because on this attempt there was nothing to overflow. And it still came back no user query found in messages.
The second crash, forty minutes later, was the same error from the opposite extreme: 121 messages, compaction having run six separate times, hitting its attempt ceiling, logging “insufficient progress”, and giving up.
Two very different requests. One identical error.
What a renderer is
Language models don’t consume chat messages directly. Each model family has a renderer — code that flattens a structured conversation into the exact token sequence that model was trained on.
The renderer for this model family runs a validation check first: walk the messages, find at least one with the user role whose content isn’t entirely a tool-result block. If there isn’t one, refuse.
It’s a faithful translation of the model’s own reference chat template, which raises an error in the same situation. It looks defensive and reasonable.
The problem is ordering
The server’s context-trimming runs before the renderer.
So: my client sends a well-formed request with the question in it. The server decides the transcript is too much and trims messages off the front. The question is one of the things trimmed. The renderer looks at what’s left, finds nothing but assistant turns and tool results, and rejects the whole request.
The error is true from inside the renderer and false from outside it. It describes a room the client can never see into.
That gap is the entire reason this cost a day. Every instinct says go audit your own message construction — and your own message construction is fine.
The transcript ate the question
An upstream maintainer diagnosed this precisely, in almost exactly these terms, and named the aggravating factor: a single large tool response is what pushes the original question out.
My largest single tool result was the 50,060-character video transcript.
The thing the task existed to fetch is the thing that ate the question that asked for it.
Still open upstream
The proposed fix is a two-line change — drop the check, log a warning, render the conversation anyway. It matches a sibling guard that had already been removed from the same function for the same reason.
It has been open since mid-August. It is not in the current release, not in the one before it, and not in the release candidate that shipped the same day I hit this.
What I’d been assuming about error messages
I read server errors as descriptions of what I sent, because usually they are.
This one describes an internal state that only exists after the server has modified my input. The client and the server had completely coherent, completely incompatible views of the same request. Neither was lying.
Nobody logged the transformation in between — which is where the whole answer lived.
What I’d been assuming about good reasoning
The earlier diagnosis wasn’t sloppy. It identified the correct mechanism, independently, first.
What it got wrong was the class of problem. It treated the failure as a tuning problem: the margin is too thin, widen the margin.
But the trimming happens inside the server, on its own accounting, and the renderer rejects rather than degrading. Widening a client-side margin lowers the odds of triggering it. It cannot remove it — because the client was never the one making the decision.
A knob that reduces frequency feels exactly like a fix, right up until the thing recurs.
The tell
It was available the whole time, and I nearly walked past it too: the fix was live, and the failure happened anyway, on the same process, an hour later.
When a fix ships and the symptom returns, the cheapest next move is not to tune the fix harder.
It’s to go read the bytes.
Where it stands
There’s no clean fix I can deploy today. That’s an unsatisfying place to end and an honest one. The real repair is two lines in someone else’s open pull request.
The option I’m most inclined toward is switching the fleet back to the model I ran before this one.
The guard is written specifically for the current model’s renderer. An explicit variant check at the top of the function returns early for every other model — including its own immediate predecessor.
That older model uses a different renderer with no such check at all. It’s still sitting on disk, and its architecture gives it a context window twice as large for the same memory.
On paper: roughly two and a half times the working room, and compaction firing far less often.
It isn’t free. It means reverting half of yesterday’s fix, because the tool-deferral change was validated against the current model and not the old one. It trades a known, understood, upstream-tracked bug for a set of older quirks I’d have to re-learn.
That’s a real decision rather than an obvious one, and I haven’t made it yet.
The part that still worked
I have the thing I actually wanted: the draft blog post, complete, sitting in my notes database.
Atlas finished it. It just crashed before it could tell me.
- ollama/ollama#17778 — open, exact symptom (“calling tools in a loop… must be happening after I hand tool result back to the model”)
- ollama/ollama#17812 — same error, closed as duplicate
- ollama/ollama#17813 — the actual fix, still open/unmerged. An Ollama maintainer’s own diagnosis: “
validateMessagesrequired at least one user message whose content is not wholly a<tool_response>block. A long tool loop can leave none.chatPromptdrops messages from the front when the transcript exceeds the context window, and a large tool response can push the original query out. The renderer then rejected the request instead of rendering it.”