{"slug": "hardening-an-ai-coding-agent-the-failures-and-the-code-that-fixed-them", "title": "Hardening an AI coding agent: the failures, and the code that fixed them", "summary": "Univoco, a company building retrieval-augmented assistants, has detailed the process of hardening an AI coding agent that writes code for a proprietary document layout engine. The agent, which relies on private documentation and search tools rather than memorized public data, encountered a series of unexpected failures, including literal search behavior, repetitive searches, and invented APIs. The team implemented fixes such as filename matching, loop detection, query inflation, and self-auditing lookups to address these issues.", "body_md": "At [Univoco](https://univoco.io) we build retrieval-augmented assistants over a customer's own documentation. One of them is a coding agent that writes code for a proprietary document layout engine.\n\nThe useful thing about that engine is that there is effectively nothing about it on the public internet. The model cannot have learned it. Everything the agent knows comes from a private documentation index and the tools we give it to search that index.\n\nThat makes it an unusually clean laboratory. When memorisation is off the table, you get to watch the problem-solving process itself, and watch it fail.\n\nIt failed a lot, and the failures were rarely the ones I expected. What follows is the list, in the order we hit them, with the code that fixed each one.\n\n| # | Failure | Fix |\n|---|---|---|\n| 1 | Search tools are literal | filename matching, construct footprints |\n| 2 | It searches the same thing forever | five layers of loop detection |\n| 3 | One phrasing is a single point of failure | query inflation |\n| 4 | Goal words vs. index words | a deterministic phrasebook |\n| 5 | The retrieval tool invented APIs | self-auditing lookups |\n| 6 | Deciding an API is invented | the checker stack |\n| 7 | Findings get ignored | finish gates |\n| 8 | The wording of a correction is load-bearing | the envelope and the silence trailer |\n| 9 | A deletion that grew the file | take the generation out |\n\nThe standard shape is fetch-then-answer: embed the question, pull the top-k chunks, hand them to the model. It works for a great many tasks, and it has one structural problem. The folder gets assembled before anyone has understood the problem, so you are deciding what *might* be useful before you know what is *needed*.\n\nGiving the agent search tools of its own fixes the ordering and immediately produces a different class of bug, because search tools are literal.\n\nThe agent searched every file in the project for `json`\n\n, found nothing, and offered to create a sample data file. `sampleData.json`\n\nwas sitting in the project. The word it needed was on the front of the folder, not inside it.\n\n```\nfor entry in project_paths.iter_project_files(ctx.project_dir, glob=glob):\n    # A model hunting \"the json\"/\"the xml\" searches by TYPE, and content search can\n    # never find a file whose content doesn't mention its own format (live failure:\n    # sampleData.json was invisible to pattern \"json\" and the agent offered to create\n    # a file that existed). Names are part of the searchable surface.\n    if rx.search(entry[\"path\"]):\n        name_hits.append(f\"{entry['path']} (filename match)\")\n```\n\nAsked to remove a feature, the agent searched, found the one matching line, deleted it, and declared success. The five lines above it declared and configured the same object.\n\nSo `find_in_files`\n\nnow returns grep-style context plus an automatic **variable footprint**. A hit on `layout.blockStart(box)`\n\nalso lists `var box = new Block()`\n\nand every property line of that variable. Only declared variables expand, so globals like `layout`\n\nnever blow the output up.\n\nThe rule it encodes: *the construct is all of its lines, not the one that matched.*\n\nNeither fix is clever. Both exist because something shipped.\n\nThere are two layers of tools, and the split explains several of the fixes that follow.\n\nThe **base tools** ship with the platform and are deliberately generic. `list_files`\n\n, `read_file`\n\n, `find_in_files`\n\n, `write_file`\n\n, `edit_file`\n\n, `apply_edit`\n\n, `delete_lines`\n\n: every customer gets them, and not one of them knows anything about anybody's domain.\n\nThe **client tools** are authored per customer and live in that customer's own directory. Here that means `lookup_docs`\n\n, `resolve_topic`\n\n, `find_construct`\n\n, `check_symbols`\n\nand `dry_run`\n\n, none of which mean anything without this engine's documentation and this engine's object model.\n\nThe mechanic that matters is that **a client tool shadows a base tool of the same name**. Two do. The client `find_in_files`\n\nadds something the generic one could not: when a literal search finds nothing, concept words like \"block\" or \"the box\" expand to the engine's real symbols, so the agent never has to translate a concept into code before it can search for it. And `rag_lookup`\n\n, the knowledge lookup itself, is shadowed too, which is what makes the fix in section 5 possible.\n\nThat split is the architecture. The generic layer is table stakes and everyone has it. The layer that knows what a \"block\" is called in this customer's world is the part that cannot be bought.\n\nThese two need separating, because the gap between them drives a lot of what follows.\n\n| Tool | What it does | What it costs |\n|---|---|---|\n`lookup_docs` |\nfull-text search over the indexed documentation, returning the chunks that literally contain the term. No embeddings, no model, no generation. | milliseconds, and it cannot invent anything |\n`rag_lookup` |\nsemantic retrieval, then a model composes an answer out of whatever came back | ten to thirty seconds, and the composing step can invent |\n\nThe second one is what most people mean by RAG: retrieve some relevant text, hand it to a model, let the model write the answer. It is the right tool for \"how do I build a table\", and the wrong tool for \"does `tableColumnRule`\n\nexist\", which is the distinction the agent is told to route on:\n\nTWO knowledge tools, and the choice matters.\n\n`lookup_docs`\n\nis ~1000x cheaper. Naming a symbol you already know is a FACT CHECK, not a research question.\n\nThat framing looks like a cost optimisation. It turns out to be a correctness one, for reasons section 5 gets to.\n\nMy favourite failure of the whole project:\n\nThe agent ran the same knowledge lookup\n\ntwelve times in one turn, identical arguments each time, and the results themselves contained notes pointing out it had already asked. It made no difference.\n\nThe variant version was worse, and no exact-match check can catch it, because the arguments differ every time. One table turn ran **12 steps and 170k input tokens**, and three of its five knowledge lookups re-confirmed the same three API names. Those names were already in its context, listed in the prompt fragment it had been handed at the start of the turn.\n\nIt was not searching. It was checking something it already knew, at ten to thirty seconds a time. The operator's note in the ledger puts it better than I can:\n\nonce it knows how to do stuff it shouldn't need to keep trying to find it at the rag end\n\nA human has an internal sense of going in circles. The model does not, and telling it so in the tool output does nothing, because that text is just more context. Worse, on a provider without prompt caching the fixed ~11k of system prompt and tool schemas is re-sent every step, so cost is roughly fixed times steps plus transcript. Fewer steps is the only real lever, and a redundant lookup adds steps.\n\nThe fix is five layers, and **the layering is the point**: each layer only catches what the one before it structurally cannot see.\n\nA counter in the tool loop, tripping at `_MAX_IDENTICAL_CALLS = 4`\n\n. Past that the call is not executed, but it still has to be answered, because every tool call in the transcript needs a result.\n\n```\n# Stuck-loop detector: the same call over and over is fixation, not progress.\n# Answer it with a stub (every tool_call must be answered) and head for the\n# forced final answer instead of burning the rest of the budget.\nkey = (name, call.arguments or \"\")\ncall_counts[key] = call_counts.get(key, 0) + 1\nif call_counts[key] > _MAX_IDENTICAL_CALLS:\n    stuck = True\n    messages.append({\"role\": \"tool\", \"tool_call_id\": call.id,\n                     \"content\": f\"[not run - you have made this exact call \"\n                                f\"{call_counts[key]} times this turn]\"})\n```\n\nReworded questions are distinct argument strings, so the identical-call counter never fires on them. Catching those needs a budget rather than a counter:\n\n| Constant | Value | Meaning |\n|---|---|---|\n`_MAX_LOOKUPS_PER_TURN` |\n`12` |\nlive lookups across all wordings |\n`_MAX_LIVE_LOOKUPS` |\n`2` |\nper identical query, then the turn cache answers |\n\nThis one I like, because the obvious solution is wrong. You could embed every query and compare vectors. We do not, because that is a per-probe cost on the hot path for a problem that plain string handling solves.\n\nMatching is deliberately embedding-free: normalise (lowercase, drop stopwords, light stemming with a double-consonant collapse so splitting to split), token-sort, then exact key hit or a\n\n`difflib`\n\nclose match. Deterministic, stdlib, zero per-probe cost, and it catches exactly the reworded-not-rethought queries above. Genuinely different questions stay distinct.\n\nThe similarity threshold is `0.8`\n\n, and the calibration comment is the reason I trust it:\n\n`create table`\n\nvs `create box`\n\n, score about The window between them is the sweet spot, and it was measured rather than guessed.\n\nRead-only calls get dispatched together, so three rewordings can all be in flight before any of them returns, and a dedupe that runs on completion never sees them. That needs a leader/follower collapse on the normalised query: the first runs, the rest wait and receive its answer.\n\nNone of the above touches the failure this section opened with. A budget caps how much re-asking costs, but the agent re-confirming names it was handed at turn start should not be asking at all.\n\nWrites to domain code are normally refused unless a knowledge lookup has run in that turn, which is what forces grounding. That gate was also what forced the redundant lookups: the agent had the names, but the rule did not know that.\n\nSo the prompt fragments now declare what they supply. Each construct fragment carries a `provides`\n\nlist, generated rather than hand-written and scoped to one family, so the table fragment declares the table companions and not the rule ones. The task brief unions those into `provided_symbols`\n\n, and the gate exempts a write whose `new X()`\n\nconstructors are all covered. A write reaching for a construct that was never delivered still grounds, and `check_symbols`\n\nremains the backstop before anything lands.\n\nThe general shape: a rule that forces good behaviour will also force pointless behaviour unless it can see what has already been satisfied.\n\nThis was the most useful thing in the section. The budget once cut a model off **mid-way through correctly verifying eleven symbols** it was about to use. It was doing exactly the right thing and we throttled it.\n\nSo budget is now charged per tool rather than per call:\n\n`_MAX_LOOKUPS_PER_TURN`\n\ncaps LIVE lookups across all wordings; cached serves and cheap tools are free (`projects.budget_tools`\n\n, default`rag_lookup`\n\n): a millisecond`lookup_docs`\n\nfact-check per symbol is diligence we demand, not cost to ration.\n\n**If your rate limit punishes the behaviour you are trying to encourage, the limit is wrong, not the behaviour.**\n\nOne last piece of absurdity, included because it is a genuine class of bug and not just a funny story. An earlier nudge injected into the tool results said *\"look up the interface's REAL members\"*. The model ran that sentence, verbatim, as a semantic search query. Twelve times.\n\nNudges now name an action, and only ever suggest exact-name lookups, never a phrase, because anything phrase-shaped in an agent's context is a candidate query.\n\nOne user phrasing is a single point of failure for recall. If the question happens to be worded unlike the corpus, retrieval returns nothing useful and the agent concludes the knowledge base is empty rather than that it asked badly.\n\nSo before the lookups run, a forced tool call reformulates the question into several deliberately different queries. Not paraphrases: different **angles**.\n\n```\n_QUERIES_TOOL = {\n    \"name\": \"search_queries\",\n    \"description\": (\n        \"Reformulate the question into optimised knowledge-base search queries using the right \"\n        \"domain keywords / API terms. For a how-to / implementation / 'how do I ...' question, \"\n        \"return 2-3 queries covering different angles (e.g. the core API/object, and a usage \"\n        \"example). Return a single query ONLY for a trivial factual lookup (e.g. 'what is X').\"\n    ),\n    ...\n}\n```\n\nThe user message that drives it is where the actual behaviour lives. Almost every clause is there to stop a specific degenerate output:\n\n```\nGenerate {max_queries} search queries covering DIFFERENT angles so retrieval is thorough:\n1. A focused how-to query for the specific task.\n2. A BROAD keyword query - just the core nouns / concepts / terms (e.g.\n   'user login session token'), to surface related material the specific query might miss.\n3. If more are allowed, add a query aimed at a concrete example.\nDo NOT return only a verbatim copy of the question. Only a trivial factual lookup gets a\nsingle query.\n\nExamples (apply the SAME pattern to the question's own domain):\n- 'set up user authentication' -> ['how to set up user authentication',\n  'authentication login session token configuration']\n- 'export a report to CSV' -> ['how to export a report to CSV',\n  'CSV export columns rows formatting']\n```\n\nThe system line is one sentence and does most of the work:\n\nYou generate DISTINCT knowledge-base search queries using precise keywords / API terms. Never echo the question verbatim.\n\nWithout it you get the question back with the word order shuffled, which is the reworded-not-rethought problem from section 2, except now we are paying to generate it.\n\n**Each query carries a human label.** The tool schema requires an `about`\n\nfield alongside the query: *\"a short, natural human phrase for what this looks up, e.g. 'creating tables with borders'\"*. That is not for retrieval, it is for the activity line the user watches. Generating the explanation in the same call as the query is free; deriving it afterwards is another model call or a bad heuristic.\n\n**The queries are not fanned out by code.** They are injected back into the prompt as an instruction, so the agent still owns sequencing and can stop early.\n\n``` python\ndef _guided_prompt(question, query_objs, adaptive=False):\n    \"\"\"Wrap a question with guidance to search each expanded query, then answer (or dig deeper).\"\"\"\n    qlist = \"; \".join(o[\"query\"] for o in query_objs)\n    tail = _ADAPTIVE_TAIL if adaptive else \" Then answer fully.\"\n    return f\"{question}\\n\\n[Search the knowledge base for: {qlist}.{tail}]\"\n```\n\n**Expansion without a brake is just a licence to over-search**, which is section 2 all over again. So `_ADAPTIVE_TAIL`\n\nships with it:\n\nThen judge the results: if they already answer the question, STOP and answer now, do not run extra, redundant, or tangential searches. Only if a genuine gap remains (or the results reference something specific you still need) search again for exactly that. Aim for the fewest searches that fully cover the question.\n\nThere is a ceiling of `6`\n\nregardless of configuration, and `max_queries: -1`\n\nmeans the model decides within that ceiling.\n\nOne clarification, since the two get conflated: this is multi-query rewriting, **not HyDE**. No hypothetical answer document is generated. In a domain the model has never seen, a hypothetical document is a hallucination with extra steps, and embedding it just retrieves whatever is nearest to the invention.\n\nThis is the failure that cost the most and looked the least like a bug.\n\nThe agent phrases things by goal: *\"display the data in a table\"*, *\"put the XML in a table\"*. The corpus is indexed by the engine's own vocabulary: `openStream`\n\n, `xmlDoc`\n\n, `selectNodes`\n\n.\n\nTo a human expert those are obviously the same topic. To a retriever they have almost nothing in common, so the lookups returned nothing, the agent concluded the capability did not exist, and it reached for browser JavaScript instead.\n\nMissing real content **and** inventing names is the expensive combination, because the invention then looks like a hallucination and you go and debug the wrong component.\n\nThree layers, in increasing order of force.\n\n`resolve_topic`\n\nmaps a goal to the real symbols, with no model involved at all:\n\nThe agent phrases requests by goal; the docs are indexed by object-model vocabulary. That mismatch made it MISS real content and INVENT names, spiralling. This reads the terminology datagraph (\n\n`_topics.json`\n\n) and hands back the real symbols, a note on the pattern, and the exact next lookup, so the agent searches truth instead of guessing.Zero LLM, deterministic. Call it BEFORE\n\n`rag_lookup`\n\nwhen you don't already know the API names.\n\nThe map is mined rather than hand-written:\n\nMatching is stopword-filtered token overlap ranked by hit count. No embeddings anywhere in it.\n\nBecause a phrasebook that is confidently wrong is worse than none. The first version applied full force to any match at all:\n\n```\n# Confidence ladder (live miss: 'footer with page numbers' shared ONE word with the\n# layers topic and got the full-strength 'do NOT invent' route, walling the agent off\n# from the real constructs). A weak match is a HINT, not a law: strict only when the\n# route explains at least half the goal's significant words, or 2+ of them.\ncoverage = top / max(1, len(_words(goal)))\nif top < 2 and coverage < 0.5:\n    return (\"WEAK MATCH - the goal only loosely fits a mapped topic; treat this as a \"\n            \"hint, NOT the answer:\\n\\n\" ...)\n```\n\nA guardrail with a false positive does not fail neutrally. It actively steers the agent away from the answer, and it does it with authority.\n\nUsers phrase goals in their own nouns (*\"display parts from an XML file grouped by category\"*) and documentation indexes mechanisms (*\"load an XML file, iterate elements into table rows, heading per group\"*). One cheap call normalises the retrieval query before the lookup stack sees it, layered outermost so that dedupe and the persistent cache both key on the normalised form and hit more often.\n\nThe constraint on that rewriter is the interesting part:\n\n```\n#: Operations only, never API names: the rewriter half-knows jargon, and an invented\n#: symbol in the query would poison retrieval with exactly the hallucination the\n#: lookup exists to prevent. API discovery stays retrieval's job.\n```\n\nIt ships disabled, incidentally. The doctrine version in the prompt does most of the work and the mechanical version is held in reserve for when doctrine leaks.\n\n**Where the phrasebook is applied matters more than the phrasebook.** The terminology hint is prepended to every lookup result, whether or not the model asked for it, and whether or not retrieval returned anything, because a gap is exactly when the real names matter most.\n\nThe agent kept writing invented API calls into its work. It would be caught, it would look the correct ones up, and then it would do it again. Seven rounds of this in one session.\n\nI assumed the model was inventing them. That is the famous failure mode and I never questioned it.\n\nThen I read what the lookup tool had actually returned, rather than the model's account of what it returned.\n\n**The retrieval pipeline was generating the invented APIs.** The style that answers conceptual questions composes an answer from retrieved examples, and that generation step had invented `layout.tableCellEnd`\n\nand `Table.cellSpacing`\n\n, neither of which exists, while asserting they came *\"directly from the example in the provided context\"*.\n\nThat is not really a bug, it is what generation does. Ask an obliging model for a worked example and it will produce a worked example, and where the retrieved material runs thin it fills the gap with the most plausible name available. The word \"lookup\" was what fooled me: I had been treating an endpoint that ends in a generate step as though it returned facts, when the only thing separating it from the agent was that its inventions arrived with a citation attached.\n\nThe model was faithfully copying bad information from the one source it had every reason to trust. Worse, the loop was closed: the check flagged the file, the agent re-ran the same lookup to find the right answer, and got the same poison back.\n\nThe debugging rule that came out of it, because I lost days to not knowing it: read the actual tool results in the log, not the model's narration of them. A large proportion of what looked like model failures turned out to be a tool feeding it poison.\n\nThe fix is a client-side shadow that wraps the built-in lookup and audits its output with the same symbol checker the write path uses:\n\nThe examples style GENERATES its answers, and generation can invent APIs. A live run returned examples using APIs that do not exist, the agent wrote them,\n\n`check_symbols`\n\nflagged the file, the agent re-ran the same lookup and got the same poison: a perfect loop. This shadow breaks it at the source: run the built-in lookup, then run the SAME symbol audit the write path uses over the returned example, and hand the agent the answer and the corrections in one result, so it learns the example is partly wrong BEFORE writing it, together with the interface's real members to build from instead.\n\nThe lookup cache had pickled the poison. Of **137 cached entries, 126 carried warning text**. Every fuzzy cache hit was re-serving a known-bad answer, forever.\n\nFlagged answers are now uncacheable, and the cache module has a rule list at the top that reads like scar tissue, because it is:\n\nOnce the guardrails were in, we probed the same grounded prompt across three model tiers, all with identical retrieval and identical checks:\n\n| Model tier | Grounded probe |\n|---|---|\n| frontier | 5/5 |\n| mid | 2/5 |\n| cheap | 0/5 |\n\nThe verdict in the notes: *the guardrails make a weak model SAFE (bounced), not GROUNDED.*\n\nA gate stack stops bad output reaching the user. It does not turn a model that cannot follow grounding instructions into one that can. Budget for the model tier; do not expect the scaffolding to buy it back.\n\nThis is the check everything else depends on, and the first version was wrong in an instructive way. It asked an LLM to list the symbols in the agent's code, then full-text searched each one.\n\nEvery step leaned on the thing that was broken: an LLM summarising another LLM's hallucination, and a search whose only possible verdict was \"not found\", which tells an agent nothing about what to write instead, so it kept the invented call.\n\nThat second clause is the part I underestimated. **\"Not found\" is not actionable.** An agent that receives it has no better move available than the one it already made, so it makes it again.\n\nThe reframe: the documentation is complete and enumerable, so the question is *decidable*. Resolve what an object is, then ask whether the member exists on it.\n\n``` php\nlayout.tableCellEnd                       ->  layout is Layout    ->  no such member  ->  INVENTED\nvar cell = new TableCell(); cell.start()  ->  cell is TableCell   ->  check `start`\n```\n\n\"Complete and enumerable\" is carrying a lot of weight in that paragraph, so here is where the index actually comes from. It is not hand-written, and that is the only reason the verdict is trustworthy. A hand-maintained list of what exists is just one more thing that can drift from what exists.\n\nThe member index falls out of the documentation's own structure. Every documented member is its own chunk, titled `Interface-member`\n\n, so parsing titles yields `{interface: {members}}`\n\nfor the entire surface. Two patterns in the prose supply the rest of what the resolver needs:\n\n| Pattern in the docs | What it yields |\n|---|---|\n`Interface-member` chunk titles |\nevery member of every interface |\n`accessed using the global property \"layout\"` |\n`layout` resolves to `Layout`\n|\n`Parent interface: Block` |\ninherited members |\n\nThe middle row is what makes receiver typing possible at all. The set of documented globals, the thing that turns `layout.x`\n\ninto a decidable question rather than a guess, is extracted from a sentence the documentation happens to write about itself.\n\nA second datagraph describes **constructs** rather than members: how each one is declared, and which calls open and close it. It is generated from the API definitions rather than curated, and the reason is the best argument in the codebase for never hand-writing ground truth:\n\nHand-curating it is error-prone: the first cut invented a\n\n`List`\n\n/`listStart`\n\nconstruct that does not exist.\n\nWe hallucinated an API while building the tool whose entire job is catching hallucinated APIs. The generator now derives the pairs mechanically: on a global-reachable interface, a `<base>Start`\n\n/`<base>End`\n\npair whose Start takes a defined interface as its first argument. Pairs whose first argument is a primitive, like `keepStart(string)`\n\n, are not constructs anyone instantiates, so they are skipped and reported rather than guessed at. Seven constructs survive that test.\n\nAn entry looks like this:\n\n```\n{\n  \"id\": \"table\",\n  \"interface\": \"Table\",\n  \"kind\": \"paired\",\n  \"aliases\": [\"table\", \"grid\"],\n  \"declare\": \"new Table\",\n  \"start\": \"layout.tableStart\",\n  \"end\": \"layout.tableEnd\",\n  \"no_close\": \"Rows and cells end IMPLICITLY. There is NO tableCellEnd and NO tableRowEnd; those methods DO NOT EXIST.\"\n}\n```\n\nThat last field is the interesting one, because it is a **negative** fact. Some constructs close implicitly, so the end-calls a developer would reasonably expect are absent, and `tableCellEnd`\n\nis precisely what the model kept reaching for. The fact is authored once and read by both the checker and the terminology builder, so the two can never drift into disagreeing about it.\n\nIt also upgrades the finding. Instead of \"no such member\", which sends the agent hunting for a replacement that does not exist, the checker can say the call is unnecessary and the row closes on its own, so deleting it loses nothing. A checker that only knows what is absent can report; one that knows *why* it is absent can tell the agent what to do instead.\n\nWith the index in place, three further properties are what make the check survivable in production.\n\nA member is only judged when the receiver's type is certain: a documented global, a `new X()`\n\n, or an interface named directly. A parameter, a function's return value, a nested property, all skipped.\n\na wrong accusation is worse than a miss: it sends the agent off to \"fix\" working code, and it teaches the user to ignore the check.\n\nA member check alone is not enough, and every gap here was found in production rather than in design.\n\n| What landed | Why it slipped the previous checks | Check added |\n|---|---|---|\n`engine.loadXML(...)` |\nundeclared receiver, so no type to resolve | object check |\n`loadXMLData()` |\na bare call defined nowhere | function check |\n`const` , arrow functions |\n`node --check` passes what the target engine cannot run |\nsyntax check (ES3) |\n`layout.blockEnd({ borderColor: 'red' })` |\nan invented key inside an object literal, not a dotted member | option check |\n`new ActiveXObject(\"Microsoft.XMLDOM\")` |\nmember check cannot type an unknown constructor | constructor check |\n`Object.keys` |\nvalid JS, absent from an ES3-era engine | engine check |\n| a referenced data file that does not exist | not a symbol at all | file check |\n\nThe **option check** is the one I would not have thought of. An invented member on a dotted call is obvious. An invented *key* smuggled through an options object is invisible to a member check, and at runtime it is silently ignored, so the behaviour you promised the user simply never happens and nothing anywhere reports an error.\n\nWe learned this twice, expensively.\n\n**Once by accident.** A markdown fence in a knowledge-base snippet tripped a template-literal check and a real API was branded invented. The model then wrote that into its session memory: it learned that a real API does not exist, and carried that forward.\n\n**Once systematically.** The target engine implements a genuine W3C DOM, and our browser-API denylist had been written on the assumption that DOM methods were browser-only. Of the 34 names on it, **23 were real APIs in this engine**. The check had been flagging the agent's *correct* code, and it had helped cause the spiral in section 5.\n\nEvery new check now ships with a known-good-input case for both shapes it audits, whole files and fenced snippets.\n\nStatic analysis has a hard limit: it must skip any receiver whose type is uncertain, which is precisely where dynamic code lives. So the last check generates a fake engine from the documentation index and actually runs the build against it.\n\nThe docs are COMPLETE and enumerable. So a fake engine can be GENERATED from them: every documented global and constructor exists; every documented member is a callable, chainable stub; touching an UNDOCUMENTED member on a typed object records an INVENTED finding.\n\n`loadJS`\n\n/`importJS`\n\nresolve real project files, so wiring is exercised for real.\n\nIt catches syntax errors and crashes, loads of files that do not exist, and invented members reached through dynamic flow: parameters and return values, exactly the receivers the static pass must skip. It runs once at the end of a turn, never per write, because one process launch per draft is cheap and one per write is not.\n\nThe limits are stated in the same docstring, which is the part I would want if I were adopting this:\n\nWhat it can NOT prove: rendering. Data APIs return permissive stubs (no real XML), so loops may run zero iterations and zero-iteration paths go unexercised. This is a dry run, \"the code executes against the documented surface\", not a render test.\n\nI built the checker, wired it in, and watched it work. It caught the invented APIs and reported them accurately, and nothing whatsoever happened as a result:\n\nTold \"these APIs do not exist, fix them now\", the model wrote the user a prose essay (\"you may need to rely on...\") and finished the turn with the invented symbols still on disk. So findings also gate the END of the turn: a\n\n`finish_gate`\n\nthe tool loop consults before accepting a final answer, which pushes the model back to work while anything is outstanding (same lesson as`lookup_before_write`\n\n: enforce it, don't ask for it).\n\nThe gate contract fits in one line:\n\n``` php\ngate(answer, asking=False) -> Optional[str]\n```\n\nReturning a string means the draft was **not sent** and that string goes back into the model's context. Returning `None`\n\nships it.\n\nEverything else is about the budget, and the budget is where the design decisions are.\n\n| Gate | Own push budget |\n|---|---|\n| verify | 2 |\n| bail | 2 |\n| claim | 1 |\n| checklist | 1 |\n\nEach is individually reasonable, and together they can bounce one turn six times, which is an unpredictable worst case for latency and cost. So there is a **shared turn budget of 3** across every gate, and past it the composed gate goes silent and the turn ends with whatever honesty the pushes bought.\n\nTwice, every push was consumed by intermediate holds and a false final claim shipped unchallenged. Hence a reserve:\n\n`reserve_final`\n\nkeeps the last (user-visible) draft policed after the shared budget is spent mid-turn. Past`max_pushes`\n\n, up to`reserve_final`\n\nfurther pushes may be spent, but ONLY on NEW material: a draft whose text materially changed since the last hold, or new deeds. A verbatim doubled-down draft has already ignored this correction once, repeating it buys nothing, so it ends the turn as before.\n\nThis was the single biggest behavioural improvement:\n\nAuditing after the fact meant asking the model that just invented an API to un-invent it. It would rather explain, annotate, or stand by it. Refused content simply never becomes a file.\n\nThe gate that catches the most is the simplest. Asked to split a file, the model created three modules and reported four:\n\n```\nChanges made:\n  1. Created xmlLoader.js ...\n  2. Created styledBox.js ...\n  3. Created tableCreator.js ...\n  4. Updated template.js to import and use the new files.\n```\n\nStep 4 never happened. It had planned it and narrated the plan in the past tense. The check is mechanical: past-tense verbs only, scoped to a single line, matched against `ctx.files_changed`\n\n, which every landed write funnels through.\n\nThe tense restriction is not incidental, and the comment explaining why earns its length:\n\n```\n# PAST TENSE ONLY, and deliberately so.\n#   - \"I will update template.js\" is a plan, not a deed - the future tense must not trip this.\n#   - The present tense describes what CODE does, not what the agent did: \"template.js already\n#     imports xmlLoader.js\" and \"createTable() creates a new Table\" are statements of fact\n#     about the project, and reading them as claims would gate honest, read-only answers.\n# Only the past tense asserts \"I did this\", which is the thing the file system can contradict.\n```\n\nThe scope restriction matters just as much: matching is per line, because a wider window lets one \"Created\" three bullets up capture every filename mentioned afterwards.\n\nAnd the gate declares its own non-goal, which I think is why it works:\n\nThis checks only what the model VOLUNTEERS. It does not police silence... A false claim is wrong unconditionally; that is why this gate can be mechanical.\n\nTwo smaller rules that took real failures to learn:\n\nEvery gate above works by writing a message into the model's own context. Getting the *wording* of those messages right turned out to be as load-bearing as the logic that triggers them, and it is the part I have seen discussed least.\n\nStart with the two constants that open and close every one of them.\n\n```\n#: Opens every gate message. Injected corrections must not read as the user speaking, or the\n#: model treats its own gated draft as user feedback.\nENVELOPE = \"[automated pre-send check - NOT a message from the user] \"\n```\n\nThat is not a stylistic choice. Without the marker the model reads its own blocked draft as something the user saw and objected to, and then reasons about it as if a conversation had happened.\n\n```\n#: Closes every gate message. Without it the model apologises to the user for a draft they never\n#: saw (\"You are correct. I apologise...\"), explains itself, and asks permission, turning a silent\n#: retry into a wall of confusing chat. The gate is plumbing and must stay invisible.\nSILENCE = (\"Do not apologise. Do not mention this check, your draft, or any mistake - the user \"\n           \"never saw them and will not understand a reference to them. Do not ask permission: \"\n           \"just do the work. Your reply must read as if you had done it right the first time. \"\n           # Live: a bail push made the model REWRITE a file smaller, deleting footer code\n           # that had already passed the symbol check, and surrender in a comment instead.\n           \"Never respond to this check by deleting or stripping code that already passed \"\n           \"its checks - fix FORWARD (add, complete, correct), never retreat.\")\n```\n\nEvery sentence in that string is a bug, and the last one is the strangest: a push meant to stop the agent giving up made it delete working, already-verified code and write an apology in a comment.\n\n**Correction pressure has a direction, and if you do not specify it, the model will sometimes satisfy you by removing the problem rather than solving it.**\n\nBoth constants live in one module because they did not, once, and a hand-copied version quietly drifted out of sync.\n\n**Escalation on repetition** - the second push stops being polite\n\n```\nSECOND refusal: you have made ZERO writes this turn, the file system is the ground truth\nand it shows no change, yet your draft still reports work as done. It is false.\n\nDo ONE of these, nothing else: (1) make the change now with a write tool and then\ndescribe it, or (2) answer exactly what is true: that you changed nothing, and what you\nfound in the file instead.\n```\n\nThe first time the model claims work it did not do, it gets a straightforward correction. This is what it gets if it doubles down.\n\n**Constraining what the model then tells the user** - the budget message\n\n```\n[lookup budget exhausted - {live} knowledge lookups have run this turn] No further\nlookups will run. Searching again, in any wording, returns this same message. STOP\nsearching and WRITE... In that final answer, if information is missing, say the SEARCH\nBUDGET ran out, do NOT claim the knowledge base lacks it; the budget ending says nothing\nabout what the documentation contains.\n```\n\nThe obvious message is \"stop searching\". The last clause took a live failure to learn: without it, our own rate limit came out of the agent's mouth as a factual claim about the customer's documentation. The agent had no way to know the difference, so we had to tell it.\n\n**A checker that argues** - pre-empting the three moves the model actually makes\n\n```\nThis is not \"unverified\", the documentation is COMPLETE, and these were checked against\nevery member of the interface. You invented them just now, most likely because a similar\nname exists (tableStart exists, so tableCellEnd feels like it must). It does not. The\nfile will fail on the real engine.\n\nRewrite those calls using the real members listed above. Do NOT re-run the same\nrag_lookup that produced this code; it will return the same wrong example. If you need a\npattern, rag_lookup a DIFFERENT, more specific goal. Do NOT annotate the call and move\non; do NOT keep it because it looks right. If the interface genuinely has no member for\nwhat you intended, leave that part out and say so in your final answer, never leave a\ncall to a method that does not exist.\n```\n\nNote that it names the failed retrieval explicitly. Without that line the agent re-runs the lookup that produced the bad code in the first place, which is the closed loop from section 5.\n\nWhen a plan is decomposed, each item runs as a bounded worker with its own context, and the worker's report comes back to the composer as a user message. Workers are told to end with what they built and what it exposes.\n\nThe composer does not believe the \"exposes\" line:\n\n``` php\ndef exposures(path: str, content: str, answer: str) -> str:\n    \"\"\"One line describing what a landed file exposes - MECHANICAL over the file itself\n    (the file is a fact; a model-authored \"exposes\" line is a claim). Falls back to the\n    worker's own short answer for non-JS / no matches.\"\"\"\n```\n\nThat parenthesis is the whole article in one sentence. Everywhere the system has a choice between what a model says happened and what the file system says happened, it takes the file system, and where it cannot, it says so and degrades deliberately.\n\nThe fix for this last one runs against every instinct: it is to take intelligence away, not add more.\n\nAsked to remove a table, the agent removed one line, rewrote everything around it, and made the file **bigger**:\n\n| Attempt | Result |\n|---|---|\n| original | 1527 bytes |\n| \"remove the table\" | 1653 bytes, reported as success |\n| retry | 1659 bytes, reported as success |\n\nThe diagnosis had two layers, and both are worth knowing:\n\n`tableEnd()`\n\n.The fix was to take the generation out of that step entirely. The model reads the file and emits `L<n>: DELETE`\n\nmarkers, nothing else, and ordinary code drops those lines:\n\nDelete-only pass: the model reads the file (or a\n\n`where=`\n\n-scoped region) and marks which lines belong to the thing being removed; they are dropped deterministically. It can neither rewrite nor add, so a removal can only shrink the file. This is where EVERY pure deletion goes instead of the generative chunk rewrite (which deleted one line and grew the file live).\n\nThe same file went from 1527 bytes to **678**, with the right construct gone and the one next to it untouched.\n\n| Mechanism | Catches |\n|---|---|\n| filename matching, construct footprints | literal search missing the obvious |\n| identical-call breaker, turn budget, fuzzy cache, in-flight collapse | fixation and rewording loops |\n| query inflation with an adaptive brake | one phrasing being a single point of failure |\n`resolve_topic` , confidence ladder, de-nouning rewrite |\ngoal words that miss an API-indexed corpus |\n| self-auditing lookup shadow, uncacheable flagged answers | the retrieval tool inventing APIs |\n`check_symbols` (8 families) + the `dry_run` shim |\ninvented APIs, statically and at runtime |\n| composed finish gates, pre-write refusal, claim audit | findings being ignored, work claimed but not done |\n| envelope, silence trailer, escalating pushes | the correction itself being misread or over-obeyed |\n| delete-scan | a destructive edit that could grow the file |\n\nAcross all of this, the model never got better. Every version ran on the same tier that failed the first time.\n\nWhat changed was the space of things it could get away with: what it could search without a limit, what it could claim without evidence, what it could write without being refused, and what it could remove by generating rather than by marking.\n\nThat is unglamorous work, and it is almost all of the work.", "url": "https://wpnews.pro/news/hardening-an-ai-coding-agent-the-failures-and-the-code-that-fixed-them", "canonical_source": "https://dev.to/joebuckle-dev/hardening-an-ai-coding-agent-the-failures-and-the-code-that-fixed-them-g3c", "published_at": "2026-07-31 11:33:04+00:00", "updated_at": "2026-07-31 11:35:23.224045+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-agents", "ai-tools", "developer-tools"], "entities": ["Univoco"], "alternates": {"html": "https://wpnews.pro/news/hardening-an-ai-coding-agent-the-failures-and-the-code-that-fixed-them", "markdown": "https://wpnews.pro/news/hardening-an-ai-coding-agent-the-failures-and-the-code-that-fixed-them.md", "text": "https://wpnews.pro/news/hardening-an-ai-coding-agent-the-failures-and-the-code-that-fixed-them.txt", "jsonld": "https://wpnews.pro/news/hardening-an-ai-coding-agent-the-failures-and-the-code-that-fixed-them.jsonld"}}