Hallucination Is a One-Shot Problem

Code is the cheapest thing in the world to check, and an agentic loop checks constantly.

A hallucination that lived four seconds

The agent calls a method that does not exist.

It writes session.get_json(url). The client has session.get(url), which returns a response, and response.json(), which parses it, and the agent has run the two together into a name that sounds right. The arguments are the right shape and the code around the call assumes it works. There’s no sign on the page that the name was made up, because the model didn’t know either.

Then it runs the type checker, which rejects the call. It reads the error, looks up the client, splits the call in two and moves on. One tool call, about four seconds.

So the model hallucinated. But nobody was misled, because the first reader of the fabrication was a compiler.

Models make things up, and that isn’t in dispute. How often still matters — a model that’s wrong on every other line wastes the session even if each mistake dies in four seconds — but it matters much less than what each one costs to catch. The cost of a hallucination is the cost of catching it.

The right fear in the wrong place

The objection I hear most about agentic engineering goes like this: language models make things up, so the code they write can’t be trusted. If your experience of these models is a chat window, and for most people it is, that’s a reasonable conclusion. It carries over badly to an agent working in a repo.

A made-up legal citation is dangerous because checking it means somebody going to look for the case. A made-up method name mostly isn’t, because checking it takes four seconds and happens whether or not anyone thinks to do it. The model failed the same way both times. The difference is what it cost to check, and the model has no say in that.

An invented method name is the easy case, and not the one skeptics worry about. They worry about code that compiles and is still wrong. Some of that does get through a loop, and it comes back at the end of this post. Far less of it gets through than the chat window would suggest.

The only text the model did not write

Claude Code and Codex both run the same loop: make an edit, run a command, read the result, go again. Every turn has a check in it, which is why checking ends up costing close to nothing.

Working that way changes two things. Each step is small, an edit and a command rather than five pages, so when something is wrong it’s wrong about a small thing that is about to be run. And the result comes back before the next step is built on top of it. In one-shot generation, where the model writes the whole answer before anything checks it, an early mistake shapes everything written after it. In a loop, each step is tested before anything depends on it.

The model isn’t more careful inside a loop. It gets interrupted. Tool results are the only text in a session the model didn’t write. The reasoning, the plan, the code, the summary at the end, the confident account of what just happened — all of that comes out of the model’s own picture of things. The exit code doesn’t. Any tool call can contradict that picture: the file doesn’t contain what the agent assumed, the command exits non-zero, the endpoint returns a 404. I’ll call that contact — a result that came from outside the model.

Most contact is smaller and earlier than the checks people picture. It’s natural to think of compile, lint and test as gates that finished code passes through. But an agent that searches for a symbol before calling it has asked the type checker’s question a step early, and the invented method never gets written. That cost one search, and nobody asked for it. Compile, lint and test are the checks you see. A lot more happens in ordinary tool calls before any of them run.

When nothing answers back

Not every tool call makes contact. With a mocked service, a stubbed clock, a fixture or a --dry-run, the call is made, the loop turns and the transcript looks the same, but nothing outside the session was touched. What comes back is the session’s own assumption with a timestamp on it, and from inside the loop you can’t tell. A mock is a tool call that failed to make contact, and failed silently.

Contact isn’t truth either. Tests flake, caches go stale, error messages mislead about their own causes. And a test counts for only as much as its expectation. The run comes from outside the model, but an assertion the model wrote alongside the code is the model’s belief again. Where the expectation came from somewhere else — a spec, a bug report, a test older than the session — the test and the model can both be wrong, but not for the same reason.

All of this depends on the repo. One with no types, no tests and no documented way to run anything locally can’t answer the agent’s questions, and an agent working in it writes code the way a chat model writes an essay: fluently, and unchecked. That’s the one-shot condition again, rebuilt inside your project. How much a hallucination costs you is mostly settled before the session starts, by how the repo is set up.

What green proves

“Green” can mean five different results: the code compiled, the linter was quiet, the tests passed, the thing started and answered, or it ran alongside the services it talks to and they agreed. Each one catches things the one before it can’t.

Hallucination fails the first of those, and it’s easy to oversell. Plenty of wrong code compiles. What compiling proves is smaller and much harder to fake: that the code only refers to things that exist. An invented API, parameter or field is a claim that something exists, and that’s the claim a type system checks first. So the narrowest check catches the failure people worry about most, in the time it takes to save the file.

None of this needs a type system; types are just the cheapest way to get it. In an untyped codebase the same question gets asked by other means: an import smoke test, schema validation at the boundary, a one-command boot that runs everything touched at startup. What the loop needs is some check that fails fast when code refers to something that isn’t there. Without one, get_json survives until something runs that line.

People have argued for strict typing, fast tests and a one-command local run on ergonomic grounds for twenty years, and on those grounds it was always arguable; some engineers really are faster without types. The verification argument is different. It’s about what can be found out at all, and it holds whoever is typing.

What survives

A good loop, with real tool calls and a repo that can answer, catches code that doesn’t work. What gets through is code that works and is wrong, and it comes from what went into the loop.

What you asked for. The repo has no copy of the requirement, so nothing in it can object to a faithful implementation of the wrong thing.

What you encoded. A test written alongside the code carries the same beliefs as the code. When it passes, you’ve learned the two agree, which is worth something but isn’t independent evidence. Your test suite and your bug are siblings, and running them in a loop doesn’t change that.

What you never wrote down. Everything true about the system that nothing in the repo states. This is the biggest of the three and the hardest to see, because nothing fails when it’s wrong. It’s where the hallucinations that survive a loop tend to be, and they’re usually explanations. An endpoint returns a 500. The agent reads the log, finds a timeout, and reports that the connection pool was exhausted. The 500 was real and so was the timeout. The pool diagnosis was made up, and it stands, because nothing in the repo records the pool size or what a timeout there usually means. The type checker could reject get_json. There is nothing to reject this.

One habit helps with all three and is the only real defence against the third: prefer artifacts to assertions. Exit codes, transcripts and the actual payload are expensive to fake and cheap to check. The pool diagnosis is a claim about a number, and numbers can be looked up. Ask for the configured pool size and how many connections were in use when the timeout fired. If the agent can get them, the diagnosis is confirmed or thrown out in one more tool call. If it can’t, nothing in your setup can see the pool, and that’s the thing to fix.

What it would cost to find out

Look at what survived. The pool diagnosis wasn’t code. It was a paragraph the model wrote about code, after the last tool call, and no step in the loop ever checked it. That’s one-shot generation, happening inside the loop. The code in a session gets checked on every turn. The explanations, the summaries and the account of what was done mostly don’t, and that’s where hallucination still gets through.

So for any claim an agent hands you, ask what it would cost to check.

Where the answer is nothing, the loop is already checking, every step, and those hallucinations die in four seconds without anyone hearing about them. Where the answer is an afternoon, or a conversation with whoever wrote the ticket, you’re as exposed as the model is, and no amount of green will tell you so.

You can’t change how often the model makes things up. You can change what it costs to find out, and that’s where the damage was coming from all along.