Ground Truth

Your Agent Is Missing a State: Done, Gave Up, Killed

Mostafa DhouibMostafa Dhouib··7 min read
The short answer

Chess has a draw, which is not a loss. A solver returns unknown, which means it ran out of budget and not that no answer exists. A database names a deadlock victim so the transaction knows it was killed. Your agent has two states, done and killed, and when the cap trips the run must be filed as one of the two it has a name for, so it gets filed as done. Everything downstream believes it.

Your Agent Is Missing a State: Done, Gave Up, Killed

The short answer. Every mature field has a third terminal state, and your agent does not. Chess has a draw, which is its own outcome with its own rules rather than a loss. A constraint solver returns unknown, carefully distinguished from no-answer-exists because the two lead to opposite next actions. A database names a deadlock victim, so the losing transaction knows it was killed rather than that it failed. Your agent has done and killed. When the cap trips, the run still has to return something, and it can only be filed as one of the two states the system has a name for. So it gets filed as done.

This is the smallest bug in agent design and one of the most expensive, because it is not a bug in the loop. It is a bug in the type.

The moment it happens

A run hits its iteration cap. The loop stops.

Stopping was correct. The cap did exactly what it was built to do, and the bound was real and correctly enforced. Nothing failed.

But the function still has to return, and it has two things it can say: here is the result, or here was an error. The run did not error. It ran out of budget while making perfectly good progress. So it returns a result.

Done
the work is complete
Killed
something external intervened
Gave up
no name for this, so it gets filed as the first one
The failure point
Stopped because I gave up and stopped because I am done are different facts with the same type.
Every layer downstream believes the second one. It gets written to a database, sent to a customer, or fed to the next agent as input.
FigureThe run still has to return something, and the system has names for only two outcomes. Stopping at the cap was not an error, so it returns a result.

"Stopped because I gave up" and "stopped because I am done" are different facts. From the caller's side they are the same type, carrying the same shape, and every layer downstream believes the second one. It gets written to a database, sent to a customer, or fed to the next agent as input.

What the mature fields do

The absence is easier to see once you notice how consistently other fields refuse to make this mistake.

Chess has a draw. Not a loss with an asterisk. Its own outcome, with its own rules for how it is reached and what it means for a tournament. A system with only win and lose would have to file every draw as one of them.

Constraint solvers return unknown. A first-class answer meaning I exhausted my budget, deliberately distinguished from unsatisfiable, which means no answer exists. Confusing those two is catastrophic in opposite directions: one says try harder, the other says stop looking. A solver that returned unsat when it meant unknown would be considered broken.

Databases name a deadlock victim. When two transactions deadlock, one is chosen and killed, and it is told that it was killed. That is why it knows retrying is reasonable, rather than concluding its work was invalid.

Each of these fields arrived at the same place independently: there are three ways to stop, not two, and the third one has to be nameable.

There is also a formal precedent that predates every current framework. Cohen and Levesque's persistent goal, specified in 1990, includes exactly this: an agent maintains a commitment until it believes the goal is achieved or until it believes the goal is unachievable, and that second condition is a distinct terminal state. It was written down in the field's flagship journal thirty-six years ago, and essentially no agent framework in production today implements it.

Fix it at the type, not in the log

This is the part that gets done wrong even by teams who have understood the problem.

The instinct is to add a field. A stopped_reason inside the success response, or a log line recording that the cap tripped.

That does not work, and the reason is worth stating precisely: if "gave up" is a field inside a success object, every caller that does not specifically check that field treats the result as success. Most callers will not check, because the type told them they did not have to. And a caller written a year from now, by someone who never read this, will certainly not check.

A field inside the success object
stopped_reason sits inside a result
Every caller that does not check it treats the run as successful
Most will not check, because the type said they did not have to
A caller written next year certainly will not
Three outcomes at the return type
Structurally distinguishable, so the difference cannot be ignored
Done: store it, send it, build on it
Gave up: retry with more budget, escalate, or surface as incomplete
Killed: state may be inconsistent, clean up rather than retry
One return type cannot carry three meanings, and the cost of collapsing them is paid entirely downstream, by code that had no way to know.
FigureWhy adding a field does not fix this. The test: can a caller consume a gave-up result as though it were done without writing anything that looks wrong?

Make the three outcomes structurally distinguishable, so a caller cannot accidentally ignore the difference and a new caller is forced to handle it. A discriminated union, three distinct return types, a result type with three variants, whatever your language makes natural. The test is simple: can a caller consume a gave-up result as though it were done without writing anything that looks wrong? If yes, it is not fixed.

Worth noting that at least one framework does get this right and returns a distinguishable error for hitting the step limit. It is not the norm.

Why the three are genuinely different downstream

They are not three labels for the same event. They lead to different actions.

Done can be stored, sent, and built upon. The work is complete and the result is a fact about the world.

Gave up should be retried with more budget, escalated to a human, or surfaced as incomplete. It must never be silently persisted as an answer, because it is a partial result wearing a complete one's clothes. It also carries information worth keeping: how far it got, and what its measure was when it stopped.

Killed usually means something external intervened, a timeout fired, an operator stopped it, a deploy cycled the worker. The state may be inconsistent, so the correct response frequently involves cleanup rather than retry.

One return type cannot carry three meanings, and the cost of collapsing them is paid entirely downstream, by code that had no way to know.

The cascade this prevents

The reason this small type-level fix matters out of proportion to its size is what it enables everywhere else.

A caller that can distinguish gave-up from done can retry with a larger budget instead of accepting a truncated answer. An orchestrator can escalate rather than feeding a partial result into step six of a chain, where it becomes a foundation. A dashboard can show a gave-up rate, which is one of the most informative numbers in an agent system and one almost nobody has, because the events are indistinguishable from successes.

And a human can be told the honest thing. "I ran out of budget at 40 percent of the way through" is useful. A confident, incomplete answer is worse than no answer, because it is acted upon.

What to check on Monday

Three questions.

Ask someone to show you the return value of a run that hit the cap, and how the caller distinguishes it from a run that finished. If the answer involves reading a string or a log, it is not distinguishable.

Ask what your gave-up rate was last week. If nobody can produce the number, the state does not exist in your system.

And ask what happens to a gave-up result today. If the honest answer is that it gets stored or passed along like any other result, you have found a live bug, and it is not in the loop.

FAQ

What terminal states should an AI agent have? Three, distinguishable at the return type: done, gave up, and killed. Done can be stored and built upon, gave up should be retried with more budget or escalated and never silently persisted, and killed usually means external intervention with possibly inconsistent state.

Why does my agent report success when it hit the iteration cap? Because the run still has to return something and the system has names for only two outcomes. Stopping at the cap was not an error, so it returns a result, and every layer downstream reads that result as a completed answer.

Can I just add a stopped_reason field to the response? No. A field inside a success object is ignored by every caller that does not specifically check it, and most will not, because the type told them they did not have to. Make the three outcomes structurally distinguishable so a caller cannot consume a gave-up result as done without writing something that looks wrong.

What is a gave-up rate and why should I track it? The share of runs that stopped because they exhausted their budget rather than completing. It is one of the most informative numbers in an agent system and almost nobody has it, because in most systems those runs are indistinguishable from successes.

Is there prior art for the third state? Yes, and it is old. Chess has a draw, constraint solvers return unknown as distinct from unsatisfiable, and databases name a deadlock victim so the killed transaction knows to retry. Cohen and Levesque formally specified an agent abandoning a goal it believes unachievable in 1990.

Carrying a program like this one?

Tell us the system, the stakes, and the date that matters. You get a straight technical reply from the person who would lead the work, within 24 hours.

Bring us the program