The problem in one line: every long-context model advertises near-perfect needle-in-a-haystack recall, and it tells you nothing about where your agent breaks — because needle recall measures match-and-copy, the one operation whose accuracy doesn't degrade with length. If cheap 1M-token context has you tempted to stop managing your agent's window, run this first. It's 20 minutes and about a hundred calls, and it gives you the only number that matters: where your model, on your prompt shape, on your domain, falls off the cliff.
The four moves#
The whole eval is a grid. You insert a fact you know into filler of varying length, at varying depth, then ask a question that can't be answered by keyword-matching, and score how accuracy changes across the grid.
1. Filler from your own domain — never lorem ipsum
Context rot is content-dependent. A model padded with plausible, on-topic distractors degrades far more than one padded with obvious nonsense — and your production context is nothing but plausible distractors. Use real (redacted) material from what you ship: support tickets for a support agent, repo files for a code agent.
# haystack.py — build padding from YOUR domain, not lorem ipsum
from pathlib import Path
def load_filler(target_tokens: int, est_tok_per_char=0.25) -> str:
# concatenate real domain text until we hit ~target_tokens
chunks, total = [], 0
for f in Path("sample_domain_text/").glob("*.txt"):
t = f.read_text()
chunks.append(t); total += len(t) * est_tok_per_char
if total >= target_tokens:
break
return "\n\n".join(chunks)
2. Insert the needle at several depths and lengths
Bracket your real usage. A practical grid is four lengths — 4k, 16k, 64k, and your true max (say 200k) — crossed with three depths — 10%, 50%, 90% through the filler. Twelve cells.
# place.py — drop a known fact at a fractional depth in the filler
def plant(filler: str, needle: str, depth: float) -> str:
cut = int(len(filler) * depth)
return filler[:cut] + f"\n\n{needle}\n\n" + filler[cut:]
# The needle is a fact you invent, so there's zero chance it's in the filler:
NEEDLE = "Marisol always leaves the spare key under the third planter on the back porch."
3. Ask a non-lexical question (this is the whole trick)
A verbatim question lets the model string-match its way to the answer without understanding anything — which is why NIAH flatters every model to ~99%. Ask a NoLiMa-style question that shares no keywords with the needle, so the model has to associate by meaning. Then add one multi-hop question that forces it to combine the needle with a second planted fact — the hardest case, where degradation is largest.
# Lexical (DON'T rely on this — it cheats via shared words):
# "Where does Marisol leave the spare key?"
#
# Non-lexical (DO use this — no shared keywords with the needle):
QUESTION = "If someone was locked out of the house, where should they look to get in?"
EXPECTED = "third planter" # substring match is enough to score
4. Score across the grid and read the cliff
Run each cell several times with different needle facts to average out noise (~8 runs × 12 cells ≈ 100 calls), and record exact-substring accuracy per cell.
# run.py — sketch of the grid runner
import itertools, statistics
LENGTHS = [4_000, 16_000, 64_000, 200_000]
DEPTHS = [0.10, 0.50, 0.90]
def score_cell(client, model, length, depth, trials=8):
hits = 0
for _ in range(trials):
filler = load_filler(length)
ctx = plant(filler, NEEDLE, depth)
ans = ask(client, model, ctx, QUESTION) # your call wrapper
hits += (EXPECTED.lower() in ans.lower())
return hits / trials
grid = {(L, d): score_cell(client, MODEL, L, d)
for L, d in itertools.product(LENGTHS, DEPTHS)}
for L in LENGTHS:
row = " ".join(f"{grid[(L,d)]:.0%}" for d in DEPTHS)
print(f"{L:>7}: {row}")
The output is a heat map of accuracy by length (rows) and depth (columns). You're reading for two things:
- Where does a row collapse? If accuracy holds at 4k/16k and drops at 64k, that length is your cliff.
- Does the middle column sag? A dip specifically at 50% depth is the classic "lost in the middle" — the model attends to the start and end of a long context and loses the middle, and it gets worse with length.
Reading your result#
- 95%+ across the whole grid: this model, on this content, tolerates a big window. Cheap 1M context is genuinely safe here — hand it the tokens.
- A clear cliff (e.g. fine to 48k, collapses by 64k): treat ~40k as your effective working window, not the advertised 200k. That gap is exactly the silent-wrong-answer zone.
- Middle-depth sag at length: put your most important context at the edges (system prompt / recent turns), never buried in the middle of a long dump.
What to do with the number#
Set your context editing or compaction trigger just below the cliff — keep the live window under the length where accuracy holds, and offload the rest to retrieval or a memory tool. Then re-run this whenever you change models or materially reshape your prompt; the cliff moves with both. If you want the benchmark theory behind why the non-lexical question matters, RULER vs needle-in-a-haystack is the deeper cut, and how to evaluate a RAG pipeline shares most of this scaffolding for the retrieval side.
Publish the result to your team in one sentence: "Our effective window is 40k, not 200k." That single number kills a whole class of bugs — because the model will never tell you it's guessing, and the vendor's spec sheet certainly won't.



