I run a fleet of agents unattended, every day. The first time I left one going overnight, it reported a clean run. Every step logged "success." The actual work was half-finished, and it had burned a small fortune getting there. None of the five failures below throw an error. They quietly produce a wrong result and hand it to the next step, which trusts it completely.
Here is the map. The same loop that shines in a demo has five silent failure points once it runs for real.

1. The agent forgets everything between steps
The agent's plan lives only in the model's context window, and that window gets summarized, truncated, or reset on any long run. When it does, the plan goes with it. Then the agent redoes work it already finished, or flatly contradicts itself. The demo never hits this because the demo is short.
Treat the context window as a cache, not your source of truth. The durable state lives in a store, and every step loads it back before it does anything.
// BAD: the plan is stuffed into a prompt string. One summarization and it's gone.
const prompt = `Goal: ${goal}. Done so far: ${doneList}. Next?`;
const next = await model.decide(prompt);
// GOOD: state lives in a store. Every step LOADS it, then saves.
type AgentState = { goal: string; done: string[] };
async function step(runId: string) {
const state = await loadState(runId); // re-hydrate from the store, every step
const decision = await model.decide(state); // the model sees ALL prior state
await saveState(runId, { ...state, done: [...state.done, decision.action] });
return decision;
}
The helpers here (loadState, saveState, and their friends further down) are yours to implement. The pattern is the point. If your agent has to remember something across steps, that memory belongs on disk or in a row, not in the prompt.
2. It trusts its own "done"
The agent believes its own report. The model says "I wrote the file," your code marks the task complete, and nobody checked the disk. But success usually means the run finished, not that the artifact exists. When the model authored that field itself, it means even less.
So check the world, not the claim. And check it well, because a stale artifact from a previous run will pass a lazy check.
// BAD: trust the model's word.
const result = await agent.run("write report.pdf");
markComplete(result); // result.success means "the run ended", not "the file is there"
// GOOD: clear any stale copy, then prove the new one exists.
await rm("report.pdf").catch(() => {});
await agent.run("write report.pdf");
const stat = await fs.stat("report.pdf").catch(() => null);
if (!stat || stat.size === 0) {
throw new Error("agent reported done, but report.pdf isn't there");
}
That one rule, "a claim is not a fact," kills a whole class of confabulation bugs. The agent can be wrong without meaning to, so your code shouldn't take it at its word.
3. A tool call "succeeds" but did nothing
A tool can return without an error and still do the wrong thing, or nothing at all. An HTTP 200 can mean "accepted but queued," "partial write," or "no-op." An exit code of 0 means the program said it succeeded: curl without -f exits 0 on a 404, and grep exits 1 when it simply found nothing. And fetch doesn't reject on a 500 or a 403 either, only on a network failure, so "no exception" is a weaker signal than it looks.
Validate the effect you wanted, not the status you got back. Read the write back out and confirm it landed.
// BAD: no exception, so it worked, right? (fetch resolves on 500 and 403 too.)
await fetch(url, { method: "POST", body });
// GOOD: check the status, then read the record back.
const res = await fetch(url, { method: "POST", body });
if (!res.ok) throw new Error(`POST failed: ${res.status}`);
const { id } = await res.json();
const saved = await fetch(`${url}/${id}`).then(r => (r.ok ? r.json() : null));
if (saved?.status !== "written") {
throw new Error("write returned 200 but did not persist");
}
On an eventually-consistent store, poll with a short backoff instead of reading once, or a write that really landed can still 404 on the immediate read-back. I once watched an agent post the same update fifteen times: each call returned 200, a downstream queue silently dropped it, so the agent re-checked, saw nothing, and posted again.
4. Small errors compound into a broken chain
The fourth failure is pure math. Assume steps fail independently, which is optimistic, and a step that works 95% of the time still poisons the chain. 0.95 ** 10 is 0.60. Four in ten full runs now fail somewhere, and a three-step demo hid it completely.
Retries buy most of it back, so build the chain to absorb flakiness: retry, dedupe, checkpoint.
const reliability = 0.95 ** 10; // 0.5987... not "reliable" across ten steps
async function safeStep(fn: () => Promise<any>, key: string) {
if (await alreadyDone(key)) return getResult(key); // dedupe: skip work already recorded
const out = await withRetry(fn, 3); // absorb transient flakiness
await recordResult(key, out); // checkpoint so a failure resumes here
return out;
}
Three attempts at 95% is 99.99% per step, which is 99.88% across the ten. But that dedupe lives at the orchestrator level only. The downstream operation still has to be idempotent on its own: pass an idempotency key to the payment or API provider, because a crash between the call and the checkpoint will run it twice, and twice means double-charge, double-send, double-write.
5. The loop never stops (and neither does the bill)
The fifth failure is the runaway loop. while (!done) { await agent.step() } is one bad plan away from looping forever, and every iteration is a paid API call. Nobody notices in the demo because the demo finishes. Unattended, it can spin for hours and hand you an invoice instead of a result.
Put hard ceilings on it that fail closed. When a limit trips, the agent stops and tells you, rather than grinding on.
class BudgetError extends Error {}
// A step cap AND a token/cost cap. Both fail closed.
let steps = 0, tokens = 0, done = false;
while (!done) {
if (++steps > 25) throw new BudgetError("step budget exceeded");
if (tokens > 200_000) throw new BudgetError("token budget exceeded");
const out = await agent.step();
tokens += out.usage.total_tokens ?? 0; // field name is provider-specific (see below)
done = out.done;
}
Watch that usage field. OpenAI gives you total_tokens; Anthropic gives you input_tokens and output_tokens and no total. Read the wrong one and tokens becomes NaN, every NaN > 200_000 is false, and your cap silently never fires. That is this whole article in one line: no error, wrong result. And notice the throw is a BudgetError, not a plain one. If a budget throw gets swallowed by the retry wrapper from failure #4, you re-burn the whole budget three times. That is the exact bill this guard exists to prevent.
The pattern under all five
One idea ties these together: the model proposes, deterministic code disposes. Treat every model output as untrusted input to an ordinary program. Verify it against the real world. Put hard limits around the loop. Keep the LLM for the fuzzy judgment it's good at, and keep the guarantees in plain code that can't hallucinate.
Here is the ship checklist:
- [ ] State lives in a store, loaded back every step, not in the context window
- [ ] Every "done" is verified against the source of truth
- [ ] Tool calls are checked by effect, not by status code
- [ ] Steps are deduped and retried; the downstream op is idempotent
- [ ] The loop has a step cap and a real token/cost cap (mind the field name)
That list is the whole difference between a thing that demos well and a thing you can actually walk away from.
I write these from real work at astraedus.dev, where I build apps and tools. Building something, or stuck on something like this? Reach me at astraedus.dev or [email protected].
Get the next one in your inbox → subscribe at astraedus.dev.