Skip to content

read_file: resumable cursor instead of same-path pagination - #573

Merged
TheGreatAxios merged 3 commits into
mainfrom
cl-6961-read_files-80k-truncation-cap-turns-one-large-file-into-many
Aug 23, 2026
Merged

read_file: resumable cursor instead of same-path pagination#573
TheGreatAxios merged 3 commits into
mainfrom
cl-6961-read_files-80k-truncation-cap-turns-one-large-file-into-many

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closes CL-6961.

The problem

read-file-guard-plugin.ts truncates large reads and told the model to "use offset=N to continue" against the identical path. Each subsequent page is a technically-legitimate but literally same-path read_file call — the exact shape a trace scan found in 97% of "4+ reads of one path" clusters: chunked pagination indistinguishable from looping.

The fix (approach chosen: resumable cursor)

Every truncated read_file result now mints a single-use tool-output:///{cursor} handle pointing at the exact resumption point (source path or blob URI, plus the next offset) and rewrites the continuation notice to hand back that path instead of "use offset=N on this same path." Following the cursor resolves through the same isToolOutputLike branch already used for real tool-output spills — no new call surface, no schema change. Each hop therefore targets a distinct path, so pagination of one large file no longer clusters as "N reads of one path," and the handle is real and resolvable: it never promises retrievable bytes that don't exist, it just remembers where to resume a fresh bounded read.

Cursors are single-use on the happy path, but the record survives consumption (bounded history, oldest evicted past 200 entries) so a stale replay — already consumed, or a turn carrying it dropped by compaction, or a race — gets a message naming the original source and the exact offset to resume from, distinct from a generic "blob not found" dead end that would name neither.

Why not the other approach

Raising MAX_RESULT_CHARS was explicitly out of scope — it just moves the same boundary.

Making the tool-output:// handle "real" by spilling the full remainder of a truncated read into a blob (so the existing "not retrievable" marker becomes literally true) was rejected: read-file-guard-plugin.ts exists specifically to stream reads without ever buffering a whole file into memory, to avoid OOM on huge files. Materializing the entire remainder into a blob on first truncation defeats that design. A resumable cursor gets the same "keep re-reading to completion" ergonomics while only ever holding one bounded page in memory at a time.

Testing

  • bun test src/plugins/read-file-guard-plugin.test.ts — 26 tests, including:
    • a truncated read never tells the model to re-read the original path
    • following the minted cursor chain reads a 9,000-line file to completion, asserting every hop uses a distinct path and the original path is referenced exactly once
    • a stale (already-consumed) cursor names the original path and offset instead of a dead end
    • a genuinely unknown tool-output URI against a real blobReader gets the production "Blob not found for key" error, not a stale-cursor message
    • a stale cursor short-circuits before ever reaching the real blobReader, so its error never regresses to the opaque "Blob not found" wording
  • bun test src/plugins/ src/util/ — 453 tests pass
  • bunx tsc --noEmit — clean

Noticed but not touched

result-truncation-plugin.ts's shared 80k-char cap (used by grep, run_shell, search_files, web_fetch, and MCP tool results) is unaffected. It is still the outermost middleware and wraps every read_file response too — what actually prevents double-truncation is that READ_FILE_MAX_BYTES (50KB) sits under MAX_RESULT_CHARS (80KB), so the outer cap never fires on read_file output. This PR does not change behavior for the other tools, and the mintCursor continuation notice itself is unchanged (~244 bytes worst case against the existing 256-byte NOTICE_RESERVE_BYTES) — the new stale-cursor message is a separate, standalone error response, not part of that bounded notice.

read-file-guard-plugin truncated large reads and told the model to
"use offset=N to continue" against the identical path. Each page was
a technically-legitimate but literally same-path read_file call, which
is exactly the shape a trace scan (CL-6961) found in 97% of "4+ reads
of one path" clusters: chunked pagination indistinguishable from
looping.

Every truncated read now mints a single-use tool-output:///{cursor}
handle pointing at the exact resumption point (source path/blob URI +
next offset) and rewrites the notice to hand back that path instead.
Following the cursor resolves through the same isToolOutputLike branch
already used for real tool-output spills, so no new call surface is
needed. Each hop therefore targets a distinct path, and the handle is
real and resolvable -- it does not promise retrievable bytes that
don't exist, it just remembers where to resume a fresh bounded read.

Rejected: raising MAX_RESULT_CHARS just moves the same boundary.
Rejected: spilling the full remainder into a tool-output blob (making
the existing "not retrievable" promise literally true by storing the
bytes) would defeat read-file-guard-plugin's whole reason for existing
-- streaming reads so a huge file is never buffered into memory to
avoid OOM. A cursor gets the same "keep re-reading" ergonomics without
ever materializing more than one bounded page at a time.
@linear-code

linear-code Bot commented Aug 23, 2026

Copy link
Copy Markdown

CL-6961

A consumed or unknown tool-output cursor previously fell through to
the generic blob-URI branch, so a live blobReader's "Blob not found
for key: tool-output:///<uuid>" error named neither the original file
nor an offset -- the model's only recovery was to recall the path
itself and re-read from scratch, reproducing exactly one instance of
the same-path repeat this mechanism exists to eliminate.

Consumed cursor records now survive (bounded, oldest evicted past 200
entries) instead of being deleted on use. A replay of an already-used
cursor is now distinguished from a genuinely unknown tool-output URI:
it gets a message naming the original source and the exact offset to
resume from, so recovery is one targeted call instead of a blind
re-read of the whole file.
@github-actions

Copy link
Copy Markdown

Thank you for your contribution to Corbits Code. Before it can be merged, please read our Contributor License Agreement and sign it by posting a new comment on this pull request containing exactly the line below (nothing else):


I have read the CLA Document and I hereby sign the CLA


Sawyer Cutler seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@TheGreatAxios
TheGreatAxios merged commit d501535 into main Aug 23, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant