The Agent Is Not Confused. Its Context Is Stale.
In a long session an agent keeps every tool result it ever saw, including the three older versions of a file that has changed twice since. It then acts on the wrong one. This is agent drift, and it is a correctness bug, not a token bill. Here is how staleness-aware pruning fixes it.
Here is a failure that does not show up in any of your dashboards. An agent is forty turns into a long task. It read a config file on turn six, edited it on turn nineteen, and edited it again on turn thirty-one. Now, on turn forty, it needs to make a decision based on that file's current contents. It has three versions of that file in its context, all of them looking equally authoritative, and it reaches for the one from turn six. Everything downstream is now wrong, and nothing errored. The tools all returned 200. The token count is fine. The agent just quietly acted on a world that stopped existing twenty-five turns ago.
This is agent drift, and if you run agents on tasks that take more than a handful of steps, you have shipped it. The uncomfortable part is that it is not a bug in your tools or your prompt. It is a direct consequence of the one thing every agent loop does by default: it keeps everything.
Keeping everything is the bug, not the feature
The standard agent loop is append-only. The model asks for a tool, you run it, you append the result to the message list, you send the whole thing back. Every observation the agent has ever made stays in the context, forever, in the order it arrived. We treat this as obviously correct because it looks like memory. In practice it is more like a desk that never gets cleared, where last week's printout sits on top of today's because nobody threw the old one away.
For short tasks this is genuinely fine. The trouble starts when the same resource gets observed more than once. A file you read, then wrote, then read again. A ticket whose status you checked, then changed. A cart you fetched, then added to. Each of those actions produces a fresh observation, and the old observations do not go anywhere. They sit in the transcript describing a state that is no longer true, and they are completely indistinguishable, to the model, from the observation that is still true.
There is research now putting numbers on how badly this bites. In one recent evaluation of long-horizon tool-using agents, roughly a fifth of tasks were forcibly terminated at the turn limit while the context was still only around seven thousand tokens. Seven thousand. These agents were not running out of window. They were getting confused inside a window they had barely filled, because the window was full of contradictory snapshots of the same handful of resources. The failure was in-context confusion, not length.
That distinction matters enormously for how you fix it, because it means the popular fix does not apply.
This is not a compaction problem
When people hear "long context is causing problems" they reach for summarization. Compact the old turns, keep the token count down, move on. I have written about context compaction for long-running agents, and it is the right tool when the problem is cost and length. It is the wrong tool here.
Compaction answers the question "how do I make this context smaller." Staleness answers a different question: "how do I make this context true." You can summarize a transcript perfectly, preserving every fact, and still carry forward the lie that the config file contains what it contained on turn six, because that fact was true when you recorded it and summarization has no idea it has since been contradicted. Shrinking a context that is lying to you just gives you a smaller, cheaper lie.
The lost-in-the-middle effect makes it worse. Models attend most strongly to the beginning and end of their context and least to the middle. So a stale observation that has drifted into the middle of a long transcript is not just present, it is sitting exactly where the model is most likely to grab it without scrutiny, while the corrected version near the end gets less weight. You are not fighting a neutral pile of history. You are fighting a pile that is actively weighted toward the oldest entries.
Key observations by the resource they describe
The fix starts with a reframe. Stop thinking of tool results as an append-only log of things that happened, and start thinking of them as a set of claims about resources, where each resource has exactly one current claim. A file has one current content. A ticket has one current status. When a new observation of a resource arrives, it does not join the old ones. It replaces them.
Concretely, that means keying observations by the resource they describe instead of by the order they arrived. Here is a small store that does exactly that.
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Observation:
resource_key: str # what this observation is ABOUT, e.g. "file:/app/config.yaml"
content: str # the tool's actual output
turn: int # when we recorded it, for ordering and staleness
tool_call_id: str
class ObservationStore:
"""Keeps at most one live observation per resource. A newer observation
of the same resource supersedes the older one instead of stacking on it."""
def __init__(self):
self._live: dict[str, Observation] = {}
self._superseded: list[Observation] = [] # kept for audit, never sent to the model
def record(self, obs: Observation) -> None:
prior = self._live.get(obs.resource_key)
if prior is not None:
# The old snapshot of this resource is now history, not context.
self._superseded.append(prior)
self._live[obs.resource_key] = obs
def live_observations(self) -> list[Observation]:
# Only the current truth for each resource, newest resource first.
return sorted(self._live.values(), key=lambda o: o.turn, reverse=True)
The whole idea lives in record. When a second observation of file:/app/config.yaml comes in, the first one is moved out of the live set and into an audit list that the model never sees. The context stops containing two versions of the file. It contains the one that is currently true.
The one piece of judgment this requires from you is the resource_key: deciding when two observations are about the same thing. That is a per-tool decision, and it is usually obvious. A file read is keyed by its path. A record fetch is keyed by table plus id. A web fetch is keyed by URL. You are encoding, once per tool, what "the same resource" means, which is knowledge you already have and were previously throwing away.
def resource_key_for(tool_name: str, args: dict) -> Optional[str]:
"""Map a tool call to the resource it observes. Returning None means
'this call is not a re-readable snapshot' (see the pitfalls below)."""
if tool_name in ("read_file", "write_file"):
return f"file:{args['path']}"
if tool_name == "get_record":
return f"record:{args['table']}:{args['id']}"
if tool_name == "fetch_url":
return f"url:{args['url']}"
# Actions like send_email or run_query are events, not resource snapshots.
return None
Render the context from live observations only
The store is only useful if the context you actually send is built from the live set, not from the raw append-only history. So the loop changes in one place: instead of concatenating every past tool message, you render the current observations plus the reasoning trail.
def build_tool_context(store: ObservationStore, current_turn: int) -> list[dict]:
"""Turn the live observation set into messages for the next model call.
Superseded snapshots are simply not here, so the model cannot reach for them."""
messages = []
for obs in store.live_observations():
age = current_turn - obs.turn
# Flag observations old enough that the underlying resource may have
# changed through a path we did not see. The model is told to re-read
# before trusting these, rather than acting on a possibly stale view.
staleness = "" if age < 8 else " [STALE: last seen %d turns ago, re-read before acting]" % age
messages.append({
"role": "tool",
"tool_call_id": obs.tool_call_id,
"content": obs.content + staleness,
})
return messages
Two things are happening here, and they are different. Superseded observations are gone outright, because you hold a newer one and the old one is pure noise. Old-but-not-superseded observations are kept but flagged, because you do not hold anything newer and cannot promise the resource has not changed underneath you through some path the agent never observed. The first is pruning. The second is staleness marking. Together they cover both ways an observation can stop being trustworthy: because it was replaced, or because it simply aged.
Note what this is not doing. It is not touching the model's own reasoning, the plan it wrote, or the record of which actions it has already taken. Those are not snapshots of external resources, and dropping them would give the agent amnesia about its own progress, which is a different and worse failure. You are pruning stale views of the world, not the agent's memory of what it has done.
The pitfalls that will bite you
Not every tool result is a snapshot. This is the one that causes real damage if you get it wrong. read_file returns a snapshot of a resource, so a newer read supersedes an older one. send_email does not return a snapshot of anything, it records that an event happened, and two sends to the same address are two distinct events, not one superseding the other. That is why resource_key_for returns None for actions: those results should flow through your normal history untouched. Collapsing events the way you collapse snapshots will quietly erase the fact that the agent already did something, and then it will do it again.
Superseding is not the same as forgetting the change happened. When the config went from version A to version B, version A is no longer current, but the fact that you edited it might be load-bearing for the agent's plan. Keep the current content in the live set, and if the transition matters, leave a one-line note in the reasoning trail ("edited config, set timeout to 30s"). Prune the stale snapshot, preserve the meaningful event. The snapshot and the fact that it changed are two different things, and only one of them is noise.
Cross-resource dependencies still need re-reads. If the agent edits file X and that logically invalidates its earlier read of file Y, no per-resource store can know that on its own, because the dependency lives in your domain, not in the keys. For those cases, the staleness flag is your backstop: mark the dependent observation stale so the model re-reads it rather than trusting a view that a sibling change may have invalidated.
Tune the staleness threshold to how fast your world moves. Eight turns is a placeholder. If your agent operates on data that other processes are writing to constantly, the window before an observation becomes suspect is short. If it works on a resource nothing else touches, you can trust observations far longer. The threshold is a claim about how volatile the underlying resource is, so set it per resource type, not globally.
The takeaway
An append-only agent loop treats every observation it has ever made as equally true, which means the moment any resource gets observed twice, the agent is carrying a contradiction it has no way to resolve. It does not fail loudly. It picks the wrong snapshot, usually an old one sitting in the weakly-attended middle of the context, and acts on a world that has already moved on. Compaction will not save you here, because a smaller context that still contains the stale snapshot is still wrong. The fix is to stop logging observations and start keying them: one live claim per resource, newer supersedes older, aged-but-current observations flagged for a re-read, and genuine events left alone. It is a change to how you assemble the context, not to your model or your tools, and on any agent that runs long enough to see the same thing twice, it is the difference between an agent that stays on task and one that slowly loses the plot.
If your agents work fine on short tasks but start making baffling decisions on long ones, stale context is one of the first places I look, and it is usually fixable without touching the model at all. Book a consultation call and we can find where your agent is acting on a world that already changed.
Viral Ruparel
Generative AI consultant helping teams ship reliable LLM and agent systems in production.
Contact Viral about your AI project →Frequently Asked Questions
Is this the same problem as context compaction?+
No, and conflating them is why a lot of teams reach for the wrong fix. Compaction is about cost and length: history grows without bound, so you summarize old turns to keep the token bill and latency down. Staleness pruning is about correctness: an old tool result describes a resource that has since changed, and the model treats that outdated snapshot as current truth. You can have a session that is well within its token budget and still drifting badly because it is holding three versions of the same file and weighting the wrong one. Compaction shrinks the context. Pruning keeps the context honest. Most serious long-running agents need both, applied for different reasons.
Why not just trust the model to figure out which observation is newest?+
Because the signal you are relying on, recency and ordering inside a long transcript, is exactly the signal that degrades as the context grows. The well-documented lost-in-the-middle effect means the model attends most to the start and end of its context and least to the middle, so a superseded observation sitting in the middle can easily outvote the current one near the end. Add that models do not have a reliable notion of which of two identical-looking tool results happened later, and you are trusting the failure mode to police itself. Making staleness explicit in the context, rather than hoping the model infers it, is the entire point.
When should an agent re-read instead of pruning?+
Prune when a newer observation of the same resource has arrived in the same session, because you already hold the current truth and the old snapshot is pure noise. Re-read when the resource may have changed underneath you through some path the agent did not observe, for example another process wrote to the database, or enough time passed that any cached view is suspect. A good pattern is to prune superseded observations aggressively and to mark long-idle ones as stale rather than trusted, so the model knows to re-read before it acts on them. The two techniques compose: pruning removes what you know is outdated, and staleness marking flags what you can no longer vouch for.
Related Articles
Your Agent Calls One Tool, Waits, Then Calls the Next
When an agent needs three lookups that do not depend on each other, running them one at a time makes the user wait for the sum of all three. The model already tells you which calls are independent. Running that batch concurrently collapses the wait to the slowest single call.
The Agent Can Call the Tool. That Is Not the Same as Allowed.
An agent that holds a tool runs it with the whole application's credentials, not the current user's. That gap is the confused deputy problem, and it is how a support agent ends up refunding an order the user was never allowed to touch. The fix is a fail-closed authorization check on every side-effectful call.
Your Agent Cost $2 Yesterday and $40 Today and You Cannot See Why
An agent's cost is elastic and path-dependent, so the same task runs for two dollars one day and forty the next. Plain logs will not tell you which step looped. A distributed trace built on OpenTelemetry's GenAI conventions shows you exactly where the tokens went.