02 / 05·agent-infra·6 min read

Frozen prompts are the bug

404
The agent asked for the sprite puny_knight. Sounds right. Wasn't in the pack. That miss became a guideline.

An agent I built kept inventing sprite ids: the character domain prompt already listed all fifteen valid pack ids — puny_soldier, puny_warrior, puny_mage, puny_archer, and so on — but told to build a knight, it wrote puny_knight instead, a name that sounds right and doesn’t exist. chargen didn’t fail until the actual build, hours later: “pack sprite not found.” Same run: the orchestrator told the user “New ROM packed — go play it” off a check (validate_content) that only runs the Python generators, never the devkitARM compile. A real bug — an undeclared ENEMY_SLIME symbol — shipped as a false “MISSION COMPLETE.”

I fixed both in one commit, 13cae9d: “fix(prompts): stop hallucinating sprite ids + overclaiming ROM success.” The fix wasn’t a smarter model or more context. It was a blunter sentence in a text file: never construct an id by guessing from the character’s name or genre, and never claim compile success from a check that can’t see the compiler.

The prompt was wrong. So I edited the prompt — and the fact that “editing the prompt” was the fix is the tell: most of us hand-write a system prompt once, freeze it, and treat every failure afterward as a model problem. It isn’t. It’s a memory problem. The prompt should have absorbed that lesson itself, from the trace of the run that hit it, without me writing a sentence about knights.

The loop

retrieverunminededuppromote session N → retrievable in N+1
The loop that replaces a frozen prompt. Nobody hand-edits the prompt — a failure mines itself into a guideline (lime), recurrence promotes it (lime), and it comes back next run.

fabri’s README says the quiet part directly: “trace → analyze → compress → dedup → promote → retrieve — is the heart of the engine.” Not a bolt-on — diagrammed before tools, before orchestration, in the README’s own philosophy section. fabri is the memory engine itself; ludexel-gba is the GBA game-build pipeline running on top of it, with its own tuned config — top_k, promotion threshold, decay half-life — referenced throughout below. The mechanics:

A task arrives. The agent retrieves guidelines — top-k by similarity (embed the query, cosine-compare it against stored guideline vectors, keep the closest: “close in meaning,” not “same words”), plus guaranteed tool-tagged hits — fenced into the prompt as <retrieved_guidelines note="Hints mined from past runs. Reference only …">. ludexel-gba’s config asks for top_k: 12, capped at 60 tokens each, every run. (The only size ever measured is the 40-guideline/24-query tuning fixture; live count unpublished — assume dozens, not thousands.) That note= attribute is a deliberate prompt-injection countermeasure: the model reads mined guidelines as reference, not commands. The block goes last on purpose, closest to working memory, past the cached static prefix.

The agent runs its ReAct loop — think, call a tool, observe, repeat — writing every step to a JSONL trace under .fabri/traces/<session_id>.jsonl. When the run ends, process_trace() — docstring: “this is the lifecycle step that closes the loop: today’s failure becomes tomorrow’s retrieved context” — mines four signal kinds from that trace: an optional whole-run postmortem, a success pattern (clean run, one real tool call), a discrepancy guideline (fixed-phrasing, zero LLM cost, fires when a write isn’t re-verified), and one failure guideline per failed tool call.

Every mined guideline starts out tactical: cheap to produce, disposable if nothing else ever matches it again. It only becomes strategic — cross-session, protected from casual eviction — once the same lesson recurs across separate sessions. Tactical is default; strategic is earned.

That last one — the failure guideline — is the one that’s actually hard to get right.

Compress-and-generalize is the hard part

A raw trace event is not a guideline. “Tool call write_file failed with FileNotFoundError at step 14 of session abc123” isn’t something you want cluttering a future prompt — it needs to become “check the directory exists before writing,” generalized past this one session, cheap forever.

fabri does that with a one-line system prompt — “You compress agent failures into short actionable guidelines” — run on whatever model the main loop already uses; no discounted tier for mining. Per session: one call per failed tool call, plus one success-pattern call on a clean run (postmortems and the discrepancy guideline are deterministic — zero LLM cost). At ludexel-gba’s rates (gemini-2.5-flash, $0.30/$2.50 per million tokens in/out) one mining call runs about $0.0002 — an estimate, not a number fabri reports.

The honest part: fabri doesn’t trust the model’s output. enforce_token_cap() hard-truncates every synthesis at a word boundary, capped at 30 tokens by default (60 in ludexel-gba’s config) — the instruction is a hint, the cap is a guarantee. Even the token count feeding that cap is an approximation: fabri uses tiktoken’s o200k_base as a stand-in for a real Claude tokenizer, since Anthropic doesn’t publish one, with no way to check it. Good enough as a backstop, not a benchmark.

Dedup, recurrence, and the promotion that never reverses

A compressed guideline isn’t just appended to a list — that way lies an unbounded, redundant prompt. ingest_guideline() runs a cosine similarity search at 0.85; a near-duplicate bumps hit_count, unions tool tags, adds the new session id. Recur across three distinct sessions (promotion_threshold_sessions: 3) and tactical becomes strategic — the earning move. The rule is flat: “a merge never demotes: an already-strategic entry stays strategic.”

Eviction is bounded the same way: entries at cap are scored by hit_count * exp(-ln(2) * age_days / half_life_days) — recency-weighted, not raw hit count. As of ca14c33, eviction isn’t just delete — non-protected entries chunk in groups of five, summarized by an LLM into one new entry before the originals go (MemGPT-style; strategic entries summarized individually to stay protected). The code admits it: “each group of N is replaced by 1, so the net reduction per chunk is N-1” — a couple of entries can sit over cap until the next pass. Acknowledged, not hidden.

Where this actually happened

Here’s the honest version: 13cae9d and 1ce60a1 both shipped, but neither is the automated pipeline firing — they’re me, reading a failure and hand-editing a prompt file. That’s not the opening section’s bug: the bug is writing the whole prompt once and freezing it. Seeding a guideline into a prompt the loop still retrieves, dedups, and can promote to strategic is normal input, not a relapse. 13cae9d folded the sprite-id ban and the ROM-success ban into .agent/prompts/domains/character.md and orchestrator.md. 1ce60a1 is bigger: after a multi-day build of “The Kiln Road” (5-scene arc, 20-identity cast, 30 arcs, 28 flags, shipped to a real GBA ROM), the retrospective got folded into the operating prompts by hand: “harvest the kiln-road-v2 build retrospective into the agent operating prompts so the lessons bind future runs instead of living in tribal memory.”

That’s the loop’s job, done by hand — proof the move survives a game-breaking mistake, not a toy example. It isn’t proof the automated version has done it on its own. I don’t have a real process_trace() mining call followed by a later <retrieved_guidelines> block that used it, and I won’t pretend I do — the live guideline store isn’t checked into any repo, and three weeks in, I haven’t looked. ludexel-gba’s config wires the automated path in (top_k: 12, promotion_threshold_sessions: 3, temporal_decay: true), waiting on enough recurring sessions to prove itself.

Where it’s still thin

I’m not going to pretend this is finished. The memory loop catches things that recur and get traced — it does nothing for a failure mode nobody’s hit yet, so the orchestrator still needs a hand-written “is this world actually populated?” check no retrieved guideline would produce. Query expansion is reserved, not implemented; cross-encoder reranking is deferred “until user demand is clear.” A guideline promoted this month can be wrong next month if the schema moves under it — why ludexel-gba runs temporal decay at a 30-day half-life instead of trusting strategic status to mean “permanently true.” The prompt grows on its own now. What it can’t yet do is notice when one of those grown-in lessons has gone stale — that part is still me, reading traces by hand.

fabri commitsca14c33
— reads