Programmatic context management¶
Scope: the design in which an agent's history is not a string in the prompt but a queryable environment the model manipulates by writing and running code, with the working view chosen at query time instead of at ingestion time. This page covers Scroll (arXiv 2608.21690, "Context as an Environment"), its Event Log plus persistent Python kernel substrate, the eviction algorithm that bounds the view without destroying history, the tiered eviction index that keeps evicted spans navigable, and an audit of the reported results. The fixed two-tool version of the same reversibility argument is agentic context management; deciding when to compress is compaction trigger policy; training a policy across compaction boundaries is compaction-aware RL; the concept-level treatment is agent context and memory, and the file-tree flavour of externalised memory is filesystem agent memory.
Evidence status, verified 2026-08-26. Method and result claims come from arXiv 2608.21690v1 (Lin, Ang, Zhu, Ding, Zhou; Alibaba Group and Columbia University), submitted 21 August 2026, fetched as PDF and read in full. The paper names an implementation at
github.com/niceIrene/QwenPawbranchscroll-research; that branch was cloned and read at commit6ad27f81d4920bc0c17fc36c029c96237d58e90d("docs: add arXiv citation", 25 August 2026), and the constants quoted below (_TIER_CAP = 10,_PROTECTED_RECENT_TOOL_RESULTS = 5,_PRE_TRIM_MIN_CHARS = 200,compact_threshold_ratiodefault0.8) were read out of that tree. No agent was run, no benchmark was reproduced, and no accuracy number on this page was measured here. What was computed here: the NumPy block below re-derives the eviction index roll-up from the shipped_carry_run, the context-budget arithmetic of three policies, and the LOCA headline expressed over the sample denominator LOCA-bench itself states (arXiv 2602.07962 Section 3.1, fetched and read here). Its assertions pass. Section Where the paper and the code diverge records five discrepancies found by that check. Benchmark split names are written with hyphens here (LongMemEval-S, BEAM-10M, LOCA-256K); the paper subscripts them, and quotations taken from the PDF text layer show those subscripts flattened.
flowchart LR
subgraph VIEW["Working view, bounded by rho times C"]
PREFIX["Fixed prefix: system prompt<br/>and tool definitions"]
IDX["Eviction index: tiered headlines,<br/>each anchored to a seq span"]
DIGEST["Namespace digest: name, type and<br/>shape of every resident variable"]
TAIL["Protected tail: active turn,<br/>recent turns, newest tool results"]
end
VIEW --> MODEL["Model call"]
MODEL -->|"exec a Python cell"| KERNEL
subgraph ENV["Session Environment, outside the prompt"]
KERNEL["Persistent Python kernel:<br/>typed namespace, lazy handles"]
LOG[("Append-only Event Log<br/>SQLite plus FTS5, stable seq")]
STORE[("Payload storage:<br/>JSON and artifact files")]
end
KERNEL -->|"ms.search, ms.expand, ms.sql_query"| LOG
LOG -->|"ToolResultRef, ArtifactRef"| STORE
KERNEL -->|"only print output crosses"| VIEW
VIEW -.->|"over budget: fold payloads,<br/>then evict oldest span"| LOG
What it is¶
Scroll is a context manager for long-horizon agents. It refuses the usual framing in which the prompt is the history and management means editing that string. Instead the session state after t steps is written as S_t = (L_t, P_t, V_t): an append-only Event Log L_t of typed events with immutable monotonically increasing seq addresses, a payload store P_t holding raw tool outputs behind lazy handles, and V_t, the namespace of a sandboxed Python kernel that survives across model calls. Each model call consumes a working view c_t of at most C tokens. The context-management problem is then stated as choosing the map S_t -> c_{t+1} at every step, and the paper's claim is about when that choice is made: compression fixes it as the trajectory grows (c_{t+1} = phi(c_t, e_t)), external memory fixes it at ingestion (V_t = psi(V_{t-1}, e_t)), and Scroll defers it to query time as a program pi_t the model writes.
The action space is therefore not a fixed tool menu. It is exec plus print, in the CodeAct style: the model emits a Python cell, the cell runs in the persistent kernel, and only what the cell explicitly prints enters the next model context. Everything else (retrieved rows, tool results, intermediate DataFrames) stays bound to kernel variables. The paper factorises the model-facing surface, a capability object named ms, into four operations (Table 1 of the paper).
| Operation | Interface | What it does |
|---|---|---|
| Locate | ms.search(query, k, ...) |
BM25-ranked full-text search over the Event Log with scope, kind and time filters; each hit carries its stable seq |
| Materialize | ms.expand(seq), ms.expand(seq_lo, seq_hi) |
Recover exact turns or spans, and load externalised payloads behind lazy handles |
| Compute | ordinary Python, plus permitted DB, filesystem and tool interfaces | Filter, join, aggregate, resolve updates, build derived state |
| Expose | print(value) |
Return the selected projection as a bounded observation; everything else stays in the kernel |
Search defaults to BM25 rather than embeddings, which the paper justifies as deterministic and free of index-time model calls. Two tools implement the exec action in the released harness: repl_exec for a general cell in the persistent kernel with environment tools exposed as Python functions, and recall_history_python for a cell with ms pre-bound.
Eviction is the second half. Algorithm 1 fires when the working view exceeds a budget rho * C. It persists live turns, protects the active turn, the recent tail and the newest tool results, folds completed tool payloads down to seq pointers, and only then removes whole spans if the view is still over budget. Its stated invariant is that everything removed stays addressable: evicted events remain verbatim in the Event Log under their original seq. To make an evicted span findable without the agent having to recall its wording, the model writes a short headline (task, verified state, next action, status) with each response; headlines of evicted spans enter a tiered index that rolls up, so recent history keeps fine per-turn anchors while distant history collapses to coarse seq ranges. The shipped eviction_index.py sets the tier cap at 10 and, when a tier fills, keeps the newest block while collapsing the other nine into one block a tier up, cascading like a carry.
How this differs from the two-tool framing¶
This KB already covers offload-and-retrieve as a pair of agent-invoked tools in agentic context management (manage_context writes raw messages to a workspace under an id, query_memory(id, query) sends a querier LLM at them). The reversibility argument is shared. Six things are not.
| Axis | Two-tool design (arXiv 2607.23809) | Scroll (arXiv 2608.21690) |
|---|---|---|
| Action space | Two fixed tool signatures | Arbitrary Python in a persistent kernel; the retrieval policy is a program |
| Addressing | Opaque workspace id per compaction boundary | Immutable per-event seq over an append-only log spanning sessions |
| Retrieval | A second LLM reads the archive and returns prose | Deterministic BM25 plus SQL plus in-kernel computation; no reader model |
| State between calls | None; every retrieval re-enters the prompt as text | Typed variables persist in the kernel and never enter the prompt |
| Awareness of what left | None; the agent must think to ask | Tiered eviction index sits in the view as a map of what is gone |
| How it is obtained | Post-training on a dual-constraint teacher | Prompt and harness only; no weights are trained |
The last row matters most for a platform team: Scroll is a harness change, not a model change. The paper's forward-looking section proposes distilling the policy into smaller models, but nothing in the reported results depends on that.
Why use it¶
- The lossless log is worth far more than the programmatic interface. Ablating on BEAM at 10M tokens (Figure 3), summarising at ingestion and discarding the originals drops the overall judge score to 19.9 against Scroll's 73.1. Removing the persistent kernel while keeping the log costs 7.3 points; removing the eviction index costs 1.8. The ordering is the design guidance: keep the raw record first, add code execution second, add navigation aids third.
- Query-time selection wins exactly where write-time compression must guess. In the per-category breakdown (Table 6), Scroll leads on knowledge update (92.5 against 45.0 to 75.0 for the three baselines with public breakdowns) and contradiction resolution (88.1 against 32.5 to 58.8). Both need both sides of a value timeline in order, which an ingestion pipeline that stores current state discards.
- The window stops being the horizon. On LOCA at a 256K environment description, a summarisation ReAct agent falls 21.4 points from its 128K score and a retrieval ReAct agent falls 21.3; the two agents that bind results to kernel variables fall 4.0 (CodeAct) and 2.6 (Scroll). Same backbone, same toolset, only the context strategy changes (Table 3).
- Ingestion is free of model calls. Records go into SQLite as-is. There is no extraction, summarisation or knowledge-graph build pass over the corpus, so cost scales with what the agent chooses to read, not with corpus size. Reported median input on BEAM at 10M is 105K tokens, about 1% of the corpus.
- It survives a backbone swap, for retrieval. With harness, tools, prompts and rules held fixed (Table 4), six backbones score between 88.8 and 94.8 on LongMemEval-S. That property does not extend to acting: see the next section.
When to use it (and when not)¶
Use it when the session outlives the window and later steps need exact historical evidence: audit trails, incident timelines where ordering decides the answer, multi-day agent sessions, tool outputs that must be compared across a long gap, or any workload where "the summary did not keep it" is a correctness bug rather than a quality regression. It also fits when the raw history is already durable somewhere (a log, a database, an artifact store) and the only missing piece is an addressing scheme the model can navigate.
Do not use it when:
- The backbone is weak at multi-step program synthesis. The generality across backbones collapses on the acting benchmark. At LOCA 256K the spread across the six models is 22.7 to 86.7 (Table 4), a 64-point gap, while on LongMemEval-S it is six points. The paper attributes the failures to execution errors and premature termination, not protocol violations. A model that cannot reliably write a three-step retrieval program will not benefit.
- The graded artifact is itself a condensed view. Scroll trails the strongest baselines on summarisation (70.5 against 91.9), preference following (89.1 against 97.5) and temporal reasoning (47.5 against 58.8). Ingestion-heavy pipelines have already built the digest when the question arrives; Scroll rebuilds it per query and its residual failures there are errors of query formulation.
- The evidence is scattered so widely that a query cannot enumerate it. Multi-session reasoning is the weakest category for every system in Table 6 (9.6 to 26.1); Scroll's 21.9 comes from over-precise filters that undercount the evidence set.
- Prefix caching is doing the heavy lifting and the same span is read repeatedly. See the arithmetic below: under a working prefix KV cache, re-reading an evicted span more than once costs more prefill than never having evicted it.
- Untrusted content flows into the kernel.
execover model-authored code that has just read attacker-controlled tool output is a code-execution path. The shipped kernel is fail-closed (the Event Log is read-only from the kernel, and database, filesystem, network and tool access are limited to explicitly declared capabilities), which is the minimum bar. Treat the kernel as an untrusted execution boundary and read agent sandboxing and isolation before deploying it.
Architecture¶
The three moving parts are the log (durability and addressing), the kernel (state that does not pay prompt tokens), and the view (what the model actually sees). The eviction procedure is the only place they interact, and it is where the design either holds its invariant or quietly becomes a compactor.
Order of operations in ScrollContextManager.compress on the scroll-research branch, matching Algorithm 1's "increasing order of recovery cost":
- Persist live turns to the Event Log.
- Count tokens; return if at or below
trigger_ratio * context_size(default0.8). - Batch-fold completed tool results into
[scroll folded]recovery pointers, skipping the active turn, the five newest results, and outputs of 200 characters or fewer. Return if this alone got under the trigger. - Split pairing-safely into an evicted middle and a protected tail, where the tail is
min(40000, max(reserve_ratio * C, min(10000, 0.1 * C)))tokens. - Update the continuation state, fold the evicted middle into the index as a new tier-0 block, rebuild the context.
- If pressure remains, fold live tool results one at a time until the target is met.
Core arithmetic (runnable): index growth, three policies, and where retrieval loses¶
The block below is self-contained NumPy. It reimplements the shipped EvictionIndex carry, simulates a 400-step rollout under three context policies over one synthetic trace, and converts the LOCA headline into sample counts over the denominator LOCA-bench states. Run: python3 programmatic_context_budget.py.
"""Context-budget arithmetic for programmatic context management.
Validates five things against arXiv 2608.21690 (Scroll) and the shipped
QwenPaw scroll-research branch (commit 6ad27f81):
1. the tiered eviction index roll-up (eviction_index.py `_carry_run`, k=10):
block count grows logarithmically and seq coverage stays gap-free;
2. a 400-step rollout under three context policies over ONE synthetic trace,
with per-policy recall and billed-prompt-token cost;
3. the irreversibility property: a query answerable only from a discarded
span is permanently unanswerable under summarise-and-discard;
4. two cost break-evens, including the case where offload-and-retrieve is
strictly worse than just keeping the tokens;
5. the LOCA headline in samples, over the denominator LOCA-bench states
(arXiv 2602.07962 Section 3.1), with the divisibility scan kept only as
corroboration and its ambiguity made explicit.
numpy only. Deterministic (seeded). Run: python3 this_file.py
"""
import numpy as np
TIER_CAP = 10 # eviction_index.py:31 `_TIER_CAP = 10`
# ---------------------------------------------------------------- 1. roll-up
def rollup(n_evictions: int, k: int = TIER_CAP) -> list[list[tuple[int, int]]]:
"""Reimplementation of `EvictionIndex.add_eviction` + `_carry_run`.
Each eviction drops one block on tier 0. A full tier keeps its newest
block and folds the other k-1 into one block a tier up, cascading.
"""
tiers: list[list[tuple[int, int]]] = [[]]
def carry(t: int) -> None:
if len(tiers[t]) < k:
return
count = len(tiers[t]) - 1 # keep the newest block only
older, kept = tiers[t][:count], tiers[t][count:]
tiers[t] = kept
if t + 1 == len(tiers):
tiers.append([])
tiers[t + 1].append((older[0][0], older[-1][1]))
carry(t + 1)
for i in range(n_evictions):
tiers[0].append((i, i))
carry(0)
return tiers
def index_blocks(tiers) -> int:
return sum(len(t) for t in tiers)
def covered_spans(tiers) -> list[tuple[int, int]]:
spans = sorted(b for tier in tiers for b in tier)
return spans
def int_log_depth(n: int, k: int = TIER_CAP) -> int:
"""floor(log_k n) + 1, computed integrally (math.log(1000, 10) < 3.0)."""
depth, m = 1, n
while m >= k:
m //= k
depth += 1
return depth
for n in (1, 9, 10, 11, 100, 1_000, 10_000, 100_000):
tiers = rollup(n)
spans = covered_spans(tiers)
# the paper's one invariant: everything evicted stays addressable
assert spans[0][0] == 0 and spans[-1][1] == n - 1
for (_, a_hi), (b_lo, _) in zip(spans, spans[1:]):
assert b_lo == a_hi + 1, f"gap in eviction index at {a_hi}->{b_lo}"
# every tier holds at most k-1 blocks once its carry has settled
assert all(len(t) <= TIER_CAP - 1 for t in tiers)
# tier depth is exactly floor(log_k n) + 1
assert len(tiers) == int_log_depth(n), (n, len(tiers))
# and total blocks stay under the (k-1) * depth bound
assert index_blocks(tiers) <= (TIER_CAP - 1) * len(tiers)
sizes = {n: index_blocks(rollup(n)) for n in (100, 1_000, 10_000, 100_000)}
assert sizes == {100: 4, 1_000: 8, 10_000: 16, 100_000: 16}, sizes
# 1000x more evictions costs 4x more index blocks, not 1000x
assert sizes[100_000] / sizes[100] == 4.0
# the settled tier profile at n=10000 is Pascal's row 4
assert [len(t) for t in rollup(10_000)] == [1, 4, 6, 4, 1]
print(f"[1] eviction-index blocks vs evictions: {sizes} "
f"(a flat index would be 100/1000/10000/100000); "
f"coverage gap-free and tier depth = floor(log10 n) + 1 at every n")
# --------------------------------------------------- 2. three-policy rollout
RNG = np.random.default_rng(20260821)
T = 400 # agent steps
C = 128_000 # nominal context window (tokens)
RHO = 0.8 # eviction trigger, config.py compact_threshold_ratio
RESERVE = 12_800 # protected recent tail, 0.1 * C
SUMMARY_TOKENS = 1_500 # size of the replacement summary
SUMMARY_KEEP = 6 # facts a summary can carry per compaction
HEADLINE_TOKENS = 40 # one address-anchored index line per evicted span
ev = RNG.integers(200, 2_000, size=T).astype(np.int64) # per-step event size
FACT_STEP = np.arange(0, T, 10) # 40 planted facts
salience = RNG.random(FACT_STEP.size) # what a summariser keeps
QUERY_STEP = 380 # the step at which the probe fact is asked about
def run(policy: str, query_fact: int | None = None):
"""Return (billed, recall, overflow_step, retrievals, lost, evicted_hi)."""
view_start = 0 # oldest step still in the working view
view_tok = 0
billed = 0
lost: set[int] = set() # facts destroyed by summarisation
evicted_hi = -1 # highest step address that has left the view
index_tok = 0
retrievals = 0
overflow = -1
queries = [(s, f) for f, s in enumerate(FACT_STEP) if s < 300]
query_at = {} if query_fact is None else {QUERY_STEP: query_fact}
for t in range(T):
view_tok += int(ev[t])
if policy == "none":
if view_tok > C and overflow < 0:
overflow = t
billed += view_tok
continue
while view_tok + index_tok > RHO * C:
# evict the oldest span down to the protected tail
span_end = view_start
span_tok = 0
while span_end < t and view_tok - span_tok > RESERVE:
span_tok += int(ev[span_end])
span_end += 1
view_tok -= span_tok
if policy == "summarise":
# lossy: only the SUMMARY_KEEP most salient facts survive
in_span = [f for f in range(FACT_STEP.size)
if view_start <= FACT_STEP[f] < span_end]
keep = sorted(in_span, key=lambda f: -salience[f])[:SUMMARY_KEEP]
lost |= set(in_span) - set(keep)
view_tok += SUMMARY_TOKENS
else: # offload: span stays verbatim in the log
index_tok += HEADLINE_TOKENS
evicted_hi = span_end - 1
view_start = span_end
step_cost = view_tok + index_tok
if t in query_at and policy == "offload":
f = query_at[t]
if FACT_STEP[f] <= evicted_hi:
retrievals += 1
# ms.search + ms.expand: the span re-enters this one prompt
step_cost += int(ev[FACT_STEP[f]])
billed += step_cost
answered = [f for _, f in queries if f not in lost]
recall = len(answered) / len(queries)
return billed, recall, overflow, retrievals, lost, evicted_hi
# probe = the first planted fact that summarisation actually destroys
lost_facts = sorted(run("summarise")[4])
assert lost_facts, "the trace must actually destroy something"
PROBE = lost_facts[0]
PROBE_STEP = int(FACT_STEP[PROBE])
none_bill, _, none_overflow, *_ = run("none", PROBE)
sum_bill, sum_recall, *_ = run("summarise", PROBE)
off_bill, off_recall, _, off_retr, *_ = run("offload", PROBE)
assert none_overflow == 108, none_overflow
assert 0.0 < sum_recall < 1.0, sum_recall
assert off_recall == 1.0, off_recall
assert off_retr == 1, off_retr
# at this retrieval rate a 1500-token summary per compaction costs MORE than
# a 40-token index line, so offload is lossless AND cheaper here
assert off_bill < sum_bill, (off_bill, sum_bill)
assert abs(100 * (sum_bill - off_bill) / sum_bill - 1.16) < 0.01
print(f"[2] no-management overflows the {C:,}-token window at step "
f"{none_overflow} of {T} (billed {none_bill/1e6:.2f}M)")
print(f" summarise-and-discard: recall {sum_recall:.3f}, "
f"{sum_bill/1e6:.2f}M billed prompt tokens")
print(f" offload-and-retrieve : recall {off_recall:.3f}, "
f"{off_bill/1e6:.2f}M billed prompt tokens "
f"({100*(sum_bill-off_bill)/sum_bill:.2f}% cheaper, {off_retr} retrieval)")
# ------------------------------------------------ 3. irreversibility (probe)
# The summary operator is not invertible: retrying, re-prompting, or asking a
# better question cannot recover a fact the compaction step already dropped.
assert PROBE_STEP < QUERY_STEP
# summarise: unreachable, and identically unreachable on every retry
for _ in range(5):
assert PROBE in run("summarise", PROBE)[4]
# offload: the span is still in the log, so one ms.expand call recovers it
assert run("offload", PROBE)[4] == set()
assert PROBE_STEP <= run("offload", PROBE)[5], "probe span must be evicted"
print(f"[3] summarise destroys {len(lost_facts)}/{FACT_STEP.size} planted "
f"facts; probe fact {PROBE} (step {PROBE_STEP}, asked at step "
f"{QUERY_STEP}) is unreachable on 5/5 retries, and recoverable by "
f"address under offload")
# --------------------------------------------------- 4. two cost break-evens
W, H, STEPS = 1_200, HEADLINE_TOKENS, 200 # span tokens, index line, horizon
def billed_keep(steps=STEPS):
return steps * W
def billed_offload(q, steps=STEPS):
return steps * H + q * steps * W
q_star = 1.0 - H / W
assert abs(billed_keep() - billed_offload(q_star)) < 1e-9
assert billed_offload(0.5) < billed_keep()
assert billed_offload(0.99) > billed_keep()
print(f"[4a] no prefix cache: retrieval beats keeping until the span is "
f"re-read on {100*q_star:.1f}% of steps")
# With a perfect prefix KV cache the arithmetic inverts: a resident span is
# prefilled ONCE; a retrieved span is re-prefilled on every retrieval.
def prefill_keep():
return W
def prefill_offload(n_retrievals):
return n_retrievals * W + H
assert prefill_offload(1) > prefill_keep() # already worse at n=1
assert prefill_offload(0) < prefill_keep()
n_break = 1
assert prefill_offload(n_break) / prefill_keep() > 1.0
loss = prefill_offload(4) / prefill_keep()
assert abs(loss - 4.033333333333333) < 1e-9, loss
print(f"[4b] with a prefix KV cache the method LOSES: 4 retrievals of the "
f"same span cost {loss:.2f}x the prefill of never evicting it")
# --------------------------------- 5. what the LOCA headline is, in samples
# The denominator is CITED, not inferred. LOCA-bench (arXiv 2602.07962,
# Section 3.1): 15 seed tasks, "for each length, we use five random seeds ...
# leading to 75 samples at each length. In total, LOCA-bench contains 525
# samples." The divisibility scan below is corroboration, and it also shows
# what such a scan can and cannot decide on its own.
SEED_TASKS, SEEDS, LENGTHS = 15, 5, 7
N = SEED_TASKS * SEEDS
assert (N, N * LENGTHS) == (75, 525)
OWN = np.array([86.7, 89.3, 88.0, 85.3, 65.3, 66.7, # Table 3
78.7, 69.3, 66.7, 30.7, 37.3, 60.0, 58.7, # Table 4
62.7, 32.0, 22.7])
BORROWED = np.array([38.7, 49.3, 34.0, 21.3, 14.7]) # Table 7, prior work
def fits(vals, n: int, tol: float = 0.05) -> bool:
k = np.round(vals * n / 100.0)
return bool(np.all(np.abs(k * 100.0 / n - vals) < tol))
# The cited N is consistent with every percentage Scroll measured itself, and
# it is the smallest denominator that is. It is NOT the only one: 150 fits as
# well, and 150 additionally accommodates the one borrowed cell that 75 cannot
# (34.0 = 51/150 exactly). A divisibility scan alone therefore cannot pick the
# denominator; only the citation does. Reported here so the scan is not
# mistaken for the source of the number.
assert fits(OWN, N)
assert not any(fits(OWN, n) for n in range(1, N)), "75 is the smallest fitting n"
assert fits(OWN, 2 * N), "150 fits too: divisibility cannot single out 75"
assert not fits(np.array([34.0]), N) and fits(np.array([34.0]), 2 * N)
assert 34.0 * 2 * N / 100.0 == 51.0
samples = {v: int(round(v * N / 100)) for v in (89.3, 86.7, 85.3, 66.7, 65.3)}
assert samples == {89.3: 67, 86.7: 65, 85.3: 64, 66.7: 50, 65.3: 49}, samples
# Scroll's margin over the CodeAct baseline at LOCA 256K is ONE sample, i.e.
# one (seed task, seed) instance: one fifth of one seed task's five seeds.
assert samples[86.7] - samples[85.3] == 1
# its margin over the summarisation baseline is sixteen samples
assert samples[86.7] - samples[65.3] == 16
# Of Table 7's five borrowed prior-system cells, 34.0 is the only LOCA figure
# in either paper that is not expressible over 75. It is verbatim from
# LOCA-bench's own Table 1 (Claude-4.5-Opus, 128K), so the anomaly is upstream.
assert not fits(BORROWED, N)
misfit = [float(v) for v in BORROWED if not fits(np.array([v]), N)]
assert misfit == [34.0], misfit
print(f"[5] LOCA-bench states N={N} samples per length ({SEED_TASKS} seed tasks "
f"x {SEEDS} seeds), {N * LENGTHS} in total; every percentage Scroll "
f"measured fits it, and {N} is the smallest such n (150 fits too, so the "
f"scan corroborates rather than derives). At 256K, Scroll 86.7 = "
f"{samples[86.7]}/{N} vs CodeAct 85.3 = {samples[85.3]}/{N} -> a ONE-sample "
f"margin; vs summarisation 65.3 = {samples[65.3]}/{N} -> 16 samples. "
f"Not expressible over {N}: {misfit} (= 51/150, upstream in LOCA-bench)")
print("all assertions passed")
Executed output:
[1] eviction-index blocks vs evictions: {100: 4, 1000: 8, 10000: 16, 100000: 16} (a flat index would be 100/1000/10000/100000); coverage gap-free and tier depth = floor(log10 n) + 1 at every n
[2] no-management overflows the 128,000-token window at step 108 of 400 (billed 93.80M)
summarise-and-discard: recall 0.767, 22.36M billed prompt tokens
offload-and-retrieve : recall 1.000, 22.10M billed prompt tokens (1.16% cheaper, 1 retrieval)
[3] summarise destroys 10/40 planted facts; probe fact 4 (step 40, asked at step 380) is unreachable on 5/5 retries, and recoverable by address under offload
[4a] no prefix cache: retrieval beats keeping until the span is re-read on 96.7% of steps
[4b] with a prefix KV cache the method LOSES: 4 retrievals of the same span cost 4.03x the prefill of never evicting it
[5] LOCA-bench states N=75 samples per length (15 seed tasks x 5 seeds), 525 in total; every percentage Scroll measured fits it, and 75 is the smallest such n (150 fits too, so the scan corroborates rather than derives). At 256K, Scroll 86.7 = 65/75 vs CodeAct 85.3 = 64/75 -> a ONE-sample margin; vs summarisation 65.3 = 49/75 -> 16 samples. Not expressible over 75: [34.0] (= 51/150, upstream in LOCA-bench)
all assertions passed
Five things to take from that run.
The index really is a navigation layer, not a second history. Growing the eviction count from 100 to 100,000 (a factor of 1000) grows the model-visible index from 4 blocks to 16, and at every tested size the block spans tile [0, n-1] with no gap, which is the paper's stated invariant that everything evicted stays addressable. The settled tier profile at 10,000 evictions is [1, 4, 6, 4, 1], a binomial shape that falls out of the carry rule rather than being designed in. If an implementation of this ever shows a gap in the covered spans, the invariant is broken and evicted content has become unreachable through the index even though it is still in the log.
Doing nothing is not a policy at any horizon worth discussing. The unmanaged view crosses a 128,000-token window at step 108 of 400 and bills 93.80M prompt tokens over the run, roughly four times either managed policy. That is the uninteresting part; the interesting part is that both managed policies bill nearly the same.
At a low retrieval rate, offload is lossless and cheaper. Summarise-and-discard answers 76.7% of the later queries and bills 22.36M tokens; offload-and-retrieve answers 100% and bills 22.10M, 1.16% less. The reason is pure arithmetic: a 1,500-token replacement summary resident for the rest of the run costs more than a 40-token index line plus one 1,200-token retrieval. This is not a general result, and the constants are this page's, not the paper's. It does show that the usual intuition (lossless must cost more) is not automatic.
Irreversibility is the property, not recall. The trace destroys 10 of 40 planted facts. Probe fact 4, planted at step 40 and asked about at step 380, is unreachable on 5 of 5 retries under summarisation, because the operator that dropped it has no inverse: a better prompt, a longer think, or a second attempt all read the same summary. Under offload the same fact is one ms.expand(seq) away. The reversibility half of this argument is developed further, with a different construction, on agentic context management.
There is a regime where the programmatic policy is strictly worse. Without a prefix cache, keeping a 1,200-token span resident is cheaper than evicting it only if the agent re-reads it on more than 96.7% of remaining steps, so eviction almost always wins. With a working prefix KV cache the arithmetic inverts, because a resident span is prefilled once and then served from cache while a retrieved span is re-prefilled on every retrieval: at four retrievals of the same span, offload costs 4.03 times the prefill of never having evicted it. On a serving stack where prompt caching is load-bearing, an agent that repeatedly recalls the same evicted region is paying for the privilege of a smaller window. The countermeasure is the one the shipped code already implements: bind the retrieved object to a kernel variable and re-filter it there, rather than re-issuing ms.expand. The paper's own knowledge-update trajectory (Appendix D.1) does exactly this after a 96,806-character print is truncated at the 32,000-character observation cap.
The LOCA headline is one sample wide against the right baseline. The denominator is stated by the benchmark, not inferred from the Scroll paper: LOCA-bench builds each length from 15 seed agentic tasks and five random seeds per task, "leading to 75 samples at each length. In total, LOCA-bench contains 525 samples" (arXiv 2602.07962, Section 3.1). A regime is therefore 75 samples drawn from 15 tasks, not 75 independent tasks, and one accuracy point is 0.75 of a sample. Every LOCA percentage the Scroll authors measured (Tables 3 and 4) is expressible over 75, which corroborates the citation; the executed block also records that this test alone could not have produced the number, since 150 fits equally well. At 256K, Scroll's 86.7 is 65 of 75 and the CodeAct baseline's 85.3 is 64 of 75: a margin of one (task, seed) instance, one fifth of a single seed task's seeds, on a protocol that states "each task is evaluated once in the benchmark-provided container with a random seed" (Section 3.3), so there is no repeat to average the instance away. Against the summarisation agent the margin is 16 samples, which is real. At 128K, Scroll and CodeAct are exactly tied at 67 of 75. Read the 256K margin as noise width, not as a result.
Results as reported¶
Against long-term memory systems, best publicly reported result per system as of 15 August 2026 (paper Table 2). The caption itself warns that reader models differ across rows (Zep uses GPT-5.4, Mem0 GPT-5, Hindsight Gemini 3 Pro, Exabase M-1 Gemini 3 Flash, and so on), so this is a set of reference points rather than a controlled comparison, and the authors state they did not reproduce the baselines.
All eleven rows of paper Table 2 are reproduced, in the paper's order; a dash there means no publicly reported result.
| Method | LongMemEval-S | BEAM-10M |
|---|---|---|
| RAG | (not reported) | 24.9 |
| LIGHT | (not reported) | 26.6 |
| Zep | 90.2 | (not reported) |
| Mem0 | 94.4 | 48.6 |
| Hindsight | 94.6 | 64.1 |
| EmergenceMem | 86.0 | (not reported) |
| Honcho | 90.4 | 40.6 |
| Mastra OM | 94.9 | (not reported) |
| Cognee | (not reported) | 67.0 |
| Exabase M-1 | 96.4 | 68.0 |
| Scroll (Qwen3.8-Max) | 94.8 | 73.1 |
LOCA accuracy by context-management strategy, all four sharing the Qwen3.8-Max backbone and toolset (paper Table 3), with the sample counts from the executed block added.
| Agent loop | 128K | 256K | Change | 256K in samples |
|---|---|---|---|---|
| Summarization agent | 86.7 | 65.3 | -21.4 | 49 / 75 |
| Retrieval agent | 88.0 | 66.7 | -21.3 | 50 / 75 |
| CodeAct agent | 89.3 | 85.3 | -4.0 | 64 / 75 |
| Scroll | 89.3 | 86.7 | -2.6 | 65 / 75 |
Ablation on BEAM-10M, overall judge score (paper Figure 3, values read off the plotted labels): lossy summarisation at ingestion 0.20, Scroll without the persistent REPL 0.66, Scroll without the eviction index 0.71, full Scroll 0.73.
Where the paper and the code diverge¶
Five items, each with an exact pointer. None of them is fatal; all of them change what a reader should conclude.
- The shipped eviction path makes an LLM call the paper never mentions. Section 4.4 says "Ingestion involves no additional LLM calls", which is about ingestion and is consistent. But
ScrollContextManager.compressstep 5 calls_update_continuation_summary, which reaches_generate_plain_summary(src/qwenpaw/agents/context/scroll/manager.py, around line 989) and awaitsagent.model(...).continuation_summary.pyis 755 lines. The string "continuation" does not appear anywhere in the paper text. Algorithm 1 has no summarisation step, and Figure 1 shows only the eviction index in the view. A reader who takes Algorithm 1 as the full picture will underestimate both the per-eviction cost and the amount of lossy summarisation still present in the working view. - The best LongMemEval-S number in the paper's own Table 2 is not Scroll's. Exabase M-1 reports 96.4 against Scroll's 94.8. The abstract states "94.8% on LongMemEvalS" without a superiority claim and Section 4.1 says "competitive with the strongest reported systems", so the paper is not overclaiming; but the abstract's rhetorical shape ("94.8 ... ; 73.1 ..., surpassing the best published memory system by 5.1 points; and 86.7 ..., exceeding the best published long-horizon agent by 37.4 points") invites the reader to carry the "surpassing" over to the first figure. The two deltas that are claimed check out: 73.1 minus Exabase M-1's 68.0 is 5.1, and 86.7 minus MiniMax M3's 49.3 is 37.4.
- One LOCA cell does not fit the benchmark's own denominator, and the defect is upstream. All sixteen LOCA percentages the Scroll authors measured are expressible over 75. Of the five borrowed prior-system cells in Table 7, four also are;
Claude-4.5-Opus + ReActat 128K, reported as 34.0, is not (26/75 is 34.7, 25/75 is 33.3). Scroll did not mistype it:34.0is verbatim from LOCA-bench's own Table 1, where the Claude-4.5-Opus row reads96.0 84.0 84.0 65.3 45.3 34.0 14.7with an average of68.1, and LOCA-bench's Table 3 repeats 34.0 as the 128K ReAct baseline. Two things are wrong in that one upstream row and nowhere else in the table: 34.0 is the only cell in it that is not a multiple of 1/75, and 68.1 is the only stated average that is not the mean of its own row (the seven cells average 60.5; every other row's average reproduces to one decimal). Quote the cell as LOCA-bench's, not as Scroll's, and do not use it as a baseline without going back to the benchmark. - Figure 4's token unit is undefined, and the two readings disagree. Section 4.4 claims "median input on BEAM10M is 105K tokens, about 1% of the corpus". The 1%-of-10M framing only works if the figure counts tokens summed across a whole task. Under that reading, LOCA-256K's annotated mean of 20.5k input tokens across an annotated mean of 39.0 turns implies about 525 input tokens per model call, which is less than a system prompt plus tool schemas. Only four values per panel are numerically annotated, matching the four red-diamond means the caption describes (146.3k for BEAM-10M input), so the 105K median cannot be checked against the figure at all.
- Thinking mode is stated for BEAM and not for LOCA. Table 6's caption says "Scroll uses Qwen3.8-Max with thinking on"; Figure 3's caption repeats "thinking on". No such statement appears for LOCA or LongMemEval. Figure 4 shows LOCA running roughly four times BEAM's turn count (35.8 and 39.0 against 9.1) while emitting roughly nine times fewer output tokens (627.8 and 598.3 against 5456), which is what one would expect if extended reasoning were off for LOCA. The paper does not say.
How to use it¶
The user-visible contract is a system prompt plus a per-benchmark rubric, with no few-shot demonstrations. Appendix C reproduces both rubrics in full, and they are worth reading as a specification of what the model must be told before the mechanism works. The load-bearing instructions are not about Python; they are about evidence discipline:
- Name the storage layout explicitly: which
kindthe rows carry, thatseqorders them, that each row has asession_idand an ISOcreated_at. - Bound the search: "Start with k=5 or k=10 and increase it only when the evidence is insufficient", and search multiple named entities separately rather than AND-combining them into one query.
- Forbid redundant materialisation: "do not call ms.expand merely to pair a user message with its assistant reply".
- Give date filtering a deterministic path: an
ms.sql_queryovercreated_atwithsubstr(created_at,1,10) BETWEEN ..., andms.days_between(d1, d2)for elapsed days. - State the grounding rule as a hard constraint, and make abstention a first-class correct answer: LongMemEval's rubric requires the exact phrase "I don't have that information from our conversations."
The single most transferable operational instruction is the one in the harness rather than the prompt. When a print exceeds the observation cap, the shipped harness truncates and returns a message telling the model that its variables persist and it should re-print less, aggregating in the kernel instead. The paper's Appendix D.1 trajectory shows a 96,806-character print being cut at 32,000 characters and the model recovering by re-filtering the already-bound rows variable with no further Event Log access. Without that message, a model typically re-runs the query.
How to develop with it¶
Start by separating the three substrate concerns, because they can be adopted independently and their measured value is very unequal (19.9 to 73.1 for the log, 7.3 points for the kernel, 1.8 for the index).
- Log first. An append-only table with a monotonically increasing integer address, a
kind, a session and agent id, an ISO timestamp, and a bounded content preview with a recovery pointer for large payloads. SQLite with FTS5 is what the reference implementation uses; BM25 over it avoids any index-time model call. Do not build the retrieval interface until the log is immutable and its addresses are stable, because every later component cites those addresses. - Kernel second. A sandboxed Python process that outlives the model call, with a namespace digest (variable name, type, shape, small scalars inline) prepended to every prompt. Tool calls must return Python objects, not serialized text, or the kernel buys nothing. See agent tools and function calling for the calling convention and object-oriented agents for the closely related pass-by-reference argument.
- Index third. Require a short headline (task, verified state, next action, status) in every model response, bind it to the
seqat append time, and roll the headlines of evicted spans up through capped tiers.
For local iteration, the reference implementation's test layout is the fastest map of the contract: tests/unit/agents/context/test_scroll_manager.py, test_scroll_repl_kernel.py, test_scroll_first_run.py, test_scroll_prompt_repl_only.py, and tests/unit/runtime/test_scroll_recall_gate.py on the same branch. Pin the commit; scroll-research is a research branch and will move.
A detail worth copying: the shipped index renders a constant seam banner (END OF ARCHIVED INDEX, then a live-turn banner) immediately before the live conversation. The code comment states the reason plainly, that weaker backbones otherwise latch onto a headline in the map and answer it instead of the real request, and that the banner text is constant so it never breaks the placeholder's KV-cache prefix. Any implementation that puts an archive summary in a user message needs the same delimiter.
How to maintain it¶
- Treat the log as the schema of record. Once events carry stable
seqaddresses that appear in headlines, index blocks, checkpoints and model transcripts, renumbering is a breaking change. Migrations must add columns, never rewrite addresses. - Watch the index invariant, not the index size. The single property to assert in CI is the one the block above asserts: the union of block spans tiles the evicted range with no gap. A refactor of the carry that drops a block is invisible in every accuracy metric until a specific question needs that span.
- Re-tune the two protected sets after any prompt change. The five-newest-tool-results protection and the 200-character fold floor are chosen against a particular tool mix. A tool that returns 150-character results will never be folded and will accumulate; a tool that returns 2 MB results will be folded on its sixth appearance.
- Version the rubric with the harness. The BEAM and LongMemEval rubrics encode column names (
hist.conversation_history), row kinds (beam_chat_turn,context_msg,model_turn) and helper signatures (ms.days_between). A schema rename silently degrades the agent into full-table scans rather than failing. - Re-check the baseline table before quoting it. Table 2 is a snapshot of publicly reported numbers "as of August 15, 2026" from vendors who publish their own evaluations. Those move. The authors explicitly decline to reproduce them, citing prior disputes over evaluation setup between Zep and Mem0.
How to run it in production¶
- Budget the observation cap, not just the context window. The working view is bounded by
trigger_ratio * context_size(default 0.8), the tail bymin(40000, max(reserve_ratio * C, min(10000, 0.1 * C))), and the per-observation print by a separate character cap (32,000 in the trajectory logs). All three need to be set together: a large print cap defeats the whole design by letting oneprint(rows)refill the view. - Instrument retrieval rate per span. This is the metric that decides whether the arithmetic in section 4b is working for or against the deployment. Emit, per session, the number of
ms.expandcalls and the distribution of retrievals per evicted span. A long tail of spans retrieved three or more times means the model is re-reading instead of computing in the kernel, and the prefill bill will show it. Route these into the same pipeline as the rest of agent observability. - Treat the kernel as a sandbox boundary with real teeth. The reference implementation makes the Event Log read-only from the kernel and gates database, filesystem, network and tool access behind explicitly declared capabilities. That is a floor, not a ceiling: model-authored code executing after the model has read attacker-influenced tool output is the classic indirect-injection-to-RCE path.
- Expect no latency or cost numbers from the paper. Section 4.4 states plainly that token counts are reported "rather than latency or dollar cost, as both depend on serving configuration". Any production estimate has to be built locally. The token-economics framing to build it with is on harness effect and token economics and agent loop economics.
- Gate on backbone capability before rollout. The 64-point spread across backbones at LOCA 256K means a model swap is a re-qualification event, not a config change. Run the acting benchmark, not only the retrieval one, since the retrieval spread is six points and hides the problem.
Failure modes¶
- Over-precise filters silently undercount the evidence. The paper names this as the cause of Scroll's weakest category, multi-session reasoning at 21.9. The program runs, returns rows, and the agent answers confidently from a subset. There is no error signal.
- Positional sampling misses mid-session evidence. Appendix D.4's summarisation failure (judge score 0.42) is decided by the model reading the first three and last two user turns per session; in sessions of 200 to 470 rows the graded content sits in the middle, and the arc it needed was 100 user turns into the session. Head-and-tail reads are the default failure shape of a retrieval program.
- Querying the wrong axis is unrecoverable within a trajectory. Appendix D.3's preference-following failure (judge score 0.0 on all three criteria) is decided at step 0: the probe is framed as a tool question, none of the trajectory's fourteen queries contains the word the rubric grades, and the correct evidence brushes past in a hit list and goes unpursued. The log had the answer the whole time.
- No disconfirming query before submitting. The paper's own trajectory analysis notes that successful contradiction-resolution runs issue an address-bounded search proving nothing later overturns a correction, and that failed ones never do. Make that step explicit in the rubric.
- A single print refills the window. A 96,806-character print against a 32,000-character cap wastes a turn even when the harness truncates gracefully. Without the "your variables persist, print less" recovery message, the model re-runs the expensive query instead.
- Repeated retrieval of the same span inverts the cost model. Quantified above: 4.03 times the prefill of keeping the span, once a prefix cache is in play.
- The eviction index becomes the only path and then rots. Removing it costs only 1.8 points overall on BEAM-10M, but 14.2 on preference following (89.1 against 74.9), because that is where evidence is scattered and lexical search alone cannot find it. An index whose headlines are low-quality degrades exactly those categories and nothing else, so aggregate metrics will not surface it.
- Weaker backbones answer the archive instead of the request. The shipped code documents this concretely for GLM and DeepSeek backbones latching onto a headline in the eviction index, and mitigates it with a constant seam banner. Any port of this design inherits the bug and must port the mitigation.
References¶
- Lin, Ang, Zhu, Ding, Zhou, "Context as an Environment: Programmatic Context Management for Long-Horizon Agents", arXiv:2608.21690v1 (21 August 2026): https://arxiv.org/abs/2608.21690
- QwenPaw, the agent operating system the implementation is built on; the paper points to branch
scroll-research(read here at commit6ad27f81d4920bc0c17fc36c029c96237d58e90d): https://github.com/niceIrene/QwenPaw - QwenPaw project site: https://qwenpaw.agentscope.io/
- Wang et al., "Executable Code Actions Elicit Better LLM Agents" (CodeAct, the interface Scroll adopts): https://arxiv.org/abs/2402.01030
- Wu et al., "LongMemEval: Benchmarking Chat Assistants on Long-Term Interactive Memory" (500 questions; the S and M splits used here): https://arxiv.org/abs/2410.10813
- Tavakoli et al., "Beyond a Million Tokens: Benchmarking and Enhancing Long-Term Memory in LLMs" (BEAM, the 10M-token split): https://arxiv.org/abs/2510.27246
- Zeng, Huang, He, "LOCA-bench: Benchmarking Language Agents Under Controllable and Extreme Context Growth": https://arxiv.org/abs/2602.07962
- Anthropic, "Programmatic tool calling" (the tool-result-stays-in-the-sandbox convention Scroll cites): https://platform.claude.com/docs/en/agents-and-tools/tool-use/programmatic-tool-calling
- Anthropic, "Code execution with MCP: Building more efficient agents": https://www.anthropic.com/engineering/code-execution-with-mcp
- Zhang, Kraska, Khattab, "Recursive Language Models" (programmatic access to externalised long inputs): https://arxiv.org/abs/2512.24601
- Packer et al., "MemGPT: Towards LLMs as Operating Systems" (the external-memory paradigm Scroll contrasts against): https://arxiv.org/abs/2310.08560
- Harbor, the evaluation framework used to orchestrate the benchmarks: https://github.com/laude-institute/harbor
Related: Agentic context management · When to compact · Compaction-aware RL · Agent context and memory · Filesystem agent memory · Memory reconstruction · Object-oriented agents · Long-context reasoning · Agent harness architecture · Agent sandboxing and isolation · Agent tools and function calling · Prompt caching · Harness effect and token economics · Agent loop economics · Agent observability · Hierarchical agent decomposition · Agent evaluation