DEV Community

weiwuji
weiwuji

Posted on

490,000 Characters Killed an Agent Session: A Production Postmortem on Context Blowup

The Pain: Your agent session suddenly dies mid-task. The error looks like an overflow or a rate limit, retries do nothing, switching models does nothing, and you cannot even tell which step broke it. Context engineering feels like a black box — until it isn't.
What You'll Learn: The full evidence chain of one 490,000-character session incident: context is not free, gradual death goes unnoticed, the real killer was content-audit poisoning (not length), 400 is a non-retryable error class, and a fix that comes back because reset ≠ erase. Plus the physical mechanism that prevents it: a session-length guard with channel-aware handling and a poisoned-session watchdog.


Production incident hero card, 2026-09-04, logistics agent session 20260904_084257: giant red number 490,000 characters stuffed into one Agent session, a 5-hour WeChat session about 16x a normal one. Comparison band: normal session 20K-30K chars vs this session ~490K chars (~220K tokens). Three impact cards: primary and fallback model both rejected (400 Content Exists Risk, 3 retries, all failed within 5 seconds), tool outputs piled 342K chars into history, human took over and resent successfully in 5 minutes. Teal conclusion bar: context must be managed physically — threshold, alert, compress or rebuild

One: 11 Characters Killed a Session That Had Run for Five Hours

September 4, 2026, 13:38. The boss sent a message to the logistics agent on WeChat:

NRT 018-92898702 // CIF — send the arrival notice

NRT is Narita airport, CIF is the Incoterm for cost-insurance-freight — an ordinary freight-forwarding instruction (11 characters in the original Chinese). But that short instruction made the agent fail with "model provider failed" three times in a row. Session 20260904_084257 had started at 08:42 that morning and had been running for almost five hours. The traceback left in errors.log looked like this:

openai.BadRequestError: Error code: 400
{'error': {'message': 'Content Exists Risk',
 'type': 'invalid_request_error',
 'param': None, 'code': 'invalid_request_error'}}
Enter fullscreen mode Exit fullscreen mode

The failure chain was very clean: primary model deepseek-v4-flash returned 400 → the system auto-fell-back to deepseek-v4-pro → same 400 → three retries all failed → at 13:38:32 the session was marked context-overflow and the error went back to the user.

The 13:38 failure chain: an 11-character instruction carrying a request with 490K chars / 220K+ tokens of history. Timeline: 11:03:12 first 400 Content Exists Risk — system only logged overflow, no compress, no rebuild, session ran sick for another 2.5 hours. 13:38:25 boss sends NRT instruction — 158th round of history, request body ~490K chars. Primary model deepseek-v4-flash returns 400 (attempt 1/3). Auto fallback to deepseek-v4-pro returns the same 400 — switching models does not switch the request body. Retry x3 all 400 — non-retryable client error, 13:38:32 marked context-overflow. 13:43 manual resend succeeds (Sent #434) — 5 minutes later, human-in-the-loop. Teal conclusion bar: 400 is not a network blip — until the poison leaves the request body, 100 retries change nothing

The turning point came five minutes later: at 13:43 the agent's human operator resent the message manually and it went out normally (message Sent #434). Machines all failed; a human took over and it worked — that thread will come back repeatedly in this postmortem.

Only during the review did we see the counter-intuitive fact: those 11 characters never reached the model. What got rejected was the entire request body carrying ~490,000 characters of history and roughly 220,000 tokens. By 13:38 the session had accumulated 158 rounds of history — system prompt, conversation and tool outputs all in the request, and the 11-character new message was just a small tail on it. A shorter instruction does not mean a lighter request.

Two: Postmortem 1 — Context Is Not Free

The session was not born huge. In the morning it did three big jobs back to back: a 7-day-window report refactor, a deep agent search for the Slovenia direction, and a freight-rate inquiry for the Ljubljana direction. Each job called tools repeatedly — and every tool output stayed in the conversation history in full.

The large blocks countable in agent.log: a single session_search output of 39,985 characters, a single terminal output of 58,295 characters, and browser snapshots of 16,000+ characters that occurred several times. I summed all tool outputs across the session's five hours: 342,340 characters — about 342K. Add the conversation history and the whole session came to roughly 490,000 characters.

What should a normal session be? Other sessions in our production environment run at 20K–30K characters. This one was more than 16x normal. It did not blow up in a single call — it was stacked up by dozens of large outputs, one at a time, and every stack made the next request heavier. The session was heavy from the start: the first round at 08:43 already carried 33,123 input tokens — the system prompt and tool descriptions are constants, occupying space from the door — and by 11:00 a single request's input had grown to 243,354 tokens: a sevenfold increase in a little over two hours.

Session growth: four tool rows showing single large outputs — session_search 39,985 chars, terminal 58,295 chars, browser snapshot 16,000+ chars repeated, and dozens of medium/small outputs (read files, run scripts, load pages) that add up in small steps — flowing into a session-size comparison bar: normal session 20K-30K chars vs this session ~490K chars split into tool outputs 342K plus history ~150K. Teal conclusion bar: 490K is not sudden — dozens of big tool outputs stacked it up, one call at a time

Context is not free: every time an agent calls a tool, it permanently adds weight to the session. We economize every line when writing prompts, yet tool output runs naked into history. That bill always comes due.

Three: Postmortem 2 — Gradual Death: Nobody Notices While It Is Dying

The time point worth fearing is not 13:38. Going back through the logs, this session reported the same 400 Content Exists Risk for the first time at 11:03:12 — all three retries failed.

What did the system do? It wrote a single log line, designed to stop the transcript from growing unboundedly:

Skipping transcript persistence for context-overflow failure
in session 20260904_084257 ... to prevent session growth loop.
Enter fullscreen mode Exit fullscreen mode

No compression. No rebuild. No alert. The session kept running and kept stacking for a full 2.5 hours between 11:03 and 13:38, until the boss's message hit it and the whole thing died. This is the most insidious part of context blowup: nobody notices at the moment it breaks; by the time you notice, it is already dead. How do you tell whether a system is handling a failure? Look for an action in the logs — compress, rebuild or alert; at least one of the three counts as handling. Writing one "overflow" line and continuing to run is the same as not handling it.

Four: Postmortem 3 — The Real Killer Is Not Length: Content-Audit Poisoning

At this point the agent's initial self-diagnosis was "context too long, the session blew up" — it sounds reasonable, and it is wrong.

Look at the numbers first: the session's token count was around 220K (at 11:00 a single request's input had reached 243,354 and was still climbing). DeepSeek's context window is 1M tokens — this session had not even used a quarter of the window. A truly over-long context reports context length exceeded. What we actually got was Content Exists Risk — the two mean completely different things.

Content Exists Risk is a server-side content-moderation rejection: some segment inside the request body was flagged as risky, so the entire request returns 400 without ever entering generation. In other words: it was not that the soup was too much — a single grain of poison had fallen into it. Somewhere inside the 490,000 characters of history sat a flagged segment, every request carried it verbatim, and therefore every request was rejected.

That explains every symptom: why three retries all failed — retries do not change the request body; why the fallback to deepseek-v4-pro also failed — switching models does not change the request body, the poison was still there. Retry 100 times and it would be the same. Judging whether a session is alive cannot stop at length — you also have to check whether the history contains poisoned content.

Five: Postmortem 4 — 400 Is a Non-Retryable Error: We Retried the Wrong Thing

Most agent frameworks share one retry logic: on failure, back off and retry — treating it like rate limiting. That instinct is correct for 429 (rate limit) and 5xx (server fault) — wait for recovery and go again.

But 400 is another class: invalid_request_error, a client error — the request body itself is the problem. In the OpenAI-compatible SDK it is BadRequestError, a different error class from RateLimitError. One heals in three seconds; the other never heals no matter how long you wait. Three buckets are enough in practice: 429 rate limit → back off and retry; 5xx server fault → wait for recovery and retry; 400 client error → inspect the request body first, retry comes last. The retry logic split by error class looks like this:

# Pseudocode: choose retry strategy by error class
def on_api_error(e):
    try:
        raise e  # dispatch on the real exception
    except openai.RateLimitError:          # 429: backoff retry is meaningful
        retry_with_backoff(e)
    except openai.BadRequestError:         # 400: request-body problem, retry is pointless
        if "Content Exists Risk" in str(e):
            rebuild_session(preserve="summary")   # poisoned session must be rebuilt
        raise NonRetryable(e)
Enter fullscreen mode Exit fullscreen mode

Moderation rejections like Content Exists Risk have one more signature: what is flagged is content inside the history, not the current input. The same user, the same history — resending with different wording gets rejected the same way. There is exactly one remediation: rebuild the session, carry a summary into the new one, and let the flagged segment leave the request body.

Six: The Fix — Session-Length Guard Plus a Human Fallback

On the day of the incident we turned prevention into a physical mechanism. Issue number ISSUE-038, closed after validation:

Prevention mechanism (ISSUE-038, closed): session length guard. Four steps: Monitor — accumulate output size after every tool call, trigger near the danger line; Judge — threshold set at ~120K chars (normal 20K-30K, incident at 490K, wide margin); two branches — WeChat channel auto-rebuilds the session (compress history, carry summary into the new session, no human needed), other channels alert a human first (channel semantics differ, no one-size auto-rebuild); Validate — dry-run simulation then live run, ISSUE-038 closed. Teal conclusion bar: don't wait for 490K chars to rescue a session — the danger line rebuilds it for you

The mechanism has four steps. Monitor session length — after every tool call, accumulate the output; when approaching the danger line (we set the threshold at roughly 120,000 characters — normal sessions run 20K–30K, the incident was at 490K, leaving wide margin), trigger. Handle by channel: for channels like WeChat that host one long-lived session per day, rebuild automatically and carry a conclusion summary into the new session; for other channels (Feishu, etc.), alert first and let a human decide. Finally, close the issue only after both a dry-run simulation and a live run pass.

The boundary deserves stating: a session-length guard fits conversational agents with long single-session reuse — WeChat customer service, logistics operations, channels that live in one session a day. Systems that use short sessions or create a fresh session per task gain little; no need for this mechanism. The criterion is session reuse — only sessions that will run for hours and dozens of rounds in a row deserve a guard.

Why can we not rely on the agent's self-discipline? Because the "should I reset" decision lives outside the call chain: while an agent is busy working, it does not weigh its own history every round — and by the time it reaches 490K characters, it can no longer even send a normal request. The point of a physical mechanism is to make the decision for the agent while it cannot see the danger. This "physical mechanism > self-discipline" line is one I broke down in The Observability Trio in Production: Gate prevents beforehand, Audit checks afterwards, Correction owns the fix. This incident was a complete Correction loop: the error entered the ledger, became a threshold, became auto-rebuild, and closed with a live-run verification.

Human-machine collaboration is the last safety net: the manual resend at 13:43 took five minutes. A production system does not need to never fail — it needs failures to be visible and a human to be able to take over. The error message has to be readable (this 400's message was actually clear enough), and there must always be a manual exit. Machines carry normal traffic; humans close out the exceptions — that is more stable than letting a machine grind repeatedly on a bad request.

The afternoon the fix shipped, we thought the loop was closed — until the same 400 came back, in a brand-new, clean session.

Six-and-a-half: Postmortem 5 — Fixed, Then It Came Back: Reset ≠ Erase

The boss opened a new session on WeChat. The first six API calls were all normal — the new session itself was clean. Then, while running an inquiry task, the agent proactively called session_search once — its "dig through history" tool for retrieving past-session context. It scrolled to that poisoned morning session (084257) and pulled a 62,855-character window of its messages into the current request. The very next API call returned 400.

The problem was a blind spot in our first fix: when we reset the poisoned session, we only marked its end_reason (session ended). The message text was still in the database. session_search found it, and the poison was carried back into any new session — the poison had not disappeared; it was hiding in the retrieval index, waiting to be pulled back in.

This layer of vulnerability is far more insidious than session bloat. Bloat is a change in quantity — visible. Retrieval reinjection is a change in kind — invisible. The new session is clean, the model is normal, the request body looks fine — and one tool call that searches history re-injects the poison into the context.

The fix has two layers: on top of marking the session ended, physically delete its message rows; and as a recurrence guard, a watchdog that patrols every 30 minutes and idempotently deletes messages of any session marked poisoned. From now on, a poisoned reset equals messages deleted — session_search has nothing left to bring back.

Seven: Beyond the Incident — Context Management Is Becoming an Engineering Problem

This incident forced "context management" from what I used to think of as parameter tuning into a real engineering problem. How to govern session length, how to keep tool output accountable, how to split retry logic by error class, how to act automatically when a threshold trips — while agents were toys, these were config options nobody read in the docs. Once an agent handles real business for humans every day, they become infrastructure, right next to database connection pools and message queues.

Context Engineering should not only live in papers arguing about window size and clever compression algorithms. In production the questions actually asked are: how does context get out of control, how is it caught early once it is failing gradually, and how does a poisoned session rescue itself. The number 490,000 will age; this postmortem path will not: start from log evidence, rule out wrong attributions layer by layer, and physicalize each lesson into a mechanism. The next time we see a 400 like this, our first reaction is no longer "raise the window" or "switch the model" — it is to ask: which segment of history in this request body might have been flagged?

Key Takeaways

  1. Context is a liability, not an asset. Every tool output left in history enlarges the failure radius of the next request. Long-lived sessions need length governance as routinely as log rotation.
  2. Gradual failure needs a proactive detection mechanism, and fixes must go to the root. The first 400 at 11:03 was the moment to act — not when the user hit the wall. And marking a session ended is not enough: messages left in the database get pulled back into new sessions by retrieval tools. Reset must physically delete messages to be a real reset.
  3. Error class decides retry strategy. 400, 429 and 5xx map to three different actions; a content-moderation 400 flags historical content — the fix is rebuilding the session, not retrying and not switching models.

If only one sentence sticks: context is not free, loss of control is gradual, and poisoning cannot be retried away — before your agent goes to production, learn how to detox a session.


Further Reading (Practitioner's Series)

About the author: Wu Ji (无记) — AI / Agent / digital transformation practitioner.

Top comments (0)