One Empty 200 OK Poisoned 5 of My Agent's 10 Steps
One Empty 200 OK Poisoned 5 of My Agent’s 10 Steps
One tool call came back HTTP 200 with an empty body. My agent shrugged, wrote down a placeholder price, and moved on. Nothing crashed. No exception, no red log line.
Ten steps later, the answer it handed back to the user was built on that price. So were four other steps in between. One empty 200 quietly poisoned 5 of 10 steps before anyone looked.
This is not the story about whether to let a bad fetch in. I wrote that one already. This is what happens after it’s in.
TL;DR
- A tool returned
HTTP 200with no usable body. The agent recorded a placeholder “fact” anyway. That fact then got reused by later steps that never re-fetched anything. - On a synthetic 10-step plan, a naive agent that trusts any fact already in context ended up with 5 of 10 steps poisoned — including the final answer to the user. Blast radius: 5.
- The fix is not “validate the fetch harder.” It’s a provenance quarantine: every fact carries
{verdict, source_step, confirmed}, and a step may only build on a fact that was confirmed by a real OK fetch in its own chain — never one it merely inherited. - With that gate, the same plan’s blast radius dropped to 0. The poison was contained at the door (
s1), and the clean branch still survived. - The code below is stdlib-only, deterministic, zero network. Copy it, run it, watch the radius go to 0.
MD5of the full stdout is in the script so you can check you got the same bytes I did.
This is not the input gate (and why that matters)
Five days ago I wrote about a different problem: your agent trusts a 200 OK, and I logged how often the page was garbage. That piece was about the door — a 40-line sanity_check that tags one fresh fetch OK / BLOCKED / EMPTY_SHELL / TRUNCATED at the moment it arrives. In that run, 1 of 6 blobs were usable content; the rest were challenge walls and empty shells. The gate’s whole job is to stop the bad blob from being read as content.
Here’s the uncomfortable part. Suppose the gate misses one. Or suppose you have no gate and the agent just records the placeholder. The bad fact is now past the door. It’s a normal entry in context, indistinguishable from a real one. And every later step that wants a price will happily reuse it.
That’s the gap. The input validator answers “should I let this in?” It says nothing about “step 8 is about to reuse a fact that step 1 never actually got.” Those are two different bugs, and fixing the first one does not fix the second.
So the contrarian claim, plainly: context poisoning isn’t really about the input. It’s about the five steps after it. You can have a perfect input gate and still ship a poisoned final answer, because the damage is done by reuse without re-confirmation, not by the original bad fetch.
How the poison actually travels
Picture a 10-step plan for one product. Some steps fetch fresh facts from a tool. Some steps reuse a fact already sitting in context. That reuse is the point of having a context at all — you don’t re-fetch the price every time you mention it.
Here’s the plan. The arrows are reuse edges: a step pointing at an earlier step is building on that earlier step’s fact.
s1 FETCH price -> 200 OK, empty body (POISON enters here)
s2 FETCH currency -> OK
s3 REUSE s1 price quote
s4 FETCH stock -> OK
s5 REUSE s1, s4 total = price * stock
s6 REUSE s3 restated price (2nd-order)
s7 FETCH rating -> OK
s8 REUSE s5 rounded total (3rd-order)
s9 REUSE s2 currency label (clean)
s10 REUSE s8, s9 FINAL ANSWER to user
Follow the poison from s1:
s1 --> s3 --> s6
s1 --> s5 --> s8 --> s10
s1 is the only bad fetch. But s3, s5, s6, s8, and s10 all inherit from it, directly or transitively. Nobody along that path re-asks “wait, was that price ever really fetched?” They just see a fact in context and trust it. The placeholder rides all the way to s10 — the answer the user reads.
Meanwhile s2 (currency) and s9 (which reuses s2) never touched s1. They’re clean. That detail matters later, because a fix that “just blocks everything downstream of any fetch” would be useless — it’d kill the clean branch too.
I measured the blast radius
I built the smallest thing that proves the mechanism: a deterministic, stdlib-only script. No network, no randomness, no clock. The “tool results” are an inline fixture so the output is the same on your machine as mine. Here’s the real stdout, copy-pasted, not retyped:
=== one EMPTY_SHELL fetch, two agents, same 10-step plan ===
plan steps: 10 | poison enters at: s1 (HTTP 200, empty body)
--- NAIVE AGENT (reuses any fact already in context) ---
poisoned steps : s1 s3 s5 s6 s8 s10
blast radius : 5 downstream steps built on a never-confirmed fact
reached final answer (s10)? YES
--- PROVENANCE-QUARANTINE GATE (confirm-in-chain) ---
quarantined at door : s1
reuse steps blocked : s3 s5 s6 s8 s10
blast radius : 0 downstream steps built on poison
reached final answer (s10)? no
--- DELTA ---
naive blast radius : 5 / 10 steps
quarantine blast radius: 0 / 10 steps
poison contained at the door: YES
MD5(stdout-above)=ea17cc5670296fded6cbca0bf13fc071
Read the naive block. Six steps carry the poison — s1 s3 s5 s6 s8 s10. One of those (s1) is the original bad fetch; the other five are downstream reuse. That’s the blast radius: 5. And the last poisoned step is s10, the final answer. The user got a number that was never fetched.
Now the gate block. Same plan, same poison, blast radius 0. The bad fetch is quarantined at s1, and every reuse step that depended on it (s3 s5 s6 s8 s10) is blocked from building on it. s10 is no longer poisoned, because it was never allowed to inherit the placeholder.
One honest caveat up front, because I’d want it if I were reading: this plan is a fixture, not a capture of one live LangChain run. I hand-built the reuse edges to be a clean, minimal example of inheritance. The mechanism — reuse without re-confirmation spreads a bad fact — is real and I’ve watched it happen; the specific 10-step shape is a model of it, not a screenshot.
Why “an empty 200” isn’t a strawman
You might think a 200 OK with an empty body is a rare edge case I invented to make a point. It isn’t. Across my published Apify actors I’ve logged 2,190 production runs lifetime — real jobs against real sites. The Trustpilot review scraper alone is 962 of them. In that work, a fetch coming back 200 with a body that’s empty, a challenge wall, or a half-loaded shell is not exotic — it’s a regular Tuesday. In the input-gate run I linked above, only 1 of 6 sampled blobs was actually usable content. The rest were 200s lying about having a page.
So the entry point is mundane. The danger is what the plan does with it.
The fix: provenance quarantine
The naive agent has one rule: if a fact is in context, you may use it. That rule is the bug. It treats “I have a value” and “I have a confirmed value” as the same thing.
The gate adds three fields to every fact: {verdict, source_step, confirmed}. And one rule replaces the naive one:
A step may build on a fact only if every fact it depends on is
confirmed— confirmed meaning it came from a real OK fetch in this same chain, not inherited from somewhere upstream that you never verified.
A poisoned fetch (verdict = EMPTY_SHELL) is marked confirmed = False at the door. Anything that tries to reuse it fails the check and gets blocked. The poison can’t propagate, because propagation requires inheriting a fact, and you can’t inherit an unconfirmed one.
Here’s the core of it — the confirmed map and the one branch that does the work:
def run_quarantine(plan):
by_id = {s[0]: s for s in plan}
confirmed = {} # step_id -> bool (fact usable downstream?)
quarantined = [] # fetches that were quarantined
blocked = [] # reuse steps blocked because a dep wasn't confirmed
for sid, kind, verdict, deps, label in plan:
if kind == "fetch":
ok = verdict not in POISON_VERDICTS
confirmed[sid] = ok
if not ok:
quarantined.append(sid)
else: # reuse: only allowed if EVERY dep is confirmed
if all(confirmed.get(d, False) for d in deps):
confirmed[sid] = True
else:
confirmed[sid] = False
blocked.append(sid)
...
That all(confirmed.get(d, False) for d in deps) line is the entire defense. A reuse step gets to set itself confirmed = True only when every dependency is already confirmed. One unconfirmed ancestor anywhere in the chain, and the step is blocked instead of quietly inheriting garbage.
Now the part I care about most. Look at the output again: the gate blocked s3 s5 s6 s8 s10 — the poisoned chain — and left s2 and s9 alone. The clean currency branch survived. The gate isn’t a sledgehammer that refuses to reuse anything; it surgically isolates the chain that traces back to a bad fetch. If you’d built a cruder “re-fetch everything” fix, you’d have paid to re-pull the currency you already had correctly. This pays nothing for the clean branch and blocks exactly the rotten one.
There’s a design choice hiding here worth saying out loud. When the gate blocks s10, the agent does not produce a final answer. “No answer” sounds like a failure. It’s the opposite. A poisoned answer is a wrong answer delivered with full confidence; a blocked answer is a known gap you can route around — re-fetch s1, escalate to a browser, or tell the user you couldn’t price the item. I will take a loud “I don’t know” over a confident wrong number every single time.
Why this is worth the trouble
This isn’t only my pet failure mode. OWASP put it in writing. Their Top 10 for Agentic Applications, 2026 lists ASI08, Cascading Failures, as a top agentic risk — the pattern where one bad input gets repeated and amplified across an agent’s steps instead of being caught once. That’s blast radius, named and ranked by a security body, not just me with a fixture.
And the cost of ignoring it shows up in the budget line. In a June 25, 2025 press release, Gartner predicted over 40% of agentic AI projects will be canceled by the end of 2027, citing escalating costs, unclear value, and inadequate risk controls. Anushree Verma, Senior Director Analyst at Gartner, called most current agentic projects “early stage experiments… mostly driven by hype.” A system that confidently ships answers built on facts it never fetched is exactly the kind of thing that turns into “inadequate risk controls” on a postmortem slide.
What I’d still want before trusting this in prod
The fixture proves the mechanism. It doesn’t pretend to be production-grade. A few things I’d want before shipping a real version:
- A real provenance graph. My fixture has clean
depends_onedges. A live agent’s reuse is messier — facts get summarized, merged, paraphrased into new tokens. Tracking provenance through an LLM rewriting a value in prose is genuinely hard, and I don’t have a clean answer for it yet. - A cost. Carrying
{verdict, source_step, confirmed}on every fact, and checking the chain on every reuse, isn’t free in tokens or bookkeeping. On a 10-step plan it’s noise. On a 200-step agent loop, measure it. - A re-confirm path. Blocking
s10is correct, but a good system shouldn’t just stop. It should try to re-confirms1— re-fetch, fall back to another source — and only then give up. The gate I showed is the brake, not the whole car.
The open question
So here’s what I genuinely don’t have settled, and I’d like to hear how you handle it: in your multi-step agents, do you actually track the provenance of a fact through context — and do you block reuse of an unconfirmed one, or do you just log it and hope? Logging a poisoned fact you still let s10 use isn’t a fix. It’s a paper trail for the postmortem.
If you’ve built real provenance tracking that survives an LLM paraphrasing the value into new tokens, I especially want to hear it — that’s the part I haven’t cracked.
I write up one reproducible agent-reliability failure like this at a time, with the numbers from real runs behind it. Follow for the next one — and tell me the worst “the agent was confidently wrong and I couldn’t tell why” bug you’ve hit. I read every comment.
The full script
Stdlib only, deterministic, no network. Run it with python3 -I poison_propagation.py. The MD5 it prints lets you confirm you got byte-identical output.
#!/usr/bin/env python3
# poison_propagation.py
# Deterministic, stdlib-only, NO network. Fixture is inline.
# Demonstrates: one garbage-but-200 tool result, ACCEPTED into the agent's
# context, PROPAGATES downstream because later steps inherit the "fact"
# without re-confirming it. We measure the blast radius (how many downstream
# steps are built on a fact that was never confirmed) two ways:
# (1) naive agent: trusts any fact already in context -> wide blast radius
# (2) provenance-quarantine gate: every fact carries {verdict, source_step,
# confirmed}; a step may only build on a fact CONFIRMED in a real fetch,
# not one inherited/rewritten -> blast radius 0.
#
# This is NOT the input validator from article #19 (which judges ONE fresh
# fetch at the moment of fetch). Here the bad fact is already past the door;
# the question is how far it travels through the plan.
#
# Run: python3 -I poison_propagation.py
# stdout is fully deterministic (no time, no randomness, no dict-order deps).
import hashlib
# ---------------------------------------------------------------------------
# FIXTURE: a 10-step agent plan. Each step either FETCHES a fresh fact from a
# tool, or REUSES a fact already in context (carried from an earlier step).
# Exactly one fetched tool result is a "200 OK but garbage" (empty shell): the
# tool returned HTTP 200 with no usable body, but the agent recorded a
# placeholder "fact" anyway. That poisoned fact then gets reused downstream.
#
# verdict comes from the per-fetch classifier (article #19 layer):
# "OK" = real content
# "EMPTY_SHELL" = HTTP 200, no usable body (the poison)
#
# Steps:
# s1 FETCH price -> EMPTY_SHELL (poison enters here)
# s2 FETCH currency -> OK
# s3 REUSE s1.price (inherits poison)
# s4 FETCH stock -> OK
# s5 REUSE s1.price + s4.stock (inherits poison)
# s6 REUSE s3 (which reused s1) (inherits poison, 2nd-order)
# s7 FETCH rating -> OK
# s8 REUSE s5.total (inherits poison, 3rd-order)
# s9 REUSE s2.currency (clean: never touched s1)
# s10 REUSE s8 + s9 (inherits poison)
# ---------------------------------------------------------------------------
PLAN = [
# step_id, kind, verdict_if_fetch, depends_on(list of step_ids), label
("s1", "fetch", "EMPTY_SHELL", [], "GET /product/9981 price"),
("s2", "fetch", "OK", [], "GET /product/9981 currency"),
("s3", "reuse", None, ["s1"], "quote price from s1"),
("s4", "fetch", "OK", [], "GET /product/9981 stock"),
("s5", "reuse", None, ["s1", "s4"], "compute total = price*stock"),
("s6", "reuse", None, ["s3"], "restate price for summary"),
("s7", "fetch", "OK", [], "GET /product/9981 rating"),
("s8", "reuse", None, ["s5"], "round total for invoice"),
("s9", "reuse", None, ["s2"], "format currency label"),
("s10", "reuse", None, ["s8", "s9"], "final answer to user"),
]
POISON_VERDICTS = {"EMPTY_SHELL"} # 200-OK-but-no-usable-body
def origin_is_poisoned(step_id, plan_by_id, memo):
"""A step is poisoned if it fetched a poison verdict, OR if ANY fact it
reuses traces back (transitively) to a poisoned fetch."""
if step_id in memo:
return memo[step_id]
sid, kind, verdict, deps, _label = plan_by_id[step_id]
if kind == "fetch":
result = verdict in POISON_VERDICTS
else: # reuse: poisoned if any ancestor is poisoned
result = any(origin_is_poisoned(d, plan_by_id, memo) for d in deps)
memo[step_id] = result
return result
def run_naive(plan):
"""Naive agent: once a fact is in context, later steps reuse it freely.
No step re-confirms inherited facts. Poison spreads along reuse edges."""
by_id = {s[0]: s for s in plan}
memo = {}
poisoned_steps = []
for sid, kind, verdict, deps, label in plan:
if origin_is_poisoned(sid, by_id, memo):
poisoned_steps.append(sid)
return poisoned_steps
def run_quarantine(plan):
"""Provenance-quarantine gate: every fact carries
{verdict, source_step, confirmed}. A step may build on a fact ONLY if that
fact is CONFIRMED by a real OK fetch in the SAME provenance chain. A
poisoned fetch is quarantined at the door; nothing may inherit from it.
Returns (poisoned_reached, quarantined_at)."""
by_id = {s[0]: s for s in plan}
confirmed = {} # step_id -> bool (fact usable downstream?)
quarantined = [] # step_ids whose fetch was quarantined
blocked = [] # reuse steps blocked because a dep was not confirmed
for sid, kind, verdict, deps, label in plan:
if kind == "fetch":
ok = verdict not in POISON_VERDICTS
confirmed[sid] = ok
if not ok:
quarantined.append(sid)
else: # reuse: only allowed if EVERY dep is confirmed
if all(confirmed.get(d, False) for d in deps):
confirmed[sid] = True
else:
confirmed[sid] = False
blocked.append(sid)
# downstream steps that ended up built on poison despite the gate:
poisoned_reached = [s for s in confirmed if confirmed[s] is False
and by_id[s][1] == "reuse" and s not in blocked]
return poisoned_reached, quarantined, blocked
def main():
total_steps = len(PLAN)
naive_poisoned = run_naive(PLAN)
q_reached, q_quarantined, q_blocked = run_quarantine(PLAN)
# The blast radius = downstream steps (reuse steps) that ended up carrying
# the unconfirmed fact. The poison ENTERS at the fetch; the radius counts
# how far it travels into the plan.
naive_downstream = [s for s in naive_poisoned
if dict((x[0], x[1]) for x in PLAN)[s] == "reuse"]
lines = []
lines.append("=== one EMPTY_SHELL fetch, two agents, same 10-step plan ===")
lines.append("plan steps: %d | poison enters at: s1 (HTTP 200, empty body)" % total_steps)
lines.append("")
lines.append("--- NAIVE AGENT (reuses any fact already in context) ---")
lines.append("poisoned steps : %s" % " ".join(naive_poisoned))
lines.append("blast radius : %d downstream steps built on a never-confirmed fact"
% len(naive_downstream))
lines.append("reached final answer (s10)? %s"
% ("YES" if "s10" in naive_poisoned else "no"))
lines.append("")
lines.append("--- PROVENANCE-QUARANTINE GATE (confirm-in-chain) ---")
lines.append("quarantined at door : %s" % " ".join(q_quarantined))
lines.append("reuse steps blocked : %s" % " ".join(q_blocked))
lines.append("blast radius : %d downstream steps built on poison"
% len(q_reached))
lines.append("reached final answer (s10)? %s"
% ("YES" if "s10" in q_reached else "no"))
lines.append("")
lines.append("--- DELTA ---")
lines.append("naive blast radius : %d / %d steps" % (len(naive_downstream), total_steps))
lines.append("quarantine blast radius: %d / %d steps" % (len(q_reached), total_steps))
lines.append("poison contained at the door: %s"
% ("YES" if len(q_reached) == 0 else "NO"))
out = "\n".join(lines) + "\n"
# asserts: the mechanism must hold, deterministically.
assert "s1" in naive_poisoned, "poison must originate at s1"
assert "s10" in naive_poisoned, "naive: poison must reach the final answer"
assert len(naive_downstream) == 5, "naive blast radius must be 5 reuse steps"
assert q_quarantined == ["s1"], "gate must quarantine exactly s1"
assert len(q_reached) == 0, "gate: blast radius must be 0"
assert "s10" not in q_reached, "gate: final answer must NOT be poisoned"
# clean branch (s2/s9) must survive in BOTH worlds:
assert "s2" not in naive_poisoned and "s9" not in naive_poisoned, \
"clean currency branch must never be poisoned"
print(out, end="")
md5 = hashlib.md5(out.encode()).hexdigest()
print("MD5(stdout-above)=%s" % md5)
if __name__ == "__main__":
main()
Written by Aleksey Spinov. I run scrapers in production (2,190 runs across 32 published actors, the Trustpilot one at 962) and write up the agent-reliability failures the tutorials skip. The script, the fixture, and every number above were produced and verified by me on my own machine; the stdout shown is the real run, not a mock-up.
AI disclosure: drafted with AI assistance (Claude). The demo numbers (blast radius 5/10 -> 0/10, the poisoned step list, the MD5) are the verbatim, deterministic stdout of the stdlib-only Python script below (no network, no randomness, no clock). The 2,190 / 962 run counts are from my own Apify production history. External claims (OWASP ASI08, Gartner) are linked to primary sources. The 10-step plan is a synthetic fixture, labeled as such throughout. Reviewed and edited by a human before publishing.
More production scraping tips: t.me/scraping_ai