The instinct when a tool call fails or an output is wrong is to retry the same way you’d retry a flaky network call: catch it, tell the model what went wrong, resend. That’s correct for a genuinely transient failure. It’s the wrong move for a failure caused by the model’s own reasoning, because “resend” doesn’t mean “give it a clean second attempt” — it means “give it a context window with the wrong answer already sitting in it, and ask it to do better.” A model doing next-token prediction over a transcript that already contains one confident, wrong attempt is anchored on that attempt, not liberated from it.
I think of this as context contamination: the failed path isn’t just a wasted turn, it’s now part of the evidence the next turn conditions on. The context window is a cache — what’s true for stale data is true here too, except the thing going stale is your own bad move, and you’re the one who put it back on the shelf.
Why the naive retry backfires
Picture an agent asked to extract a date from a messy log line, using a regex tool call. First attempt: \d{2}/\d{2}/\d{4}, which misses the line’s actual 2026.07.13 format. The naive retry loop appends a tool-result turn (“no match found”) and a nudge (“that didn’t match — try again”), then re-calls the model with the full transcript, including the original wrong regex, still sitting there in plain view.
What tends to happen next isn’t a fresh attempt. It’s a near-duplicate of the first: \d{2}\/\d{2}\/\d{4} with an escaped slash, or \d\d/\d\d/\d\d\d\d with the quantifiers spelled out, or the same pattern with \s* sprinkled around it. The model isn’t ignoring the feedback — it’s doing the statistically reasonable thing given a context where the most recent, most salient prior turn is “here is a plausible-looking regex for this problem,” which is exactly the anchor you didn’t want it anchored to. This is a cousin of loop drift: drift is the agent convincing itself it’s making progress across many turns; contamination is the narrower case where one specific wrong turn keeps re-asserting itself because you never took it out of the window.
Here’s a small illustration of the mechanism, not a claim about real pass rates — the point is qualitative, not a benchmarked number:
# Illustrative only: shows the SHAPE of the failure, not a measured pass rate.
naive_retry_messages = [
{"role": "user", "content": "extract the date from: ERR 2026.07.13 timeout"},
{"role": "assistant", "content": "tool_call: regex(pattern=r'\\d{2}/\\d{2}/\\d{4}')"},
{"role": "tool", "content": "no match"},
{"role": "user", "content": "that didn't match. try again."},
]
# The model's next completion is conditioned on ALL four turns above —
# including its own wrong regex, which is the strongest recent signal
# in the window about "what a plausible answer looks like here."
The transcript is doing the opposite of what you want a retry to do. You wanted the model to reconsider the date format. What it actually saw was: task, an example of a kind of answer, a terse rejection, and an instruction to produce another one of those. Nothing in that shape points away from the failed pattern’s neighborhood.
Not every retry needs a scrub
The fix is not “always wipe the context before retrying” — that’s wasteful and, for a real class of failures, wrong. As I explored in compaction is a lossy operation, any edit to the transcript — dropping, summarizing, or reordering turns — invalidates the cache and costs you in ways beyond just the context rewrite. The distinction that matters is where the failure lived:
- Transient / environmental failure — the tool timed out, the API returned a 503, the network blipped. The model’s reasoning was fine; the world hiccupped. Retrying with the same context, maybe the same exact call, is correct and cheap. Scrubbing here would throw away good reasoning for no benefit.
- Semantic / reasoning failure — the model picked the wrong regex, the wrong tool, the wrong interpretation of the task. The reasoning itself is the thing that failed, and it’s sitting in the transcript as the most recent example of “how to approach this.” This is the case that needs a scrub, not a resend.
Conflating the two is how teams end up with a single retry() wrapper that quietly makes semantic failures worse while “fixing” transient ones, and nobody notices because the transient case is the common one in testing and the semantic case is the one that shows up in production on the weird inputs.
The scrub: keep the constraints, drop the transcript
For a semantic-failure retry, the goal is a context that carries forward everything the agent learned about the task’s constraints, without carrying forward the specific wrong attempt as a stylistic template. That means replacing the raw failed turns with a short structured statement of what’s now ruled out. This is the deliberate, manual version of the cache management from context-window principles — you’re deciding what earns its space and what gets evicted. The key difference from a generic old-turns-get-summarized policy is that you’re not trying to preserve the detail; you’re deliberately erasing the failed path while keeping the constraint it discovered:
def scrub_for_semantic_retry(task: str, ruled_out: list[str]) -> list[dict]:
"""Rebuild a clean retry context: original task + explicit exclusions.
Drops the raw failed assistant/tool turns entirely — they don't get
to serve as a template for the next attempt."""
constraints = "\n".join(f"- {r}" for r in ruled_out)
return [
{"role": "user", "content": (
f"{task}\n\n"
f"Approaches already ruled out (do not repeat these or close variants):\n{constraints}"
)},
]
# After the regex miss above:
messages = scrub_for_semantic_retry(
task="extract the date from: ERR 2026.07.13 timeout",
ruled_out=["slash-delimited MM/DD/YYYY regex — this log uses dot-delimited YYYY.MM.DD"],
)
Notice what’s preserved and what isn’t. Preserved: the task, and a named reason the first approach failed — specific enough to actually steer the next attempt (dot-delimited, not slash-delimited), not just “that didn’t work.” Dropped: the literal wrong regex string, the terse rejection, and the turn structure that made the wrong answer look like the most recent worked example in the room. The model gets the lesson without getting the paper trail that taught it the wrong lesson.
What I’d actually do
- Classify the failure before you retry, not after. Transient (environment) vs. semantic (reasoning) determines whether you resend or scrub — a single generic retry wrapper can’t make that call for you.
- Never let a retry’s context contain the literal failed attempt as the most recent turn. If it’s semantically necessary information, restate it as a ruled-out constraint, not as a transcript the model can pattern-match against.
- Cap scrubbed retries too. A scrub buys a genuinely fresh attempt, not infinite ones — after two or three ruled-out constraints pile up, the more honest move is to escalate to a human or a different tool, not keep rebuilding a cleaner box for the model to fail in again.
- Log what you scrubbed. The ruled-out list is valuable telemetry independent of whether the retry succeeds — it’s a direct readout of which of the model’s assumptions were wrong, which is exactly the kind of thing worth catching in an eval before it ships.
A retry is supposed to be a second, independent look at the problem. The naive version isn’t independent — it’s the first look, plus its own wrong answer, plus an instruction to disagree with something still sitting in front of it. Scrub the transcript, keep the lesson, and the second attempt actually gets to be a second attempt.