Why My LLM Agent Fabricated Numbers From Stale Context
An agent replayed a ten-minute-old CPU reading as live. The bug wasn't the model: a guard checked whether cached tool output existed, not whether it was fresh.
I asked my own system what my CPU was doing. It told me, confidently, with a number. The number was ten minutes old. Nothing had measured anything on that turn.
Then it did it again in a different shape: it produced a timing table for MCP tool calls that it had reasoned out of the source code, and a set of memory statistics carried forward from an earlier run where get_memory_info had actually executed. Both were presented as measurements. Neither was measured.
That is the worst class of bug in an agent product. A crash is honest. This was the system lying to me in my own voice, about my own machine, using data it had legitimately collected at some point in the past.
The symptom: replayed tool output presented as a live measurement
Vodou’s gateway (MCP-servers/Vodou-Console/) assembles context for every turn: memory recall from a SQLite store, plus tool output when a query routes to an MCP tool. Tool output goes to the model inside an <active_context> block.
There is a deliberate replay path. If you ask “what’s my CPU at,” get an answer, and then say “huh, that seems high,” the second message doesn’t route to a tool. It’s conversational. So the gateway re-injects the previous turn’s tool output so the model can talk about what it just said. Reasonable feature. Here it is, llm.ts:3195:
// BrainLoader skipped (conversational message) — still re-inject stored context if available
const stored = _lastOiContext.get(conversationId);
if (stored && Date.now() - stored.timestamp < 600_000) {
oiResults = stored.oiResults;
stored.timestamp = Date.now();
console.error(`[BrainLoader] skipped (conversational) — reusing stored context (${oiResults.length} chars)`);
}
If you grep your own logs for reusing stored context, that line is where I started.
Three days blaming the TTL
Look at line four of that block. stored.timestamp = Date.now(): the sibling site at llm.ts:3099 even labels it // refresh TTL on use. Touch the cache and its age resets. A ten-minute window that refreshes on every read is not a ten-minute window; in a long conversation it never expires. Stale context reads as fresh forever.
I was sure that was it. I wrote up a fix: stop refreshing on read, and stamp the replayed block so the model knows what it’s holding.
<active_context fresh="false" age_s="412">
Clean. Honest. The model gets provenance, decides for itself, and the whole thing is about fifteen lines.
I was wrong for three days, and the tell was sitting in the transcript the whole time: on the turn where Vodou reported the stale CPU number, the daemon had run the tool. Fresh output existed. The unbounded TTL explains how stale data survives. It does not explain how stale data beats fresh data that is present in the same request.
I also chased a second wrong theory in parallel: that this was ordinary model hallucination and the answer was a stronger anti-hallucination anchor in the system prompt. That turned out to be a real finding but a different bug. Three of the five provider functions get no ground-truth block at all, so BYOK OpenAI and Ollama users are running a materially different product than the managed tier and nothing in the UI says so. Worth fixing. Not this.
The measurement that cracked it: count the writers, not the readers
I stopped reading the consumers and enumerated the producers. Every code path in the entire system that can write text into a model request. One row per site, file and line.
The plan I was working from budgeted for five. There were seven.
Number six was the one that mattered. The Rust daemon has its own tool-output channel that I had forgotten about, and it does not write into <active_context> at all. It writes a ### Vodou Tool Results (auto-routed) heading into the memory lane, and it has a second cached variant that does the same. Two independent producers, two different lanes, one buffer at the far end.
(Number seven was the SessionStart hook’s workspace bootstrap: AGENTS.md plus MEMORY.md, 25,293 bytes, consistent across sampled sessions, counted by nothing.)
Once both writers were on the same page, the bug was obvious.
Root cause: a guard that tested truthiness instead of provenance
Five sites in llm.ts, 5437, 5994, 6200, 7099, 7609, one per provider family, contain this:
// Fix 2: strip tool results block from system prompt — it's already in <active_context> via oiResults
const memoryForSystem = (oiResults && memoryContext)
? memoryContext.replace(/### Vodou Tool Results[\s\S]+/, '').trim()
: memoryContext;
The intent is deduplication. If tool output is already going to the model in <active_context>, don’t also ship the daemon’s copy in the system prompt.
The guard is oiResults &&. It asks whether cached tool output exists. It never asks whether that output came from this turn.
So: conversational message, BrainLoader skipped, replay fills oiResults from ten minutes ago. Meanwhile the daemon auto-routes the query and puts genuinely fresh tool output into memoryContext. The strip fires, because oiResults is a non-empty string. [\s\S]+ eats the fresh block and everything after it. The model receives the ten-minute-old reading and nothing else.
The comment on that line asserts the deleted content is safely present elsewhere. It isn’t. A different, older thing is. The code was working exactly as commented and the comment was describing a state that didn’t exist.
That’s the whole bug. Fresh output deleted to make room for stale output, five times, in a line whose comment claims the opposite.
The obvious fix is wrong on the OpenAI-compat path
My first instinct was to concatenate the fresh block back onto the system prompt when the guard rejects the replay. On three of the five provider paths, that works.
On chatWithOpenAICompat it silently breaks something else, and this is the part I’d have shipped if I hadn’t gone reading. That function has a stable-prefix mode where the system prompt is deliberately frozen:
const STABLE_PREFIX = process.env.VODOU_COMPAT_STABLE_PREFIX != null
? process.env.VODOU_COMPAT_STABLE_PREFIX === '1'
: currentProvider === 'vodou';
// ...
if (STABLE_PREFIX) {
systemPrompt = staticParts; // frozen → cacheable prefix
lateContextBlock = memoryForSystem || ''; // volatile → relocated to a late turn
}
The system prompt is staticParts and nothing else, on purpose, because byte-identical request fronts are what make the provider’s prompt cache hit: 87–97% on warm turns with a roughly two-turn warm-up. Anything query-dependent gets relocated into the message array instead, spliced in as a late system turn just before the current user turn:
if (STABLE_PREFIX && lateContextBlock) {
const insertAt = Math.max(1, m.length - 1); // before the trailing current-user turn
m.splice(insertAt, 0, { role: 'system', content: '### Relevant context for this turn\n\n' + lateContextBlock });
}
Append fresh per-turn context to systemPrompt on that path and you don’t lose the context. You lose the cache, on the tier where cost actually matters, and the only symptom is a bill. systemPromptStaticPrefix() also has two arities across the provider families, which is the second way that “just concatenate it” patch fails to compile in one place and compile-but-misbehave in another.
That is why the repair isn’t merged yet. Five nearly-identical strip sites across five provider functions is roughly 27 edit points for the full set of changes, and I’m not hand-editing 27 sites in a file this hot. The single context-assembly seam gets built first; the strip repair, the replay deletion, and a regression test land in it.
The regression test is the part I’d write first if I were you: on a turn where the daemon auto-routes but the gateway’s own router does not match, assert that the fresh ### Vodou Tool Results block reaches the model. That assertion fails today.
The policy change that came out of it is one line: replay instructions, never replay measurements. Skill text, menus, operating rules. Safe to re-inject, they don’t decay. A CPU reading, a row count, a disk figure: those are true at an instant and lies forever after. That distinction also killed 341 entries in my conflicts queue that turned out to be CPU readings from different runs disagreeing with each other, which is not a contradiction, just two clocks.
Here’s the rule I’d hand to a stranger. If two code paths can write to the same buffer, every dedupe guard between them must key on provenance, not existence. if (cached) is not a freshness check. if (cached.turnId === currentTurnId) is. And before you tune any of the readers, enumerate the writers with file and line numbers, because the number is higher than your architecture diagram says. Mine said five.