Skip to content

19 min read

Replanning changes the path; retrying repeats the same action

On this page

When an agent that plans before it acts hits a failure, it has two responses available and they look almost identical from outside. It can try the same thing again, which is a retry. Or it can produce a new plan, which is a replan. The words suggest that the second is the more considered of the two, and in a well-behaved system it is. The trouble is that the observable difference between them — a new plan object exists — is not the difference that matters, and a system built to watch for the observable one will happily run a retry loop for nine minutes while reporting that it is replanning.

The two responses also cost very different amounts. A retry is one tool call. A replan is a model round trip that generates a new plan, plus every token of the plan and the observations behind it resent on the request after that. Choosing wrongly is therefore expensive in both directions: retrying a failure that will never clear burns attempts on an action that cannot succeed, and replanning a failure that would have cleared burns a plan generation on a path that was never the problem. Telling them apart is what the rest of this chapter is about, and the case that shows why it is not obvious is a run that produced six plans and one action.

Six plans against one missing file

Two weeks after gating shipped, a run started at 03:41 and stopped at 03:50 having done nothing. It had produced six plans and 71,400 tokens. Its first plan was the reconciliation path — load a vendor's monthly invoice export from a drop bucket in S3, reconcile it against the warehouse, and issue credits for the differences. The export was missing again, the pre-check fired, and the executor replanned as designed. Here is what it replanned into.

text
plan 1  s3.get  vendor/exports/2026-03-invoice.csv                      -> NoSuchKey
plan 2  s3.get  vendor/exports/2026-03-invoice.csv  (region=us-east-1)  -> NoSuchKey
plan 3  s3.list vendor/exports/  -> []   then  s3.get <latest>          -> NoSuchKey
plan 4  s3.get  vendor/exports/2026-03-invoice.csv                      -> NoSuchKey
plan 5  s3.get  vendor/exports/2026-03-invoice.csv  (wait 30s first)    -> NoSuchKey
plan 6  s3.get  vendor/exports/2026-03-invoice.csv                      -> NoSuchKey

Six plans, one action. The model was replanning in the sense that it emitted a new plan object each time; it was retrying in the sense that every plan asked the same tool for the same key. Plan 3 was the only one that acquired information — the empty listing, which said the prefix was empty and therefore that no key in that bucket was going to work — and plan 4 discarded it and asked for the original key again.

Plan 3's empty listing deserves a sentence on its own, because it is the point at which the run had enough to stop and did not. s3.list on the prefix vendor/exports/ returned an empty array. An empty listing on a prefix is a statement about every key under that prefix at once: if no object is enumerated there, then no object exists there, and therefore no spelling of the key, no region hint and no length of wait can make s3.get on a key under that prefix return an object. That single observation closes off plans 4, 5 and 6 before any of them is written. It also rules the failure out of the transient class entirely, because NoSuchKey after an empty listing is not a failure that the world is going to clear by itself. The run had the decisive fact at plan 3 and discarded it at plan 4.

The distinction that matters is not whether a new plan document was produced. It is whether the action changed.

What separates the two

A retry repeats the same action, possibly with different wording, in the hope that the system behaves differently this time. That hope is well-founded for a narrow class of failures: 503, 429, connection reset, statement timeout under load, a lock that another transaction will release. For those, retry with backoff, each attempt waiting longer than the one before it, is correct and replanning is actively harmful, because it spends 1,200 tokens and three seconds rewriting a path that was never the problem.

A replan changes what will be done. There are three ways it can, and naming them is what turns "try something else" into something a system can evaluate:

  • A different tool. The export is not in S3, so fetch it from the vendor's API instead of the drop bucket.
  • A different order. The index build cannot proceed while the backfill runs, so build the index first and let the backfill maintain it.
  • A satisfied prerequisite. The export is not in S3 because it was never dropped, so trigger the vendor's re-export job, wait for it, and then load.

Plan 2's region argument is none of these — same tool, same target, a parameter that does not affect key resolution. Plan 5's thirty-second wait is a retry with a sleep in front of it. Only plan 3 attempted a different tool, and it did so in service of the same action.

RetryReplanTerminal
The actionIdenticalDifferent tool, order, or prerequisiteNone
Appropriate forTransient failures — 5xx, 429, timeouts, lock contentionThe world differs from what the plan assumedThe failure recurs with no new information available
Evidence it is rightThe error class is in the transient setThe new plan's fingerprint differs from the last oneTwo consecutive plans with the same fingerprint
Cost per attemptOne tool call~1,200 tokens plan generation, plus the resendZero — the run stops
Bounded byAttempt count and backoffThe run's shared budget—

The instructive part of that table is the "evidence it is right" row, because it is the only one an executor can evaluate at the moment it has to choose. Everything above it describes intent, and intent is not available to a program. The gap between the retry column and the replan column on that row is a gap between two different kinds of test. Retry's test looks at the error that just arrived and asks whether its class is in a fixed set — a lookup, decided before the run started, against a list somebody wrote. Replan's test looks at the plan that was just produced and compares it against the plan before it, which is a test that cannot be made until a second plan exists. That is why the two responses cannot be gated by the same mechanism, and it is also why the terminal column has no cost per attempt: the third column is not an action, it is the absence of one.

The cap is not the fix

The obvious response to the 03:41 run is to allow at most two replans. It stops the bleeding, and it is the wrong rule for two independent reasons.

It answers the wrong question. The 03:41 run did not fail because six is more than two. It failed because plans 2, 4, 5 and 6 were the same action as plan 1, and a run that replans six times into six genuinely different approaches is a run that is working — that is exactly the behaviour you built replanning for. A cap treats a productive sequence and a stuck one identically, which means it will truncate the runs where replanning was earning its keep. The week's telemetry made this concrete: of 41 runs that replanned three or more times, 34 never completed, and the 7 that did completed on the third or fourth plan after a genuine change of approach. A cap of two would have killed all 7 and saved nothing on the 34, which were already burning budget by then.

The claim that a cap saves nothing on the 34 is worth stating carefully, because a cap plainly does end them sooner and that is not nothing in tokens. What it saves nothing on is failures. Every one of the 34 was going to fail whether it was stopped at plan three or at plan six; the cap changes when the failure is reported and how much was spent reaching it, and it changes neither whether the run succeeds nor whether anyone learns why it did not. Measured against the thing a stopping rule is for, the cap's yield on the population it fires most often against is zero, and its cost on the population it fires wrongly against is every one of the 7.

It also hides the diagnosis. The 34 stuck runs shared a property that a count cannot see and that is the actual signal: the same failure signature kept recurring. Once you can see that, you do not need a cap, because you have a stopping condition that means something.

It is worth naming why a cap is nonetheless the first thing everybody proposes, since the instinct behind it is sound. A cap is a defence against unbounded cost, and unbounded cost was visibly the symptom at 03:41. The failure is in what the cap measures. Replan count is a number that a converging run and a looping run produce identically — plan six of a run that has changed approach five times and plan six of a run that has asked for the same key five times are both plan six. The quantity that separates them, whether the action changed, is not the quantity the cap reads, and it varies fully while the count is doing nothing but going up. Any rule built on a measurement that holds still across the distinction it is supposed to draw will misclassify in both directions, which is precisely what the telemetry shows it doing.

Convergence, expressed as four rules

Every replan must be conditioned on information the previous attempt did not have. This is the rule the other three implement. Operationally it means the replan prompt carries the failed probe's actual output — object_exists(vendor-drops, vendor/exports/2026-03-invoice.csv) -> False, and the s3.list result that came back empty — as observations, not as a summary. It also means that when there is no new information, the correct next action is to acquire some. A replan triggered by a precondition failure with nothing new to condition on should produce a diagnostic step (list the bucket, check the vendor's status endpoint, read the drop job's log) whose only job is to generate the observation the next plan will need. The 03:41 run's plan 3 was that diagnostic step, arrived at by accident, and its result was thrown away.

Two consecutive plans that are the same means the run is terminal. A plan's fingerprint is the sequence of its actions, ignoring cosmetics.

python
def fingerprint(plan):
    parts = []
    for s in sorted(plan["steps"], key=lambda x: x["n"]):
        ident = {k: v for k, v in s["args"].items() if k in IDENTIFYING[s["tool"]]}
        parts.append((s["tool"], tuple(sorted(ident.items()))))
    return hashlib.sha256(repr(parts).encode()).hexdigest()[:12]

Every line of that function is throwing something away, and what it throws away is the whole design. Sorting the steps by n discards any difference that is only a difference in how the plan was serialised. The dictionary comprehension keeps a step's arguments only if the argument name appears in IDENTIFYING for that tool, which discards every argument that does not change what the call touches. Sorting the surviving pairs discards key order. What is left is a list of pairs of tool name and identifying arguments, in execution order, and hashing it turns that list into a fixed-width string that can be compared with ==.

IDENTIFYING lists, per tool, the arguments that determine what the call touches — bucket and key for s3.get, table for warehouse.load — so a region hint and a thirty-second wait do not disguise a repeat. On the 03:41 run, plans 1, 2, 4, 5 and 6 all fingerprint to the same twelve characters. Plan 2 would have ended the run in eleven microseconds.

Run plan 2 through the function by hand and the collision stops being a claim. Its single step is s3.get with bucket=vendor-drops, key=vendor/exports/2026-03-invoice.csv and region=us-east-1. region is not among the identifying arguments for s3.get, so the comprehension drops it, and what survives is the same bucket and the same key that plan 1 produced. The parts list is therefore byte-identical to plan 1's, repr of it is the same string, and the same string put through SHA-256 gives the same digest — not a similar one, the same one, because that is the one thing a hash function guarantees.

The eleven microseconds are the argument for the rule rather than a detail of it. A dictionary comprehension over a handful of keys and one SHA-256 of a short string is work you cannot measure at the scale of a run. What it would have prevented was a further four plans, each costing a model round trip to generate and a resend of everything above it. A check whose cost is measured in microseconds and whose saving is measured in thousands of tokens does not have to be right often to be worth running. It has to be right sometimes and cheap always.

A repeated failure signature is the trigger, not a count. The signature is what failed, not how often something did.

python
def signature(step, result):
    return (step["tool"], error_class(result), target_of(step))

("s3.get", "NoSuchKey", "vendor-drops/vendor/exports/2026-03-invoice.csv") appeared six times. The rule is that a signature seen twice, across two different plans, is terminal for that approach: the agent has now demonstrated that changing the plan does not change this outcome. This catches a case the fingerprint rule misses, which is a run that keeps producing different plans that all fail on the same underlying obstacle — six distinct routes to the same missing file. Both rules are needed because they catch different shapes of stuck.

The two rules are worth reading as a pair, with each one's licence and its leftover stated. The fingerprint rule licenses one conclusion only: this plan proposes the same actions as the last one, so executing it will produce the same outcome. That is airtight when it fires, and it is silent on every run whose plans differ in any identifying argument, which is the residual the signature rule is there to absorb. The signature rule licenses a wider conclusion — changing the plan has now twice failed to change this outcome, so the obstacle is not something the agent can plan around — and it buys that width by assuming the obstacle sits still between the two observations. Its residual is everything that does not sit still, which is the subject of the last section of this chapter.

Together they close over "stuck" under two conditions, and it is worth stating them rather than leaving them implied. The obstacle has to be stable across at least two attempts, or the signature rule reads a flicker as a fact. And IDENTIFYING has to actually name every argument that changes what a call touches, or the fingerprint rule reads a genuine repeat as a new plan. Neither condition is checked by anything. Both are assumptions the code depends on and cannot verify, which is a different thing from a bug and is worth writing down where the on-call engineer will find it.

Replans draw on the same wall-clock and token budget as everything else. The run has one budget. Not a plan budget and a step budget and a retry budget, which lets a run be simultaneously within all three limits and nine minutes long. One deadline, one token ceiling, decremented by every model call including plan generation, and checked before each one.

python
class Budget:
    def __init__(self, deadline_s, tokens):
        self.until = time.monotonic() + deadline_s
        self.tokens_left = tokens

    def spend(self, kind, tokens):
        self.tokens_left -= tokens
        if self.tokens_left <= 0:
            raise Exhausted(f"token budget spent, last on {kind}")
        if time.monotonic() > self.until:
            raise Exhausted(f"deadline passed, last on {kind}")

The 03:41 run spent 71,400 tokens against a median run of 18,400, and 43,200 of those went to plan generation and to re-sending five superseded plans that were still in context. With a shared budget of 30,000 tokens, that run stops at 03:44 with a partial result and an explanation. With deadline_s=300, it stops at 03:46. Neither number is a cap on replans; both are limits on what the run is allowed to consume, and they apply identically whether it is spent on replanning, on tool calls, or on the model changing its mind.

Both of those stop times are worth deriving rather than accepting, because the two limits reach almost the same place by completely different routes. The deadline is the easy one: five minutes of wall clock added to a start of 03:41 lands at 03:46, and nothing about the run's content is involved. The token limit is the interesting one. The run burned its 71,400 tokens across the nine minutes between 03:41 and 03:50, so at a roughly even rate the point at which it had spent 30,000 of them falls a shade past four tenths of the way through — under four minutes in, which puts it inside the 03:44 minute. That the two land within a couple of minutes of each other is not a coincidence anybody engineered; it is what happens when a run spends at a steady rate, and it is the reason a single budget object holding both is not redundant. Change the run's rate and the two answers separate immediately. A run stalled on a slow tool blows the deadline with tokens to spare, and a run generating plans as fast as the provider will serve them blows the token ceiling with minutes left.

Notice also what the token figure says about where the nine minutes went. Of the 71,400 tokens the run spent, 43,200 — well over half — went on producing plans and on re-sending plans it had already superseded. The remainder covered every actual attempt to fetch a file. A run whose majority spend is deliberation about work rather than work is the shape the shared budget is designed to catch, and it is invisible to any limit that counts only tool calls or only steps.

What happens when the run is terminal

Stopping is only half of the response. A terminal run has to hand back something a human can act on inside a minute, because the whole point of catching the loop early is to shorten the path to a person who can fix it. That means the last plan, the failure signature that recurred, the probe outputs that established it, and the state of every step — which succeeded, which were skipped, which never ran, and what the recovery probes say about the world now.

text
TERMINAL after 2 plans, 6,240 tokens, 41s of a 300s budget.
reason: identical plan fingerprint 4c9e1a77b2f0 emitted twice.
recurring signature: (s3.get, NoSuchKey, vendor-drops/vendor/exports/2026-03-invoice.csv)
evidence: object_exists -> False at 03:41:09 and 03:41:33
          s3.list vendor/exports/ -> [] at 03:41:31
steps: 1 DONE (staging schema created)  2 NEVER_RAN  3 NEVER_RAN
world:  staging.vendor_invoice_202603 exists, 0 rows. No credits issued.

That paragraph is what an on-call engineer needs, and producing it costs nothing beyond writing down what the checks already computed.

What convergence looks like when it works

The rules are worth judging on a run they should not interfere with. Same missing export, same trigger, rules enabled.

text
plan 1  fp=4c9e1a77b2f0  pre-check on step 3 fails: object_exists -> False
        classify: nothing in plan produces it -> REPLAN, observations attached
plan 2  fp=b013d5e84a91  DIAGNOSTIC
        s3.list vendor/exports/            -> []
        http.get vendor.status/drops       -> last drop 2026-03-11T23:50Z, ok
        read_logs job=vendor_drop since=48h -> "PUT ok, 61,400 rows, 2026-03-11"
        conclusion: the file was written and later removed
plan 3  fp=7f2ac60918de  vendor.request_export(period=2026-03)
        poll object_exists every 60s, deadline 900s
        then the original load / reconcile / report / credit path
        -> completes 04:19, 8,240 tokens of a 30,000 budget

Three plans, and neither stopping rule came near firing. The fingerprints all differ because the actions genuinely differ. The failure signature ("s3.get", "NoSuchKey", …) appears once, because plan 2 did not retry the fetch — it went and found out why the fetch failed. That diagnostic middle step is the shape a healthy replan takes, and it is the thing the 03:41 run never did deliberately.

It is worth being precise about what made plan 3 possible: it is conditioned on facts that did not exist when plan 1 was written. The drop job's log line is what rules out "the vendor never sent it" and rules in "something deleted it", and that distinction is the entire difference between requesting a re-export and waiting for a file that was never coming. Replanning without that observation is guessing with extra steps.

Where the rules stop working

Convergence rules assume the obstacle is stable long enough to be recognised, and some are not. A vendor endpoint that fails intermittently at 30% produces a signature that recurs, then clears, then recurs; treating the second occurrence as terminal ends runs that would have succeeded on the next attempt. This is why the transient error classes have to be routed to retry before the signature counter ever sees them — the classification in chapter 5's table is load-bearing here, and a 503 misfiled as a precondition failure will terminate healthy runs.

The fingerprint rule has the opposite failure. Two plans that differ in one argument — a date, a limit, a page token — fingerprint differently and pass the check, even when the difference is cosmetic in context. IDENTIFYING is a hand-maintained mapping, and a tool whose important argument is missing from that list is a tool the agent can loop on forever with a counter that never fires. Adding a new tool means adding its identifying arguments, and forgetting to is not an error anything reports.

What separates the two responses, then, is nothing you can see in a plan document and everything about whether the action changed. A retry belongs to failures the world clears without help, and its evidence is the error's class. A replan belongs to failures where the world differs from what the plan assumed, and its evidence is that the new plan touches something the old one did not. Where neither holds, the run is terminal, and it is terminal on evidence rather than on a count: an identical fingerprint twice, or one failure signature across two different plans. What that buys is not a cleverer agent. It is a run that stops within seconds of having nothing left to learn, and hands a person the signature, the probe outputs and the state of every step — which is the only thing in this chapter that ever fixed anything.

By now the planning agent has a plan schema, a pre-execution checker, a probe registry, a gated executor with six failure responses, a fingerprint function, a signature counter, and a shared budget. That is real engineering, and it exists to make one architectural choice safe.

Nothing so far has asked whether that choice was worth making for this agent. The 1,200 tokens and three seconds have been treated as a cost to be justified by the failures they prevent, and the failures they prevent have been described but never counted. A reconciliation task and a row-count investigation clearly sit on opposite sides of the line, which is easy; the median task in a real queue sits somewhere in the middle, and the question of which side it belongs on is not a matter of taste.

Planning — read the current version at https://daviddominguez.dev/learn/en/planning/replanning-changes-the-path-retrying-repeats-the-same-action

Back to top