read_file: resumable cursor instead of same-path pagination - #573
Merged
TheGreatAxios merged 3 commits intoAug 23, 2026
Merged
Conversation
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.
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.
TheGreatAxios
enabled auto-merge
August 23, 2026 20:31
|
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes CL-6961.
The problem
read-file-guard-plugin.tstruncates 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-pathread_filecall — 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_fileresult now mints a single-usetool-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 sameisToolOutputLikebranch 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_CHARSwas 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.tsexists 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:blobReadergets the production "Blob not found for key" error, not a stale-cursor messageblobReader, so its error never regresses to the opaque "Blob not found" wordingbun test src/plugins/ src/util/— 453 tests passbunx tsc --noEmit— cleanNoticed but not touched
result-truncation-plugin.ts's shared 80k-char cap (used bygrep,run_shell,search_files,web_fetch, and MCP tool results) is unaffected. It is still the outermost middleware and wraps everyread_fileresponse too — what actually prevents double-truncation is thatREAD_FILE_MAX_BYTES(50KB) sits underMAX_RESULT_CHARS(80KB), so the outer cap never fires onread_fileoutput. 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-byteNOTICE_RESERVE_BYTES) — the new stale-cursor message is a separate, standalone error response, not part of that bounded notice.