Repository navigation
feat(workspace): read says what the text is, stops a page before it would spill, and settles its path against disk first - #465
Conversation
244a0d3 to
5af3d63
Compare
[Medium impact] The changes affect file reading and editing within workspace workflows. The PR appears safe to merge, with non-blocking improvements needed for edit speed and unfinished-read warnings. Findings
Fix with agent prompt### Issue 1
src/modules/workspace/edit-plan.ts:379-384
`planFile` keeps comparing overlapping spans after `named` has already recorded their clashes. For example, 100 edits matching `x` 10,000 times in a 20 KB file create 1,000,000 spans and 49,500,000 inner-loop passes to report just 99 problems. This work runs while other agents wait.
Use a single pass over the sorted spans, or bound the work before building them. Limiting reported clashes does not limit the comparisons.
### Issue 2
src/modules/workspace/edit-text.ts:329-334
`otherReplacements` filters all of `plan.changes` once per edit, even after collecting the 20 notes it will show. A successful batch with 500 edits and 100 replacements per edit scans 50,000 changes 500 times to prepare its receipt. That adds work after the files are already stored.
Group changes by `edit` in one pass and build the notes from those groups.
### Issue 3
src/modules/workspace/index.ts:2221-2224
When branches keep changing during a read, `reconcilePass` can return empty `reports` with an `incomplete` reason and take in nothing from disk. `handleRead` ignores that reason and returns stored text without a warning, or a plain not-found error. The caller cannot tell that the disk check did not finish.
Carry the reason into the read's note or error, or retry the check.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryThis PR gives tool calls a result budget and uses it to build bounded workspace reads and edit answers.
Reviews (1) · Last reviewed commit: "docs(workspace): grep follows ripgrep's ..." · Reviewed by Greptile |
| spans.forEach((first, i) => { | ||
| for (let j = i + 1; j < spans.length && spans[j]!.start < first.end; j++) { | ||
| const later = spans[j]!; | ||
| const a = Math.min(first.spec.index, later.spec.index); | ||
| const b = Math.max(first.spec.index, later.spec.index); | ||
| if (a === b || named.has(later.spec.index) || pairs.has(`${a} ${b}`)) continue; |
There was a problem hiding this comment.
Clash checks repeat comparisons
planFile keeps comparing overlapping spans after named has already recorded their clashes. For example, 100 edits matching x 10,000 times in a 20 KB file create 1,000,000 spans and 49,500,000 inner-loop passes to report just 99 problems. This work runs while other agents wait.
Use a single pass over the sorted spans, or bound the work before building them. Limiting reported clashes does not limit the comparisons.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/edit-plan.ts
Line: 379-384
Comment:
**Clash checks repeat comparisons**
`planFile` keeps comparing overlapping spans after `named` has already recorded their clashes. For example, 100 edits matching `x` 10,000 times in a 20 KB file create 1,000,000 spans and 49,500,000 inner-loop passes to report just 99 problems. This work runs while other agents wait.
Use a single pass over the sorted spans, or bound the work before building them. Limiting reported clashes does not limit the comparisons.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Addressed in d92344b9e, in #463, which this PR is stacked on. A replacement whose edit is already in named can't add a clash, so the clash scan now links past it the first time it meets it (nextLater, src/modules/workspace/edit-plan.ts:519). Each later scan then crosses a run of such replacements in one step. On 160,040 generated cases, the two versions gave the same plans, and the same problems in the same order.
I timed your example on Linux with Bun 1.4.3: 100 edits replacing x in a 20 KB file of 10,000 xs. The planner took 2.96 to 3.09 s at a3eaa3334, the commit before it, and 0.19 to 0.20 s at d92344b9e, with the same 99 problems. A test at test/workspace-edit-plan.test.ts:157 plans 2,000 edits of one text at 500 places in a child process with a 5 s timeout, and checks its 1,999 problems. This PR's head carries the commit.
Written by Abigail-2676.
— Tabitha-2696
| for (const edit of edits) { | ||
| const rest = plan.changes.filter((change) => change.edit === edit).slice(1); | ||
| if (!rest.length) continue; | ||
| if (notes.length === SHOWN_MATCHES) { | ||
| more++; | ||
| continue; |
There was a problem hiding this comment.
Receipts repeatedly scan every change
otherReplacements filters all of plan.changes once per edit, even after collecting the 20 notes it will show. A successful batch with 500 edits and 100 replacements per edit scans 50,000 changes 500 times to prepare its receipt. That adds work after the files are already stored.
Group changes by edit in one pass and build the notes from those groups.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/edit-text.ts
Line: 329-334
Comment:
**Receipts repeatedly scan every change**
`otherReplacements` filters all of `plan.changes` once per edit, even after collecting the 20 notes it will show. A successful batch with 500 edits and 100 replacements per edit scans 50,000 changes 500 times to prepare its receipt. That adds work after the files are already stored.
Group changes by `edit` in one pass and build the notes from those groups.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Addressed in 706da54a7, in #463, which this PR is stacked on. A file's plan now keeps one entry per edit, with its first replacement, its count and the lines a receipt names (EditChanges, src/modules/workspace/edit-plan.ts:339). otherReplacements builds its notes from those entries and no longer filters every change for each edit (src/modules/workspace/edit-text.ts:375).
I timed your example on Linux with Bun 1.4.3: 500 edits, each replacing all 100 occurrences of its own token, so 50,000 replacements in a 244 KB file. Building the receipt took 53 to 74 ms at 6a1d0e3d9, the commit before it, and 2 ms at a3eaa3334, that pull request's head (12 ms in one of six runs). Both gave the same receipt. This PR's head carries both commits.
Written by Abigail-2676.
— Tabitha-2696
| const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }); | ||
| const report = pass.reports.get(relativePath); | ||
| const took = (op: TreeOp) => pass.ops.some((o) => o.path === relativePath && o.op === op); | ||
| const entry = store.treeGet(mount.treeStateId, relativePath); |
There was a problem hiding this comment.
Unfinished disk checks stay hidden
When branches keep changing during a read, reconcilePass can return empty reports with an incomplete reason and take in nothing from disk. handleRead ignores that reason and returns stored text without a warning, or a plain not-found error. The caller cannot tell that the disk check did not finish.
Carry the reason into the read's note or error, or retry the check.
Knowledge Base Used: Workspace synchronization module
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 2221-2224
Comment:
**Unfinished disk checks stay hidden**
When branches keep changing during a read, `reconcilePass` can return empty `reports` with an `incomplete` reason and take in nothing from disk. `handleRead` ignores that reason and returns stored text without a warning, or a plain not-found error. The caller cannot tell that the disk check did not finish.
Carry the reason into the read's note or error, or retry the check.
**Knowledge Base Used:** [Workspace synchronization module](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/workspace-module.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Addressed in d4fa18ca9. When a read's disk check gives up because the selected branch kept changing, the read now says so, with the check's reason (src/modules/workspace/index.ts:2228). For a file the workspace holds, it shows the workspace's copy with the note "The path couldn't be checked against disk (the selected branch kept changing while the scan ran), so this read shows the workspace's copy." For a file only disk has, it answers "work/b.txt is not in the workspace, and disk could not be checked (the selected branch kept changing while the scan ran)."
Two tests in test/workspace-three-way.test.ts, at lines 3830 and 3842, switch the selected branch at every disk read, as the ls test above them does. Each fails with the change taken out.
ensureSynced's other callers, readBinary and a deletion, still read such a check as finding nothing, as on main. #282, at its head 40beba72b, makes ensureSynced itself report such a check as unverified, with its reason.
Written by Abigail-2676.
— Tabitha-2696
…ments, not their pairs Greptile's review of anima-research#465, which is stacked on this branch, found this, and a run confirmed it: - planFile's clash scan compares each replacement with every later one that overlaps it, and it went on doing so after the later one's edit was named, which skips it. 2,000 edits that each replace every "x" of a text holding 500 of them overlap in every pair at every place, and the scan took 34 s on Linux with Node 24.22.0. The scan now links past a named edit's replacement the first time it meets one, so the scans after cross a run of them in one step, and the same case takes 0.3 s. The problems are the same, since the replacements passed over are only ones the scan skipped. Tests: test/workspace-edit-plan.test.ts runs that case in a child Node, stopped at 5 s, and checks its 1,999 problems. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d4fa18c to
6285a3a
Compare
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R1 at 6285a3a · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.
Reviewed at 6285a3a against main e670363
Scope. As the description asks, this covers only the six commits this PR adds on top of #463's head d92344b9e (src/modules/workspace/{index,read-page,text}.ts, four test files, one changelog fragment: +809/−125). #463's ten commits are reviewed there. The "+3.9k" figure against main includes #463. GitHub reports the branch as conflicting with main at the head above, so "will it run" is judged on a trial merge into that main, not on the PR's original base. This is our first review of the PR; there are no earlier findings to account for.
Verdict: changes requested (0 blockers, 2 major, 2 minor)
The paging core holds up. Every page I generated stayed within its limit, every line was covered exactly once, and every offsetChars a marker or note names points at the rest of its line in the stored text, BOM and CRs included. The disk check is coherent and held under the mount's turn. The problems are in how the change meets everything else. Resolved the way the description proposes, the merge with main undoes #460's fix for reads. A caller that doesn't pass resultMaxChars (connectome-host's file viewer does this today) now gets about 24,000 characters where it used to get up to 5,000 lines, and it has no way to page. And at the end of a file, the structured fields say "complete" while the line says otherwise.
On the desk's question, whether read behaviour is preserved for callers: the result fields and defaults are preserved. The content is not, and the changelog says so for CRs, the BOM and long lines. It doesn't say so for in-process callers (R1-2) or scripts (R1-4). Notes are accurate at page boundaries and at cut lines mid-file. The one boundary where they fall silent is a cut line that ends the file (R1-3).
Major
R1-1. Resolved as described, the merge with main drops #460's named pass for read, and reads of files the mount passes over by design fail again. (Found by both reviewers independently.)
- Evidence: on
main, every file tool's pass on its own path is named:ensureSyncedcallspassUnlocked(..., { named: true })(main:src/modules/workspace/index.ts:2179), and so doessettleBeforeMutation(:2164). Without it,reconcile.ts:726(} else if (!opts.named) {) leaves a path under an ignore entry, or an editor's backup file,unobserved. This PR replacesensureSyncedinhandleReadwith a directthis.passUnlocked(mount, { kind: 'paths', paths: [relativePath] })(src/modules/workspace/index.ts:2224), which has nonamed. The textual conflict is only atparsePath/parseFilePathand the doc comment, so the missing option doesn't show up as a conflict. - Reproduced: I merged this head into
mainand resolved thehandleReadconflict the way "Open pull requests this meets" proposes (this change's body with #460'sparseFilePathcall). #460'sreads, edits and deletes a file … that the store doesn't hold, as a sync naming it takes it in (#276)then fails for all three kinds (beneath an ignored directory, ignored by its own name, an editor's backup file), each at thereadcall. With{ named: true }added to that one pass, the read step passes. The edit step then fails in the same way; that is #463's edit pass, which needs the same option when #463 is rebased. With both added, one test still fails:an edit of a path the ignore list covers sees the disk version its settled adoption takes in too, also #463's. All ofworkspace-file-tools,workspace-read-text,workspace-text,workspace-editsandworkspace-edit-planpass on that merged tree. - Failure: an agent reads
work/build.log(ignored by the mount, not held).maintakes it in and shows it. After this PR merges as described, it answers "is not in the workspace, and disk could not be checked (ignored by the mount)". That is exactly the regression #460 fixed. A held file under an ignore entry would instead get the "couldn't be checked against disk" note and its stored copy. - Fix: when rebasing, call
this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true })inhandleRead, keepparseFilePath(input.path)so the other-mount refusal comes first, and keep{ named: true }inensureSynced. #460's #276 tests inworkspace-three-way.test.tsare the check. It is worth one line in "Open pull requests this meets" too, since #463's edit pass needs the same treatment.
R1-2. A call without resultMaxChars is now held to 24,000 characters, which cuts connectome-host's workspace file viewer down sharply, and that viewer cannot page.
- Evidence:
const resultMaxChars = call.resultMaxChars ?? DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS(src/modules/workspace/index.ts:1407). The framework setsresultMaxCharson every call it dispatches (src/framework.ts:14391,:15913,:20102), so the 24,000 default only ever reaches in-process callers, whose results the framework doesn't store. connectome-host (mainac2e8bf) has two such callers.sendWorkspaceFileRead(src/modules/web-ui-module.ts:2424-2445) callshandleToolCall({ name: 'read', input: { path, limit: 5000 } })with its own 256 KB byte cap and noresultMaxChars. The headlessrequest-workspace-filehandler (src/headless.ts:402-430, which the fleet module uses for child processes) does the same. The request message carries onlypathandscope(src/web/protocol.ts:1027-1032), so neither caller can ask for a second page. - Measured: read the way the viewer reads it, this repo's
src/modules/workspace/index.ts(3,780 lines, 177 KB) returnedtoLine: 543at this head. Before, it returned all 3,780 lines, under the viewer's 256 KB cap. The viewer reportstruncated: trueand shows the first 14% of the file. Lines over 2,000 characters also show the in-line[… N more chars; …]marker, which the viewer renders as file text. - Failure: once connectome-host takes an agent-framework release that includes this change, an operator opening a medium-sized source file in the WebUI sees a few hundred lines and has no way to see the rest.
- Fix: decide what a missing
resultMaxCharsmeans and say it in the changelog. The PR's rationale for the budget is what the framework stores whole, which doesn't apply to an in-process caller. So either (a) apply only the line limit whenresultMaxCharsis absent, as before, or (b) keep the 24,000 default and add a changelog sentence that in-process callers (handleToolCall,ModuleContext.callTool) now get bounded pages and should passresultMaxChars, with connectome-host's two callers changed to pass it before the release reaches them. (b) keeps the contract #463 already documents onToolCall.resultMaxChars. Either way, the changelog currently mentions the "carries none" case only in passing, and nothing in it tells an in-process caller its pages changed.
Minor
R1-3. A line cut at the end of the file gets no note, so toLine === totalLines reads as complete. (Sol found this; I corrected it.)
- Evidence:
final()picksto === last ? (to < total ? limitNote(to, rest) : null) : budgetNote(to, rest)(src/modules/workspace/read-page.ts:96). When the page ends the file, the note isnullwhether or notrestis set. Reproduced: a one-line file of 1,500"read at a limit of 1,000 returned 999 characters,totalLines: 1, toLine: 1, nonote, and a cut line whose marker namesoffsetChars=433. A file whose last line is 3,000 characters behaves the same way at the default limit. - The changelog says "Where a page's last line is cut, its note names the
offsetCharswhere that line's rest starts" with no exception. The description and the tests (test/workspace-read-text.test.ts, "a page that ends the file with no note") treat the end of file as one. A model will probably see the in-line marker. A program that checkstoLine < totalLines, as connectome-host's viewer does fortruncated, won't. - Sol also said that with
limit: 1a budget-cut first line leads a reader past its rest. Rejected: the note there readsTruncated at 1 lines (file has 2). Line 1 is cut: read its rest with offsetChars=742. Use offset/limit to read more.It names the rest. The only issue there is wording: the cut was made for the result limit, not the line limit. - Fix: when
rest !== nullat the end of the file, emitLine N is cut: read its rest with offsetChars=K.The first-line search already measuresfinal(), so the extra clause stays inside the budget. Alternatively, qualify the changelog sentence ("…except on the page that ends the file, where only the line's own marker names it").
R1-4. The 2,000-character line cut ignores the call's limit, so a script's 5,000,000-character call still gets cut lines.
- Evidence:
shown()always cuts atMAX_LINE_CHARS(src/modules/workspace/read-page.ts:60), whatevermaxCharsis. The changelog's sentence about scripts ("its pages still stop at 2,000 lines orlimitChars, unless a page would pass that") reads as if a script's reads were unchanged. But a script that reads a minified bundle or a one-line JSON file throughreadnow getscontentwith an in-line[… N more chars; read them with offsetChars=K]it can't tell from text. Together with the CR and BOM changes, a script that rebuilt a file from line pages now gets different bytes. - Fix: either lift the cut when the call's limit is large enough to hold the line (for example, cut at
max(MAX_LINE_CHARS, …)scaled tomaxChars), or add to the scripts sentence that lines are still cut at 2,000 characters and thatoffsetChars/limitCharsis the path for raw text.
Verified fine
-
Result shape and inputs: a line page still returns
path, totalLines, fromLine, toLine, note?, content, and a character pagepath, totalChars, offsetChars, nextOffsetChars, contentwith an optionalnoteadded. The defaults (2,000 lines, 2,000 characters), the "not both" error, the surrogate-pairoffsetCharserror and the empty-file and past-the-end answers are unchanged. Validation still rejects zero, negative, fractional and NaN values before a read. -
The budget measures what the framework stores: pages are measured with
JSON.stringify(data), which is exactly what history stores (src/tool-result-history.ts:57) and what the spill compares with the limit (src/framework.ts:15170). -
Boundaries, by generated walks rather than by hand. I generated 12 files: BOM or none; LF, CRLF and
\r\r\nends; lone CRs; quotes, backslashes, control characters, a tab,éand emoji; lines up to 6,000 characters; a trailing newline or none. I paged through each by following each page's note, at limits of 700, 1,000, 5,000 and 24,000, by line page (default limit andlimit: 3) and by character page. Results:- No page passed its limit.
- Every line was covered exactly once.
- Each continuation
offsetwastoLine + 1. - Each cut line's
offsetCharsand "N more chars" matched the stored text exactly, and note and marker always named the same offset. - Character pages put the stored text back exactly.
At limits of 60 to 450, pages pass the limit only in the way the changelog describes, and paging always moves on.
-
First-line search: the binary search terminates. Below the whole-line case, the measured page length doesn't decrease as the cut lengthens: each step adds at least one character of content, and the "more chars" count loses at most one digit.
-
fileText:startsindex the decoded text with the BOM and CRs included. Only a leading BOM and the CR directly before an LF are left out of a line. Invalid UTF-8 is decoded the same way for line and character pages, so offsets agree between the two. -
Disk check: the pass and the tree/blob read happen inside
withMount. A draft survives a conflict and the read shows it.readBinaryand deletion keepensureSynced. A check that gave up (the branch kept changing) is now reported, which answers Greptile's comment. -
grep: it sharesfileTextwithread, so line numbers and text agree.lineEndingsisCRLFormixedexactly when the counts say so. The pattern is built with no flags, sotest()carries nolastIndexfrom line to line. -
Tests at this head: the four touched workspace test files plus #463's
workspace-editsandworkspace-edit-planpass 334 of 334 on Node 22, as the description reports for Node 24.tool-call-result-max-charspasses 5 of 5, the fullnpm testpasses 2,738 of 2,738 on Node 22, and CI is green on all four Linux/macOS × Node 20/24 jobs.
Method. Two independent passes, merged after every claim was re-checked. Sol (Codex gpt-5.6-sol, reasoning effort xhigh, read-only sandbox, no network) reviewed d92344b9e...6285a3a8 from a checkout of this head, with main available locally. It ran 58 commands in 9.3 minutes, read 3.35M input tokens (3.18M cached) and wrote 21.5k output tokens. Its filesystem-backed tests couldn't run in its sandbox (read-only /tmp), and it had no access to downstream repositories. Claude (Opus 5.5) did the merge and downstream work: a trial merge into current main with #460's tests run, connectome-host's main searched for in-process read callers, generated paging walks run against the built module, the full test suite run, and existing review threads and CI state read. Who caught what: R1-1 was found by both independently. R1-2 and R1-4 were Claude only. R1-3 was Sol's: its end-of-file case is confirmed and adopted, and its limit: 1 case is rejected because the note names the rest. Sol's other conclusions (shape preserved, budget measured as stored, surrogate safety, search monotone, lock held, drafts kept) agree with mine and are under "Verified fine".
Review finding R1-3 on anima-research#465: on the page that ends the file, the note was null even when the page's last line was cut, so a page whose toLine equals totalLines read as complete while its last line held a cut marker. A program that checks toLine < totalLines took the file as read whole. The changelog said a cut last line's note names its rest, with no exception. That page's note now names the rest: "Line N is cut: read its rest with offsetChars=K." The first-line search measures the page with its note, so the clause stays within the result limit, and the overhead estimate already allows the longer note that holds the same clause. A page that ends the file on a line shown whole still carries no note of its own. Tests, in test/workspace-read-text.test.ts: a one-line file of 1,500 '"' read at a limit of 1,000, and a four-line file whose last line is 3,000 characters read at 24,000, each now naming the cut line's rest; and a page that ends the file on a short line after a cut one, with no note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and the changelog says how a script's line pages read Review finding R1-2 on anima-research#465: a read whose call carries no resultMaxChars was held to 24,000 characters. The framework sets resultMaxChars on a model's, a script's and the operator's puppet's call, so only a call made in process carries none, unless its caller sets one, and the framework stores no such result. The page's budget fits it to what its receiver keeps whole; a caller that states none takes the data itself. connectome-host's workspace file viewer and its headless file request read with limit 5000, no resultMaxChars, a byte cap of their own and no way to page, and a 3,780-line file came back only as far as line 543. Such a call now pages by its line limit alone, with every line whole, as before pages had a budget: linePage and characterPage take no maxChars for it. CRs and a BOM are left out of its line pages as of any other. ToolCall.resultMaxChars' doc says so for the workspace's read, while its edit and edit_many answers keep the 24,000 default. The changelog names in-process callers. Review finding R1-4: lines are cut at 2,000 characters whatever the call's limit, so a script's 5,000,000-character call gets cut lines too, and the changelog's sentence on scripts read as if their reads were unchanged. Every call that states a limit keeps one rule: its line pages are for reading, and a character page gives the stored text exactly, which each cut line's marker names. The changelog now says so for scripts. Tests, in test/workspace-read-text.test.ts: a call that carries no resultMaxChars reads 2,500 of 3,000 lines of a CRLF file, with its 3,000-character line whole and only the limit's and the CRLF notes, its default page is 2,000 lines, and its character page gives 100,000 characters whole. The tests of the cut line, which leaned on the old default, now carry a model's 24,000. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review. Addressed R1-2, R1-3 and R1-4 in R1-2. A call that carries no R1-3. The page that ends the file now names a cut last line's rest ( R1-4. The changelog's sentence on scripts now says their line pages cut a line over 2,000 characters and leave out CRs and a BOM, as a model's do, and that a character page gives the stored text exactly. We kept the cut, so every call that states a limit gets the same line pages, and each cut line's marker names the At this head, Written by Abigail-2676. — Tabitha-2696 |
…stops a page before it would spill What the text is, and a bound on one page, built on resultMaxChars from the commit before. What the text is. read decodes through fileText, new in text.ts: the lines read shows, without a BOM and with a CR before a line feed dropped, where each starts in the stored text as character paging reads it, the CRLF and LF counts, and whether the bytes are valid UTF-8. So a CRLF file's lines come without the CR, a BOM isn't shown, and the page's note says when the file has either, with the counts where CRLF and LF ends are mixed. Bytes that aren't UTF-8 show as U+FFFD, as before, and the note now says so, and that edit refuses the file and write replaces it whole (handleEdit's refusal on this branch). A character page still reads the stored text as it is. A long line. A line over 2,000 characters is cut, without splitting a surrogate pair, with how many characters follow and the offsetChars that reads them, counted in the stored text as character paging reads it, BOM and CRs included. A bound on one page. The framework stores a success's data as JSON and writes what passes the call's resultMaxChars to a file, cut mid-JSON. So a line page stops at the last line whose page fits, and a character page at the last whole character that fits (never inside a surrogate pair), each with a note saying where to continue. Each is costed as JSON writes it (jsonLength) beside the rest of the page, allowing for the page's largest numbers; a line page then adds lines while the exact page still fits. Where even the first line would pass, it is cut shorter, at the most that fits, and a character page always moves on by one character, so a limit too small for a page's own fields still gets the reader somewhere. A call without resultMaxChars, one whose caller takes the result in process, uses the framework's default, 24,000. The page logic is src/modules/workspace/read-page.ts (linePage, characterPage); handleRead now only finds the text. read's description says all three. Tests, in test/workspace-text.test.ts: fileText's lines, starts and counts, for CRLF, LF, mixed ends, a lone CR, a BOM, an empty text and invalid UTF-8. In test/workspace-read-text.test.ts: - A CRLF file reads as its lines without the CR, with its note; mixed ends are counted, and a CR that doesn't end a line stays the line's own. - A BOM is noted and not shown, a character page still shows it, a BOM alone is an empty file that has one, and invalid UTF-8 is noted on a line page and a character page. - A long line is cut at 2,000 characters before an emoji's surrogate pair, and the offsetChars it names, past a BOM and a CRLF, reads exactly the rest. - A line page stops before its result passes 24,000, 5,000 and 1,000 characters; one more line would have passed, and the note's offset reads on. - A first line that alone would pass 1,000 characters is cut where it fits. - A character page of escape-heavy text stops within 24,000 and 1,000, its pages put back the whole text, and a stop never splits a surrogate pair, at four neighbouring limits. - A limit of 50, too small for a page's own fields, still moves a character page on by one character, and shows a first line as its marker alone. - The 2000-line default holds for a call whose limit lets it, a script's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hows them
grep drops the CR before each line feed with read, for matching as well as display, so foo$ finds a CRLF
file's line, as it does under ripgrep's --crlf. grep shared linesOf with read, and with hands' planner coming to
edit, a CRLF line copied from grep ("foo\r") would miss: the planner makes
every CRLF in an edit's old text LF but keeps a lone CR, as hands' match.ts
does.
grep now searches fileText's lines: without a CR before a line feed and
without a BOM, so `foo$` finds a CRLF file's line and `^foo` a BOM file's
first. Each file's result says `lineEndings: 'CRLF'` or `'mixed'` when the file
has CRLF ends, and says nothing for LF alone. The disk version kept with a
conflict is searched the same way. linesOf, which only grep still used, goes.
grep's description says so. The cost, which the changelog names: a pattern
written to find a CR at a line's end no longer matches.
Test, in test/workspace-file-tools.test.ts: `^foo$` with context finds a CRLF
file's line with its context lines all without the CR and lineEndings 'CRLF';
a mixed file says 'mixed', an LF file nothing, a BOM file's first line matches;
and `\r` finds nothing in a CRLF file. And a test of the copy into an edit:
two lines copied from a read of a CRLF file with a
BOM, and then a line copied from a grep match, each match in an edit through
the planner this branch is stacked on, and the file keeps its CRLF ends and
its BOM.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ting and an edit do The read half of the disk check, last here since it changes most what a read does with disk. A read of a path the workspace held didn't look at disk (ensureSynced returned early), so it could show a copy another writer had changed since. Now handleRead passes over its path first, under the three-way rule a listing uses, as handleEdit does on this branch: - disk changed and the store didn't: the change is taken in, and the note says this read shows that text; - disk's copy couldn't be checked: the note says this read shows the workspace's copy; - disk has no copy and nothing records whether it was deleted there (disk-missing-provenance-unknown): the note says so, and that this read shows the workspace's copy, as handleEdit now says for an edit; - disk deleted the file: "File not found", saying disk deleted it and that was taken in (a copy disk made binary or too large keeps the disk-only answer); - the stored copy is a draft and disk changed too: the read shows the draft, and the note names the conflict and both ways to settle it, as the edit's answer does (a refusal would hide the text the agent needs to settle it). A file the workspace didn't hold is taken in with no word, as before. The note rides on a line page and a character page alike (characterPage takes extra notes now). readBinary, the framework's read_image route, settles the same way, so its doc comment's "as the read tool" stays true. A deletion still observes only a path the workspace doesn't hold (ensureSynced, its doc now says why): taking disk's change in first would let its push remove that change unseen, as for a write. Three tests in test/workspace-three-way.test.ts read the store through read, to see what a pass had or hadn't taken in: a sync asked on one branch takes nothing on another, a restored timestamp hides no change, and a tracked link the walk cap passed over isn't read through. A read now settles its own path, so they read the store directly (storedOf, beside contentOf, whose doc now says a read settles first). Before, "the read serves the store" was the first assertion of the second; that is the behaviour this commit changes. Tests, in test/workspace-file-tools.test.ts (6): disk's newer text is shown with the note, the next read has none, and a character page says it too; a file disk deleted; a draft whose disk copy changed shows the draft with the conflict note and leaves disk alone; a directory in the file's place reads the workspace's copy with the unverified note; an entry disk lacks with no record reads with the missing-copy note; readBinary returns disk's new bytes. Red-first: with this commit's source changes undone, all five fail and the other 29 in the file pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry keeps the workspace's copy, and a deletion conflict says what a sync takes What review found in the read half, and the fixes: - A page whose last line is cut, at 2,000 characters or as a first line cut to fit, named the next line as where to continue. Reading on in order starts with that line's rest, which only the line's own marker named. The note now names the offsetChars of the rest before the next line's offset, on a limit's note and a budget's alike. A page that ends the file has no next line to name, so its cut line's marker stands alone, as before. - The first line's cut is sized on the page itself, now that the note names the cut. The line as a page shows it is tried first: shown whole, it carries no marker and its note no cut, so its page can fit where a cut one character shorter doesn't (a one-line file of 900 characters at 1,000: 984 characters whole, 1,032 cut at 899). Below that, a longer cut never makes the page shorter, and a binary search finds the most that fits. The estimate it replaces reserved the longest note, so on a page that ends the file, which carries none, the cut stopped short by that note's length. - readBinary goes back to observing only a path the workspace doesn't hold (ensureSynced), as before this branch. The pass takes no binary disk copy into the tree, so settling dropped a held binary file that disk had changed, and readBinary, a reader of binary content, answered File not found for a file disk still had. read_image doesn't reach readBinary on this module either: the framework calls the module's own read_image handler where the module serves it, and readBinary only where it doesn't. readBinary's doc and ensureSynced's now say why a held path is left alone. - A conflict's note told the reader that a sync takes disk's copy. Where disk has none, a sync takes the deletion and removes the draft, and the note now says so wherever disk has no copy now (the report's diskNow), not by the conflict's kind: a recorded conflict keeps its kind, so one recorded as both-changed before disk deleted the file is still both-changed (which the test below confirms). Where disk has a copy the workspace can't hold (binary or too large), a sync takes nothing in and only gives up the draft, so the note says what holds in both cases: a sync keeps disk's copy and gives up the workspace's. - The mixed-line-ends note said each line is shown without a CR. Only a CR before a line feed is dropped, and the note says that. - The read tool's description scopes the CRLF and BOM wording to a line page, and says a character page returns the stored text as it is, BOM and CRs included. - The changelog says where a page's bound holds: where its own fields and the least it can show fit, which at 1,000 characters only a path of about 130 characters beside every note a read can carry, or about 700 with none, passes. It also gives the cut-line note, a file disk made binary, the deletion conflict, grep's CR wording, that a path the workspace didn't hold was observed before too, and the prompt-cache miss the changed tool descriptions cost once. It no longer mentions readBinary, which this branch now leaves as main has it. Tests: - workspace-read-text: the first-line cut's note names the rest, and one character more passes the limit, on a page with a note and on a one-line file's page with none; a one-line file whose whole page fits is shown whole, where a cut one character shorter wouldn't fit; a page whose last line is cut before a surrogate pair names the offset its marker does; a page stopped at a cut line by its limit and by the result limit names the rest, and a page that ends the file has no note; a call that carries no resultMaxChars gets pages within 24,000; each of the emoji loop's four limits checks the page's bound; the mixed note's wording. - workspace-file-tools: a read of a draft disk deleted gives the deletion's note, and so does a conflict recorded as both-changed before disk deleted the file; a sync of the path then removes the draft, and a forced materialize writes it back; a draft whose disk copy turned binary gets the note that a sync keeps disk's copy and gives up the draft, and the sync does that; a read of a file disk made binary answers the disk-only text; readBinary serves a held binary file that disk changed as the workspace holds it (in place of the test that pinned the settle). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e, not in showing it The comment above grep's search and the changelog said grep leaves out the CR before a line feed for matching as well as display "as ripgrep's --crlf mode does". ripgrep 14.1.0's --crlf lets `foo$` find `foo\r\n`, but it prints the line with its CR and refuses a `\r` in a pattern, so only the matching is ripgrep's. Both now say so. The changelog also says "no note" where it said "no word", and says what the framework did with a page past the limit: it kept only the start, cut mid-JSON, and wrote the whole result to a workspace file where the agent has one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t finish When the selected branch keeps changing while a read's pass gathers, the pass decides nothing: it leaves no report for the path, only an incomplete reason, "the selected branch kept changing while the scan ran". The read then showed the stored copy with no note, or, for a path the workspace doesn't hold, a plain "File not found", so the caller couldn't tell that its disk check hadn't finished. The read now takes such a pass as a copy disk couldn't be checked, with the pass's reason: the note says the read shows the workspace's copy and why, and a path the workspace doesn't hold answers that disk could not be checked. Tests, in test/workspace-three-way.test.ts beside the listing's: a read of a held file, and one of a file only disk has, each under a branch that switches at every disk read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review finding R1-3 on anima-research#465: on the page that ends the file, the note was null even when the page's last line was cut, so a page whose toLine equals totalLines read as complete while its last line held a cut marker. A program that checks toLine < totalLines took the file as read whole. The changelog said a cut last line's note names its rest, with no exception. That page's note now names the rest: "Line N is cut: read its rest with offsetChars=K." The first-line search measures the page with its note, so the clause stays within the result limit, and the overhead estimate already allows the longer note that holds the same clause. A page that ends the file on a line shown whole still carries no note of its own. Tests, in test/workspace-read-text.test.ts: a one-line file of 1,500 '"' read at a limit of 1,000, and a four-line file whose last line is 3,000 characters read at 24,000, each now naming the cut line's rest; and a page that ends the file on a short line after a cut one, with no note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and the changelog says how a script's line pages read Review finding R1-2 on anima-research#465: a read whose call carries no resultMaxChars was held to 24,000 characters. The framework sets resultMaxChars on a model's, a script's and the operator's puppet's call, so only a call made in process carries none, unless its caller sets one, and the framework stores no such result. The page's budget fits it to what its receiver keeps whole; a caller that states none takes the data itself. connectome-host's workspace file viewer and its headless file request read with limit 5000, no resultMaxChars, a byte cap of their own and no way to page, and a 3,780-line file came back only as far as line 543. Such a call now pages by its line limit alone, with every line whole, as before pages had a budget: linePage and characterPage take no maxChars for it. CRs and a BOM are left out of its line pages as of any other. ToolCall.resultMaxChars' doc says so for the workspace's read, while its edit and edit_many answers keep the 24,000 default. The changelog names in-process callers. Review finding R1-4: lines are cut at 2,000 characters whatever the call's limit, so a script's 5,000,000-character call gets cut lines too, and the changelog's sentence on scripts read as if their reads were unchanged. Every call that states a limit keeps one rule: its line pages are for reading, and a character page gives the stored text exactly, which each cut line's marker names. The changelog now says so for scripts. Tests, in test/workspace-read-text.test.ts: a call that carries no resultMaxChars reads 2,500 of 3,000 lines of a CRLF file, with its 3,000-character line whole and only the limit's and the CRLF notes, its default page is 2,000 lines, and its character page gives 100,000 characters whole. The tests of the cut line, which leaned on the old default, now carry a model's 24,000. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
795f422 to
0b8a2d0
Compare
…l's does Review finding R1-1 on anima-research#465: merged with main, read's own pass over its path lacked the { named: true } that anima-research#460 gave ensureSynced. So a read of a file the mount passes over by design (beneath an ignored directory, ignored by its own name, or an editor's backup) that the store doesn't hold answered that disk could not be checked ("ignored by the mount"), where main's read observes it. The three anima-research#276 tests in test/workspace-three-way.test.ts that read such a file failed. The pass now names the path, as a file tool names the one it acts on, so a read observes it as a sync naming it would, and those tests pass. Tests, in test/workspace-three-way.test.ts: a read of a held file beneath an ignored directory, and of one ignored by its own name, takes disk's change in first, says so and stores it, and then takes disk's deletion in first, as a read of any held file does. The anima-research#276 tests cover only files the store doesn't hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l's does Review finding R1-1 on anima-research#465: merged with main, read's own pass over its path lacked the { named: true } that anima-research#460 gave ensureSynced. So a read of a file the mount passes over by design (beneath an ignored directory, ignored by its own name, or an editor's backup) that the store doesn't hold answered that disk could not be checked, "ignored by the mount" or "an editor's swap or backup file", where main's read observes it. The three anima-research#276 tests in test/workspace-three-way.test.ts that read such a file failed. The pass now names the path, as a file tool names the one it acts on, so a read observes it as a sync naming it would, and those tests pass. Tests, in test/workspace-three-way.test.ts: a read of a held file beneath an ignored directory, and of one ignored by its own name, takes disk's change in first, says so and stores it, and then takes disk's deletion in first, as a read of any held file does. The anima-research#276 tests cover only files the store doesn't hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The doc said SubagentModule dispatches ephemeral agents' tool calls through executeToolCall. Nothing in src/ calls it but the operator's puppet (puppetToolCall), which puts the agent's inline cap on the call as resultMaxChars, and connectome-host's main doesn't call it. The doc now names the puppet, and says a host may call it as well, as ToolCall.resultMaxChars' doc already describes such a call. Two older lines spoke of callers it doesn't have, the comment beside ModuleContext.callTool's closure and the doc's own reason for agent origin. They now say its callers act for an agent: the puppet as the agent, and a host for the model it runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0b8a2d0 to
fd7a44b
Compare
|
Replying to this review. Addressed R1-1 in R1-1.
Taking At this head, Written by Abigail-2676. — Tabitha-2696 |
…how text as a 'chronicle' mount's do Anarchid's nightly review R3-1 (anima-research#465, at ac6f05d): since anima-research#564 merged, this branch conflicted with main, and the merge decides whether a direct mount gets this change's read and grep. main's direct mounts (anima-research#564) moved where read and grep get a file's bytes: read takes them from bytesOf, which on a direct mount reads disk as it is (bytesOnDisk) and on a 'chronicle' mount syncs the path first; grep takes each text from textsFromStore or textsFromDisk. This branch's read checks disk inline before reading the workspace's copy, and its grep searches each copy's bytes through fileText, so the two meet in four places in src/modules/workspace/index.ts. Each is resolved so a file's text is shown the same way whichever kind of mount holds it, since read and grep are one tool for one agent and a direct mount changes only where the bytes come from: - bytesOf now gives the bytes and the read's notes. On a 'chronicle' mount it runs this branch's disk check in place of ensureSynced: the named pass over the path, a change disk made taken in first, a pass that gave up read as a copy disk couldn't be checked, and the notes on which copy the read shows. On a direct mount it gives bytesOnDisk's bytes with no notes, since nothing else is kept there. handleRead sends both through fileText and linePage or characterPage, so a direct mount's pages leave out a CRLF end's CR and a BOM, say so, cut a long line with its rest named, and stop at the call's result limit. - GrepText carries each text's bytes in place of a decoded string, so a direct mount's grep matches fileText's lines too, with each file's lineEndings and the notes on a pattern that names a CR or U+FEFF. - readBinary's doc keeps both sides' facts: a path the workspace doesn't hold observed first, a held one read as held, and a direct mount read from disk. Before this resolution, a direct mount on main read a CRLF file's lines with their CR, gave a 60,000-character line back as 60,083 characters of JSON with no note, and found nothing for two$ in a CRLF file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unt's answers Anarchid's nightly review R3-1 (anima-research#465, at ac6f05d) asked for the CRLF, BOM, budget, long-line and grep tests on a direct mount too. Two tests on a direct mount, each of which compares its answers with a 'chronicle' mount's over the same files: - read: a CRLF file, one that starts with a BOM, one that isn't valid UTF-8, one with mixed ends and a line whose own text ends in a CR, a 60,000-character line and a 3,000-line file, each read as a page of lines and of characters at a result limit of 24,000, of 1,000, and with none. Beside the comparison, it checks the CRLF and BOM pages and their notes, the cut line's marker, and that the long line's page at 24,000 and the 3,000-line file's pages at both limits stay within them. - grep: ^foo$ over CRLF, mixed, LF and BOM files, with each file's lineEndings, and the notes on patterns that name a CR, U+FEFF or both. With direct mounts left on main's reading and every other line of the merge as it is, these two fail, and every other test in the five workspace test files this change touches passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…backed mount's do Anarchid's nightly review R3-1 (anima-research#465, at ac6f05d): with direct mounts left on main's reading, the changelog's claims on read's pages and grep's lines would be false for them. The changelog's sentences on read's pages and grep's lines now hold for direct mounts too, and one line says so: the same pages, cut lines, notes and lineEndings, with the disk check a store-backed mount's alone, since a direct mount reads disk as it is and keeps no copy. Direct mounts are new in the same release, so there is no earlier behaviour of theirs to name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th a direct mount grep's note on a pattern that names a CR or U+FEFF ends by saying where a character read shows what lines leave out: "every CR in the workspace's copy", "any BOM the workspace's copy has". The note names the workspace's copy because grep also searches a conflict's disk version, which read doesn't show. Since the merge with main, a direct mount's grep gives the same notes, and a direct mount keeps no workspace copy: its read shows disk's file. On a host with a mount that runs direct, the notes now end "every CR in the workspace's copy, or in disk's file on a direct mount" and "any BOM in the workspace's copy, or in disk's file on a direct mount". A host with none keeps the notes as they were, as the tools' descriptions mention direct mounts only on a host that has one. The direct grep test checks each host's notes, and that the rest of the two hosts' answers is the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…limit to it The test's title says each page is held to the call's result limit, and it checked that only for the 60,000-character line's page at 24,000 and the 3,000-line file's pages at both limits. It now checks every page it asks for with a limit: each file's line page and character page, at 24,000 and at 1,000. The comparison that follows holds a 'chronicle' mount's pages to the same answers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in too The 'chronicle' backend's doc listed listings, searches, syncs and scans as what takes disk's changes in, and said a read of a path the store holds reads the store's copy. Since read settles its path against disk first, under the same three-way rule, the doc names reads among them, and says a read then shows the store's copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… question of whether a mount runs direct getTools mentions direct mounts in four descriptions only where one of the host's mounts runs direct, and grep's notes name disk's file on the same hosts. The two had each written the test inline. anyRunsDirect now asks it for both, so a later change to what counts can't leave the descriptions and the notes disagreeing about the host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…which reads take disk's change in MountConfig.backend's doc said a 'chronicle' mount takes in disk's changes as its reads reach them, and that a read then shows the store's copy. Of the reads, only the read tool settles its path first. read_image and readBinary still read a held path as the workspace holds it, and a read that takes in disk's deletion shows no copy. The doc now names `read` among what takes disk's changes in, and says the mount's tools read the store's copy. The changelog's sentence on grep's CR note now says the note names disk's file on a direct mount too, where the host has one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… each capped direct read is a page The notes name disk's file wherever any of the host's mounts runs direct, and the only grep test of them used a host with a direct mount alone, where a choice by the mounts a call searched would give the same notes. A new test greps a 'chronicle' mount beside a direct one, for a CR and for U+FEFF, and expects the notes to name disk's file too. The direct read test's check that each page with a result limit fits it also checks that each is a page, since an error's text would fit any limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review. @Anarchid R3-1 is addressed in R3-1.
R3-2. We've kept At The description is updated to match. Written by Abigail-2676. — Tabitha-2696 |
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R4 at a40712e · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R4-n are stable; cite them in fix commits.
Reviewed at a40712e against main 2099c40
Nightly dual review · round R4 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged, with every claim re-checked by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the maintainer files the formal review event). Finding IDs R4-n won't change between rounds, so fix commits can cite them.
Scope. This is round 4. Round 3 is the review posted at ac6f05d8. It went up as a comment with a changes-requested verdict, and its findings are R3-1 and R3-2. The PR's merge base with main is b0b13d3c, which the PR merged in at 4c6171c0, so git diff main...a40712ef is this change alone: 12 files, +1,532/−169. Since then main has moved 55 commits to 2099c40a. None of them touch src/modules/workspace/ or src/tool-result-history.ts. The PR merges cleanly into that main, and every test result below comes from that trial merge.
Verdict: changes requested (0 blockers, 1 major, 0 minor)
R3-1 is fixed, and fixed the way it should be. A direct mount now gets this PR's read and grep, and new tests compare its answers with a store-backed mount's. The paging core hasn't changed since round 3, and the full suite passes on today's main. One problem is left. It is in grep, and it predates round 3, though no round of ours raised it. Leaving a CRLF end's CR out of the lines grep searches removes the only way the workspace tools had to find which files have CRLF ends or a BOM. The note that replaces it doesn't name any file (R4-1). Grep already has the data, so the fix is small.
Earlier findings
| Disposition | Evidence | |
|---|---|---|
R3-1 the branch conflicted with main after #564, and a direct mount still ran main's read and grep |
Fixed | 4c6171c0 resolves the merge as proposed. bytesOf (src/modules/workspace/index.ts:3738) runs the disk check on a store-backed mount and returns disk's bytes with no notes on a direct one. GrepText carries bytes (:5597), so both mount kinds go through fileText. Tests are at test/workspace-direct.test.ts:2318, :2367 and :2415. We re-measured round 3's three symptoms on a direct mount at a cap of 24,000, on main 2099c40a and then on this head. one\r\ntwo\r\n read as 1\tone\r/2\ttwo\r and now reads as 1\tone/2\ttwo, with the CRLF note. The 60,000-character line was a 60,094-character page and is now 2,155. grep two$ found nothing and now finds line 2, with lineEndings: "CRLF". |
R3-2 the read tool's "Keep limitChars when continuing" contradicts a stopped character page's limitChars |
Not fixed: declined, with reasons | index.ts:1726 is unchanged. Sol raised it again on its own this round, with the same result as round 3: keeping the old limitChars reads past the cut line's end into lines the next line page shows again. On the release side, v0.23.2, published Oct 20 and now npm latest, still carries edit's pre-#463 description. So the next minor release still rewrites the workspace tool list once, whatever this PR does, and the clause would land in that same rewrite. The author argues the stop is rare, costs one page and shows no wrong text, and we agree with all three points. We don't hold the PR on it, and the maintainers can decide. |
| R2-1 – R2-3, R1-1 – R1-4 | Still fixed | read-page.ts and text.ts are byte-identical between ac6f05d8 and this head, and the tests round 3 cited pass on the trial merge. |
Major
R4-1. grep can no longer find which files have CRLF ends or a BOM, and its note doesn't name them. (Claude only.)
-
What changed.
grepnow matchesfileText's lines, without a CRLF end's CR or the BOM. That much is intended and in the changelog: "a pattern written to find the CR of a CRLF line end no longer matches". What replaces it doesn't cover the usual reason to write such a pattern, which is to find the files:- The note is built from two booleans,
searchedCrlfandsearchedBom(index.ts:4644,leftOutNoteat:5941). It says that "a file searched has CRLF line ends" and names none. lineEndingsappears only on a result with at least one match (:4680), so a CRLF file that doesn't match the pattern is never reported.- Nothing in grep's answer marks a file that starts with a BOM.
- The note is built from two booleans,
-
Measured. We ran the same tree through
main2099c40aand through the trial merge. The tree had 4 small files (LF, CRLF, mixed, BOM) and 50 ordinary source files, and every call hadresultMaxChars: 24000.pattern mainthis PR \r$mixed.cfg(2 lines),win.bat(2 lines)no results; a note that names no file ^bom.txt(1 line)no results; a note that names no file ^(the cheapest route left to learnlineEndingsper file)n/a 458,087 characters of JSON, 19× the cap, so it spills The workspace tools have no other route short of reading every candidate file and checking each page's note.
-
Why it matters. Finding stray CRs is a routine debugging step, for example after
$'\r': command not foundfrom a shell script checked out with CRLF ends.grepis the agent's search tool, and onmainone call answered the question. On this head the agent learns that some file has CRLF ends and has to hunt for it one read at a time. For a BOM, which breaks a shebang or a JSON parser, the agent finds nothing at all short of reading the files. -
Fix. The data is already computed for each file in
search. Take either of these:- Collect the CRLF files and the BOM files searched, and have
leftOutNotename them, bounded, for example the first 20 and then "and N more". - When the pattern names a CR or U+FEFF (
namedLeftOut), push a zero-match result carryinglineEndings(and abom: true) for each such file.grep '\r$'would then list the files as it did onmain, andtotalMatchesstays 0.
Either way, add a test that greps
\r$and^over a tree mixing CRLF, LF and BOM files and asserts exactly which files are named. Add one sentence to the changelog saying how to find CRLF and BOM files now. - Collect the CRLF files and the BOM files searched, and have
Verified fine
- Builds and tests on today's
main. On the trial merge with2099c40a(Node 22.21.0) the build is clean. The fullnpm testruns 5,860 tests: 5,815 pass, 45 are skipped and 0 fail. The workspace and result-limit test files alone run 1,202 tests: 1,158 pass, 44 are skipped and 0 fail. GitHub reports the PR as mergeable. - Paging. The code is the same as at round 3, so round 3's walk of about 870,000 generated pages still applies. This round Sol ran the current source over 1,200 generated files (LF, CRLF, BOM, invalid UTF-8, surrogate pairs) at caps from 1,000 to 24,000. That gave 17,257 line pages and 24,609 character pages. No page went over its cap, no continuation failed to advance, and every character walk rebuilt the stored text exactly.
- Disk check under the lock. The pass,
treeGetandgetBloball run insidewithMount(index.ts:3743-3778). The tests cover each answer: changed, deleted, made binary, conflict, unverified, mode-only, and a check that gave up. - Spilled tool results are ordinary workspace files (
docs/tool-result-guard.md). We wrote one withwriteBinarywhile its disk copy was refused (inChronicle: true). It stays a draft, andreadserves it twice with nothing lost. If a copy that did land on disk is later deleted there, the nextreadtakes that deletion in and answers "File not found…". Onmainthe read served the stored copy. This is the PR's stated rule, and anlsof the directory does the same onmain. We note it because spill files are where an agent is most likely to meet it. - In-process callers. connectome-host's
main(3027eafd) has two file viewers, atsrc/headless.ts:413andsrc/modules/web-ui-module.ts:2524. Both read withlimit: 5000and noresultMaxChars, so they get whole lines held to the line limit. Their reads now settle against disk, as their tree view'slsalready did. connectome-host never callsexecuteToolCall. - grep's cost. Sol pointed out that
fileTextvalidates UTF-8 on every searched file and grep never uses the result. We measured the whole change on 400 files (48 MB, a quarter of them CRLF), median of 5 runs. A store-backed mount went from 282 to 299 ms and a direct mount from 170 to 179 ms, about +6%. That isn't worth a change. - Generated text. The only file registered through
registerGeneratedTextFileis the tool-presentation catalogue (src/framework.ts:3116). Sol showed that its UTF-8 round trip (index.ts:3846) turns a lone surrogate into U+FFFD with no note. The round trip keeps the length (one code unit for one), so every offset still holds, and a lone surrogate there would need one in a tool description. That isn't worth a change. - Framework comments. In the trial merge,
executeToolCall's only caller in the framework is still the puppet (framework.ts:19233), which setsresultMaxCharsfrom the inline cap. Theutilsandcode_executioncomments match the dispatch code. - Tool text.
read's andgrep's descriptions aremain's, byte for byte. The only change ingetToolsis theanyRunsDirect()refactor.
Method. Two independent passes, merged after every claim was re-checked. Sol (Codex gpt-5.6-sol, reasoning effort xhigh, read-only sandbox, no network) reviewed main...a40712ef from a checkout of this head, with today's main and connectome-host's main readable locally. It ran 72 commands in about 8 minutes, read 3.91M input tokens (3.69M cached) and wrote 28.0k output tokens (15.0k of them reasoning). It had no installed dependencies, so it probed read-page.ts and text.ts straight from source with Node's type stripping and couldn't run the filesystem-backed tests. Claude (Opus 5.5) did everything that needed the network or a running build: re-fetching main and the PR, the trial merge, builds of both trees, the test runs, the before-and-after probes against built main (direct-mount reads, grep's CR and BOM patterns, spilled files, grep's cost), the release history, connectome-host's callers, and the prior rounds and replies. Who caught what. R4-1 was found by Claude only. R3-2's remainder was found by both: Sol filed it again as minor, and Claude added the release check. Two minors were Sol's only: the lone surrogate in generated text, and grep's unused UTF-8 scan. Claude checked both, narrowed the first (offsets hold, and only one file is affected) and measured the second, and folded both into "Verified fine". Both reviewers found R3-1 fixed and no defect in the paging or the disk check.
… files that have CRLF ends or a BOM Anarchid's nightly review R4-1 (anima-research#465, at a40712e): grep matches lines as read shows them, without a CRLF end's CR or a BOM, so a grep for \r$ or ^\uFEFF, main's way of finding which files have CRLF ends or a BOM, found nothing. Its note said that a file searched had them and named none, and lineEndings comes only with a match, so no workspace tool answered it short of reading each file. Whether a file has CRLF ends or a BOM is a fact about the file, so grep now keeps each searched file that has either by its path, and the note on a pattern that names a CR or U+FEFF says how many files searched have it and names them: "3 files searched have CRLF line ends: work/both.json (CRLF), work/mixed.cfg (mixed), work/win.bat (CRLF)". Past 20 files it names the first 20, in the order grep searched them, then "and N more; narrow with path or glob", so the count stays exact. A conflict's disk version is named as that version, since read doesn't show it, and in each note only where the workspace's copy of its path doesn't have that note's character. The results, the tools' descriptions and a search that names no such character are as they were. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ches with CRLF ends or a BOM Anarchid's nightly review R4-1 (anima-research#465, at a40712e) asked for a sentence saying how to find CRLF and BOM files now. The grep sentence says the note names the files, each as CRLF or mixed, up to 20 and then how many more, and that a BOM pattern's note names the files that start with one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…isk version, and with skipped files Anarchid's nightly review R4-1 (anima-research#465, at a40712e) fix, made more exact: - A conflict whose workspace copy and disk version both have CRLF ends and a BOM is named once in each note, as the workspace's copy. - One whose workspace copy starts with a BOM and whose disk version has CRLF ends is named in each note for the version that has it: the CR note names the conflicting-disk version, the BOM note the workspace's copy. - A conflict's recorded disk version, once disk has changed again, is named in each note as the recorded-disk version. - A binary file and one over the size limit, each with a BOM and CRLF ends, are listed as skipped and named in neither note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nly the CR note gives ends Anarchid's nightly review R4-1 (anima-research#465, at a40712e) fix, worded as the code does it. The comment on grep's files said a conflict's disk version is kept only where the workspace's copy has neither CRLF ends nor a BOM; each note keeps it where the workspace's copy lacks that note's character. leftOutNote's doc said each named file comes with its ends; only the CR note gives them, and either note names a disk version as that version. The changelog's sentence on finding CRLF and BOM files now says it finds them among the files grep searches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anch Two conflicts, both in comments and docs main and this branch each rewrote: - src/framework.ts, executeToolCallFrom's code_execution branch: main's condition (this.codeExecution) with this branch's description of the callers that reach it, the puppet and a host through executeToolCall and a module through callTool, and main's wording of what the callerAgentName key keeps apart. - src/types/events.ts, ToolCall.resultMaxChars: main's account of a script's limit (the longest result that reaches the script whole, 7,456,527 at the default memory, with nothing cut or saved) with this branch's last sentence, that without the field the workspace's edit and edit_many answers hold to the house default and a read holds its page to its line limit alone. This branch's other comments on executeToolCall's callers, the utils meta-tool and handleUtilsToolCall still hold against main: the puppet is executeToolCall's one caller in the framework, a module's callTool reaches executeToolCallFrom, and a running script's calls take the model-facing dispatcher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cript's included Main's code_execution now gives a running script's call the longest result that reaches the script whole, 7,456,527 characters at the default 128 MiB of script memory, and answers a longer one with ResultTooLarge, cutting and saving nothing. The workspace's comments said a read's limit was the most the framework stores before it spills the rest into a file, which holds for a model's call alone. They now say it's the most of the call's result that reaches its caller whole, and what becomes of a longer one on each path. The changelog gives a script's figure beside a model's, and the read tests that stand for a script's call use that figure, where they used the 5,000,000 a script's call carried before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review. @Anarchid R4-1 is addressed in R4-1. For each file it searches, grep keeps the path of one with CRLF ends, as
R3-2. Nothing changes for it here. As you say, it's the maintainers' to decide.
At The description is updated to match. Written by Abigail-2676. — Tabitha-2696 |
…esult) into the workspace read branch Three conflicts, all where main's fix for a pass that decided nothing meets this branch: - ensureSynced's doc: this branch's account of its callers (a deletion and readBinary, since read no longer calls it), with main's sentence that it answers the pass's report and why the pass decided nothing. - bytesOf: this branch's version whole. It runs read's own disk check, which already answers a pass that gave up, and no longer calls ensureSynced, whose return main changed. - test/workspace-three-way.test.ts: this branch's read test of a check that gives up and main's tests of the other tools, side by side. read's and grep's handlers are this branch's, unchanged; ensureSynced, delete and readBinary are main's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… whole Main's ToolCall.resultMaxChars doc now says a model's result within the agent's inline cap is stored whole unless its round's results together pass the readers' per-message bound, when each takes an equal share. So a read page held to the cap may still spill, beside other large results of the same round. The workspace's comments said the limit is the most of the call's result that reaches its caller whole; they now say the most that can reach it whole, and the changelog says when a model's result within the cap is stored whole. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e other file tools do main's undecidedOf reads a pass that decided nothing by its missing branchId, and delete, readBinary and the edit plan use it. read's disk check spelled the same case as a path with no report beside a region left incomplete. The two agree for every pass a read runs: a pass that decides nothing reports no path and leaves its reason as a region, and one that decides with a region incomplete, as under a moved or replaced mount root, still reports the path as unverified. One predicate keeps them from drifting apart. read's answers are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tes) into the workspace read branch One conflict, in MountConfig.backend's doc: - 'chronicle': this branch's sentence, that disk's changes are taken in as read, listings, searches, syncs and scans reach them, and that its tools read the store's copy, since read no longer takes in only a path the store doesn't hold. - 'direct': main's sentence, a checkout whose history is its own, since a direct mount now takes writes. grep's handler is main's, with its mount-prefixed reading of the glob filter, plus this branch's change exactly: lines as read shows them, and the note naming the files with CRLF ends or a BOM. The tools' descriptions are main's, the direct mount's write texts included. A direct mount's edits are planned against disk's bytes as on any mount, so the lines its read shows copy into an edit as a store-backed mount's do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@Anarchid This pull request merged into R4-1 is answered in R3-2 stands as it was. The tools' descriptions are After our R4 reply, whose head was
The description lists the checks run at the merged head. Written by Abigail-2676. — Tabitha-2696 |
…he model reads The model read each of these four results as one line of JSON: a page's numbered lines escaped inside a `content` string, grep's matches inside nested objects, a receipt's changed region inside `changed`. Each now answers with its text as data (one text block), and the same fields it had beside it as `structured`, which a code_execution script reads as structuredContent (anima-research#262's rule; anima-research#698 sends only a string data as text, so it didn't reach these). The text (result-text.ts): a header of the result's own fields, one `key: value` to a line in their order (`path`, `totalLines`, `fromLine`, `toLine`, `note`; `totalChars`, `offsetChars`, `nextOffsetChars`, null at the end), then labelled sections, `--- label ---`, an empty one left out: - read: `--- content ---`, last, so nothing in a file can read as a section; - grep: a section for each version of a file it matched, labelled `FILE: VERSION (STATE)[; lineEndings: CRLF|mixed]`, a match as `N:text` and its context as `N-text`, `--` between one match's lines and the next's, then `--- skipped ---` (`FILE: REASON (N bytes, MIME)`) and `--- incomplete ---`; - edit: path, size, replacements, note, modeKept, then `--- changed ---`; - edit_many: edits, replacements, filesNotListed, `--- files ---` (one line a file: `PATH: size N, replacements N, edits 1, 2; modeKept: …. NOTE`), then `--- changed ---`. A value written outside content and changed that holds a line break (\n, \r, U+2028, U+2029), such as a path from disk, is written as a JSON string, U+2028/U+2029 escaped too, so it can't start a line that reads as the answer's own and gives back the exact path. The words are the results' own field names, which the model read before as JSON keys, so no tool description changes. The bound: a page and a receipt are held to the call's resultMaxChars as their text measures it, where they were measured as JSON. The workspace can't tell a script's call from a model's, so this holds for both: a model gets what it reads; a script's page is never shorter than before, unless its path or a note holds a line break, and its answer carries the fields beside the text, which the script's figure counts six bytes a character for, so they pass what it takes only for a page of more than half that figure (about 3.7 million characters at the default 128 MiB of script memory) holding control characters. jsonLength and jsonCharLength had no other user and go. The framework's spill notice sized its read-back page for JSON's six-character escapes; it now sizes it as the text measure does. anima-research#465's pending changelog gives two path lengths at which a page at 1,000 characters passes the limit, measured as JSON. Measured as text, they're about 690 with no notes, where it said about 680 (a probe on main and on this change found 682 and 690), and 101 to 137 with the three text notes and a conflict's, where it said 93 to 129. The probe measured one case in that range, 124 on main and 132 here: the text's framing is 8 characters shorter than the JSON's for any page whose path and notes hold nothing JSON escapes, so each end of the range moves by 8. Tests: workspace-result-text.test.ts pins each form, the line-break quoting, each bound as text (pages and receipts that would pass the limit as JSON), and, through the framework, that a model's read reaches its history and the wire as the text and a script's call gets the text and the fields. The workspace and spill tests read fields from `structured`, measure pages and receipts by their text, and the receipt test that pinned the JSON measure is turned round; the mode-kept test reads edit's and edit_many's text too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…qualified channels meet the registry, tune-out and the reply anchor What main brings since e4d1e46, the sixth catch-up's base: 0.25.0 and 0.26.0, and anima-research#709, anima-research#710, anima-research#719, anima-research#722, anima-research#724, anima-research#728, anima-research#727, anima-research#743, anima-research#744, anima-research#755, anima-research#759, anima-research#761, anima-research#763, anima-research#767, anima-research#707, anima-research#740, anima-research#760, anima-research#726, anima-research#371, anima-research#452, anima-research#768, anima-research#465, anima-research#770, anima-research#771, anima-research#723, anima-research#777, anima-research#776, anima-research#773, anima-research#775, anima-research#778, anima-research#774, anima-research#779 and anima-research#781. Most of the conflicts are where anima-research#710 met the branch: in main a channel the framework acts on is a ChannelRef, { serverId, channelId }, since two MCPL servers can register one id. Conflicts in 14 files (61 hunks), resolved by keeping both sides' intent; where the two had different designs for one thing, the branch's stays, moved onto main's types: - src/framework.ts: main's conversationAgentHomes map stays gone. A fork's home is the registry record's homeChannel, which homeOf reads wherever main read the map (isForkBound, mayTakeGateRoute, canCloseChannels, holdsChannelTraffic, agentForPrimary, the drafts resend, the channel tools' guard, the registry's homeChannelResolver, stop()'s unbinding, releaseAgentRuntime's). The record still keeps a bare id, read as main reads a wake that names its channel without its server (channelNamed), or, where it names no one registered channel, as a home on no server ({ serverId: '' }), which a path reading the pair finds unregistered and one reading the name finds no channel or two for. main's dropContinuations goes into releaseAgentRuntime, so every way a live non-ephemeral agent ends drops a continuation of its turn, and beside the registry's end of an ephemeral run. Arrival carries main's channel ref and the branch's addressed; the addressed-elsewhere notice names channels with main's channelShown and keys its once-per-channel set by destinationKey. isAddressedMessage stays tag-only, with main's ambient marks. publishInputWithTurnLocus gives the locus with its server, and only to a call made inside the turn (callingTurnOf); outside it, a conversation fork's call takes its home, as its dispatched calls do, and anyone else's the registry's default. The tune_out handler keeps the branch's shape (serverId, scope, check, peek) and resolves through ChannelRegistry.resolveChannel, now resolveToolChannelEntry with the calling turn's own channels, check's peek included; its answers name the channel with nameShown. holdForProviderWait holds nothing for an agent a listener of the failure it reports ended (stopAgent), where main's report-before-hold (anima-research#774) meets the branch's stopAgent at a conversation agent's hold after its compile. handleSubconsciousTool gets main's own channels, then the branch's caller. - src/tune-out/coordinator.ts: main's shown() names beside the branch's reader helpers; deliver_summary, cancel_tuneout and speak_in_channel resolve as main's do, for the subconscious and a reader fork alike; cancel_tuneout keeps the branch's serverId and all-except fallback, and a name that picks no channel (one two servers share, or a turn's own channel ownChannelFor refuses) doesn't keep a call naming the scope from ending it; findByChannel goes, as on main. focusChannel() returns a ref. - src/mcpl/channel-registry.ts: markAccepted records no anchor (admission does), and handleIncoming's arrival count of removals, which main's guard there read, goes with it; noteDelivered takes the channel with its server and sets nothing for a channel its server no longer registers, read at admission where main's guard compared removal counts at arrival and acceptance (a message admitted after its server registers the channel again sets the anchor here). buildChannelContext is main's, per server, with the branch's sharedFallback; incomingFor reads the anchor itself, not the speech fallback. globalFallbackFor, sharedFallback, fallbackLocus and focusChannelResolver are on refs. - src/index.ts: main's DefaultErrorPolicy beside the branch's exports. - Tests: the branch's registry stubs take main's shapes; main's appendInTwoParts in batched-wake-locus's rig. Beyond the conflicts: the all-except announcement goes to the excepted channel on its server (publishForAgent refuses a shared bare id since anima-research#710), and tune_out's all-except answer names its channel with nameShown; the tune_out schema's serverId description says channelId takes serverId:channelId too; main's refusals for a tuned-out channel no name picks alone give the tune_out cancel by id and server, since this branch's tune_out takes a serverId; a stale comment naming conversationAgentHomes in channel-registry.ts names the registry; docs/dendrite.md says where a publish naming no channel goes outside the turn, and no longer lists ChannelRef as unbuilt. Test adaptations: main's tests that set conversationAgentHomes give the fork its home through the registry. main's tests written to the latest-inbound rule run under it (reply-server-routes: speech LEGACY; shared-channel-id's registry fixture: latestInboundLocus, and noteDelivered as admission records). Two reply-server-routes tests of an in-turn publish naming no channel now check the branch's rule, the turn's locus through its server. tune_out's shared-channel-id tests wait for the handler's answer, and its shared-id refusal offers serverId. all-except- unit's stub registry has nameShown; present-while-acting and deferred-window-only build routing state in main's shapes. main's marker and removed-server anchor tests record the anchor at admission (noteDelivered), where this branch records it. At this commit alone, the tests where a fork's home has to name its server fail (two servers' channels with one id, or a home the registry doesn't register at that moment); the pull request lists them. The next commit stores the home with its server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TL;DR: the workspace's
readshows a file's text as it is and says what it is, holds each page to what can reach its caller whole where the call states a limit, and on a store-backed mount settles its path against disk first, as a listing and an edit already do.grepmatches and shows a CRLF file's lines without the CR, asreadshows them. Both work the same way on a direct mount. It was stacked on #463, which merged on Oct 19, and the compare withmainshows this change alone.Problem
On
mainat9fdc3a65, driving the workspace module throughhandleToolCallas a model's tool call reaches it (Linux, Node 24.22.0):1\tone\rand2\ttwo\r, so a line copied from a read into an edit carried a CR.grepfortwo$found nothing in it.editrefuses such a file.maingained with feat(workspace): a read-only direct mount, reading disk as it is #564, reads the same way. A CRLF file's lines carry their CR, a 60,000-character line comes back as 60,083 characters of JSON with no note, andgrepfortwo$finds nothing.Changes
In
src/modules/workspace/, using theresultMaxCharsthat #463 added to a tool call, with three doc comments and three other comments outside it.What the text is (
text.ts'sfileText,read-page.ts'stextNotes). A line page shows a CRLF file's lines without the CR, and leaves out a BOM. A line whose own text ends in a CR shows its line end's CR too, as stored, so text copied from it matches in an edit. The page's note says when the file has CRLF ends or a BOM, counting a mix of CRLF and LF ends and the lines that show both CRs. It also says when the file isn't valid UTF-8: each invalid byte sequence shows as U+FFFD,editrefuses the file, andwritereplaces it whole. A character page still reads the stored text as it is, BOM and CRs included, and carries the UTF-8 note too.A long line is cut at 2,000 characters on a page for a call that carries a
resultMaxChars, never inside a surrogate pair, with a marker that gives theoffsetCharswhere its rest starts and thelimitCharsthat reads the rest alone:[… read the rest with offsetChars=2000 and limitChars=500]. That offset is counted in the stored text as character paging reads it, BOM and CRs included.A bound on one page (
linePage,characterPage). A line page is the longest page from its offset whose data, as JSON, fits the call'sresultMaxChars, measured page by page, and a character page stops before its data would pass it. A call that carries none is made in process and takes the data itself, so its page holds to its line limit alone, with every line whole, as onmain. Its note says where to continue: theoffsetafter its last line, or theoffsetCharsa character page also gives asnextOffsetChars. A character page that stops short of what it was asked for gives thelimitCharsleft of that too, so a reader that follows the notes ends where it asked to: "read the rest of what you asked for with offsetChars=X and limitChars=M". The read tool's description ismain's, whose "Keep limitChars when continuing" holds for every other page. Where even a page's first line would pass, that line is cut shorter, at the most the page has room for. Where a page's last line is cut, by the 2,000-character cut or to fit, its note names theoffsetCharsandlimitCharsof that line's rest, before the next line'soffsetif the file goes on, since reading on in order starts there. A character page moves by whole characters, so it never stops inside a surrogate pair, and a range whose end would split one takes the pair whole. A page always moves on, so where its own fields (the path and its notes) and the least it can show pass a small cap, it passes the cap, and a character page that then holds all it was asked for names no stop. At 1,000 characters, with the first line cut to its least, that takes a path of about 680 characters with no notes, or of 93 to 129 beside all three notes on a file's text and a conflict's. A note saying disk couldn't be checked can name a symlink's target, and then takes as much room as the target is long.The disk check (
bytesOf, on a store-backed mount). A read first settles its path against disk, under the three-way rule a listing uses, ashandleEditdoes:edit's answer says.MountConfig.backend's doc for a store-backed mount now listsreadamong what takes disk's changes in.The pass names the path, as a file tool names the one it acts on, so a path the mount passes over by design, such as a file beneath an ignored directory, is observed as a sync naming it would observe it (workspace: bare mount-wide sync reports totalSynced: 0 while per-path sync on the same file works — bulk and per-path disagree about reality in both directions #276), as
ensureSynced's pass is onmain.readBinarystill observes only a path the workspace doesn't hold, as does a deletion. A pass would drop a held binary file that disk changed, since the tree takes in no binary disk copy, andreadBinaryreads binary content. For a deletion, taking disk's change in first would let its push remove that change unseen, as for a write.ensureSynced's doc says both.Direct mounts (
bytesOf,GrepText). A direct mount'sreadandgrepshow a file's text as a store-backed mount's do.bytesOf, whichmainadded for direct mounts, now gives the read's notes with its bytes. On a store-backed mount it runs the disk check above in place ofensureSynced. On a direct mount it gives disk's bytes with no notes, since the mount keeps nothing else.handleReadsends both throughfileTextandlinePageorcharacterPage.GrepTextcarries each file's bytes in place of a decoded string, so a direct mount'sgrepmatchesfileText's lines too, withlineEndingsand the notes.grepsearchesfileText's lines, as read shows them, for matching as well as display: without a CRLF end's CR, except on a line whose own text ends in a CR, and without a BOM. It shows each line whole. Sofoo$finds a CRLF file's line, as it does under ripgrep's--crlf, and a match copies into an edit as a read's line does. A file with CRLF ends says so in its result'slineEndings(CRLF, ormixed). The cost, which the changelog names, is that a pattern written to find the CR of a CRLF line end no longer matches, except on such a line. Where a pattern names a CR, a literal one or an escape for one such as\ror\x0d, and a file it searched has CRLF ends, the answer'snotesays how many files searched have CRLF ends and names them, each asCRLFormixed, up to 20 and then how many more. It says that lines are matched as read shows them, which leaves out a CRLF end's CR unless the line's own text ends in a CR, and that a characterreadshows every CR in the workspace's copy. A pattern that names U+FEFF, over a file that starts with a BOM, gets a note that names the files that start with one and says a BOM isn't part of the first line. A conflict's disk version is named as that version. On a host with a direct mount, each note also names disk's file on a direct mount as where a characterreadshows the character, since a direct mount keeps no workspace copy.No tool's text changes.
read's andgrep's descriptions aremain's, byte for byte. What this PR adds is said in each result where it applies: a line page's notes, the cut line's marker, a stopped character page's note, andgrep'slineEndingsand its notes on a pattern that names a CR or U+FEFF.fileTextjoins the text helpers feat(workspace): port hands-mcp's edit planner, and add edit_many, several edits stored all or none #463 added totext.ts(numberedLine,safeCut,hasBom,jsonLength), which the read's cut line and its budget use.Six comments outside the module.
ToolCall.resultMaxChars(src/types/events.ts) says that for a call carrying none, the workspace'sreadholds its page to its line limit alone, whileeditandedit_manyanswers keep the 24,000 default.executeToolCall's doc (src/framework.ts) names its one caller in the framework, the operator's puppet, which puts the agent's inline cap on the call. It named SubagentModule, which doesn't call it. The doc's reason for agent origin, and the comment bycallTool's closure, now say its callers act for an agent: the puppet as the agent, and a host for the model it runs.executeToolCallFrom's comment oncode_executionnamed SubagentModule's ephemerals as its callers too, and now names the puppet and a host throughexecuteToolCall, and a module throughcallTool. Its comment onutils, andhandleUtilsToolCall's doc, named code_execution scripts and ephemerals as reaching utilities through it. They now name the same callers as the comment oncode_execution. The comment says that a call throughexecuteToolCallreaches what its agent'sallowedToolspermits, as the model's does, and an unidentified caller is unrestricted, asexecuteToolCall's doc says, while a module's call is trusted and reaches every utility. The doc says that a script's calls take the model-facing dispatcher.What each part is for
main. A model's request starts with its tool list, so a changed tool description makes every agent with the workspace's tools rewrite its whole cached context once, at its next request. Each thing this PR would have added to the descriptions is learned by calling, so the result says it, when the agent meets it.grep's notes on a pattern that names a CR or U+FEFF. With nothing in the description, a CR search that finds nothing in a CRLF file would read as a file without CRs, and a U+FEFF search on a file with a BOM, which onmainwas part of the first line, as a file without one. Each note comes only where that can mislead: a pattern that names its character, over a file that has it. A CR pattern over files without a CRLF end finds every CR, as onmain. A pattern names a CR the usual ways it's written for a pattern compiled without flags: as a literal one, as\r,\x0d,\x0D,\u000d,\u000D,\cM,\cmor\015, or as a backslash before a literal CR. An escaped backslash before one doesn't count, so\\rgets no note. U+FEFF is named as a literal one, as\uFEFFin either case, or as a backslash before a literal one.grepmatches whatreadshows, so\r$and^\uFEFF, which found those files onmain, find no line here. The note on such a pattern is where an agent looks next, so it names the files, and the results stay matches. Past 20 files it gives the count of the rest and says to narrow withpathorglob, so a large tree can't make the note large. A path is named once in each note, by the workspace's copy where that copy has the character. A conflict's disk version is named only where the workspace's copy lacks it, and then as that version, sincereaddoesn't show it and the reader would otherwise look for the character where it isn't.readandgrepare one tool for one agent, so how a file's text is shown doesn't depend on where the mount keeps it. A direct mount changes where the bytes come from, not how they're shown. The disk check is a store-backed mount's alone, since a direct mount reads disk as it is.grep's notes name disk's file on a host with a direct mount. A direct mount keeps no workspace copy, so a note saying that a characterreadshows a CR or a BOM in the workspace's copy would point at nothing there. The notes keep naming the workspace's copy, sincegrepalso searches a conflict's disk version, whichreaddoesn't show. The wording follows the host, as the tools' descriptions mention direct mounts only on a host that has one (anyRunsDirectanswers both), so a host without one keeps the notes as they were. On a host whose mounts are all direct, the second place is the one that applies. Choosing the wording by the mounts a call searched would be exact on a host with both kinds, but it would take more code for a mix that's rare.fileText, one reading of a file's lines for read and grep. Read, grep and the planner's matching then agree on what a line is, so text copied from either tool matches in an edit.editreads a CR before a line feed in its old text as part of the line end. So a stored linex\r\r\nshown asx\rwould lose its own CR when copied with the next line, and the copy would match elsewhere or nowhere. Showing such a line as stored,x\r\r, keeps a copy matching where it came from, and leaves every other line as it was.jsonLengthcounts whatJSON.stringifywrites, so a page stops before it would pass any cap the framework sends, wherever the page's own fields and least content fit, and a script's read keeps its long pages.maingives it.offsetCharsandlimitCharsthat read its rest.edit's excerpts, which nothing reads on from, keep their count. The note gives the rest's length aslimitCharstoo, since a character read givenoffsetCharsalone takes 2,000 code units and so reads on into lines the next page shows again. Where the rest runs past one character page, each page that stops for the result limit gives what's left of it aslimitChars, so reading on as the notes say ends at the line's end, not the file's. It costs one clause in each note, the page that ends the file included, since a page whosetoLineis the last line otherwise reads as complete. The first-line cut is now measured on the page itself, since the note depends on the cut, which takes a binary search of about eleven measurements of a one-line page. The rest's length is in the marker and in the note, so where one more character of cut drops a digit from it, the page can come out a character shorter, and the search alone can stop two characters short of the longest cut that fits. So the cuts up to four code units past the search's are measured too. The estimate it replaces reserved the longest note, so on a page that ends the file, which then carried none, the cut stopped short by that note's length. Leaving the rest to the line's own marker alone was the simpler choice, and an internal review's eleven findings on it showed it misleads.diskNow), not from the conflict's kind, since a recorded conflict keeps its kind after disk deletes the file. A sync there takes the deletion, so the note says so.readBinary, which would lose a held binary copy. Anarchid's third review measured its cost. A held path that an earlier pass vouched for reads in about 0.2 ms, against 0.05 ms onmain, flat from 500 to 4,000 files on the mount, and the first read of a path none vouched for writes one record of about 380 bytes, aslsdoes for such a file.storedOfin eight tests ofworkspace-three-way.test.ts. They read the store throughreadto see what a pass, a scan or a sync had or hadn't taken in, and a read now settles its own path, so it could take a change in itself. They read the store directly instead. Before, one of their assertions was that the read serves the store, which is the behaviour this PR changes.Tests
On Linux, with Node 24.22.0 and npm 11.19.1, from a clean checkout with
npm installas CI runs it.At this head,
b61d4e593, which hasmain9fdc3a65merged in:e6556ad29typechecks alone, its three merges of feat(workspace): port hands-mcp's edit planner, and add edit_many, several edits stored all or none #463 and its four merges ofmainincluded.workspace-editsandworkspace-edit-plan, pass 677 of 679, with 2 skipped: two tests inworkspace-directthat run only where the temporary directory folds case. On Node 20.20.2, the five pass 557 of 559, with 2 skipped.test/workspace-text.test.ts, sixteen intest/workspace-read-text.test.ts, fifteen intest/workspace-file-tools.test.ts, ten intest/workspace-three-way.test.tsand three intest/workspace-direct.test.ts, with the 2,000-line default's test now run at a script's limit:offsetCharsthat reads exactly the rest past a BOM and a CRLF, which the note names too where that line ends the page;offsetreading on;offsetCharscounted past lines 1 and 2, with a note whoseoffsetCharsandlimitCharsread exactly the rest of the line;resultMaxCharspaging by its line limit alone, with a 3,000-character line whole, and its character page giving 100,000 characters whole;\X0Dand\CM, and an escaped backslash, naming no CR;readBinaryserving a held binary file that disk changed as the workspace holds it;\r$and^\uFEFFover a tree of LF, CRLF, mixed and BOM files naming exactly the CRLF, mixed and BOM files, with a binary file and one over the size limit, each with CRLF ends and a BOM, listed as skipped and named in neither note, 23 CRLF files named as the first 20 and 3 more, and a glob narrowing them to 3;lineEndings, and a CR pattern's note where only that disk version has CRLF ends, a U+FEFF pattern's note where only a conflict's disk version starts with a BOM, and the give-up note on a character page and on a line page under a result limit.test/workspace-three-way.test.ts, which read a file the mount passes over by design that the store doesn't hold, now coverread's own pass, with the two held-file tests above. On the rebased branch beforeeec578167, each workspace: bare mount-wide sync reports totalSynced: 0 while per-path sync on the same file works — bulk and per-path disagree about reality in both directions #276 read answered that disk "could not be checked", because the path was "ignored by the mount" or "an editor's swap or backup file". With{ named: true }taken out, all five fail.bytesOf, whose store branch then callsensureSyncedas onmain, its fifteen read tests fail, and the other 427 tests inworkspace-file-toolsandworkspace-three-waypass.readBinary's test is among those that pass, since it pins whatreadBinarydid before this change.main's read and grep, and the rest of the merge as it is, the new read and grep tests inworkspace-directfail, and every other test in the five workspace files passes.maingained with feat(workspace): a direct mount can be written, and a head move away from its writes is told once #719, and on a 'chronicle' one, aneditwhoseoldStringis lines 2 and 3 asreadshows them, in a file with CRLF ends, one with mixed ends, one with a BOM, one with lines whose own text ends in a CR and one with a lone CR, replaces those two lines and leaves the same bytes on disk on both kinds, at this head and onmain.workspace-path-spellingpasses 56 of 61. Of the other 5, four run only where the disk tells case apart, and one only where a disk that folds case keeps ß apart from ss.With
main9fdc3a65, which this head holds (treeb35fc1e1dbf3), from a fresh install with no lockfile, as CI installs:tsc --noEmitand the build are clean.npm testpasses 6,597 of 6,641, with 44 skipped, in 219 s on a 12-CPU host at a load average between 8 and 19. CI's unforced step passes 132 of 132.--test-force-exit, passes: 6,597 tests pass and 44 are skipped, as under CI's step.mainhas since moved tof4bcb1d1(feat(retry): the framework is the one retry layer for its turns #728, the framework as its turns' one retry layer), which merges with this head with no conflict.Out of scope
read_image. The workspace serves it itself (handleReadImage), and the framework calls that handler rather thanreadBinary. It still reads a held image from the tree first, without settling, as before. This PR leaves images as they are.grep's answer isn't held toresultMaxChars. The page bound here isread's.notInWorkspace, unchanged here). The rarer interrupted kind doesn't fit that wording.Open pull requests this meets
Trial merges of each of the 55 other open pull requests, at their heads of 04:57Z on Oct 23, with
main9fdc3a65and with this head, which holds thatmain:README.md,src/agent.tsandsrc/framework.ts. Insrc/framework.ts, this change's comment oncode_executioninexecuteToolCallFromfalls inside one of its conflicts, and the four other comments it rewrites there merge cleanly.watchtool,write { append }#271 conflicts with both insrc/modules/workspace/index.ts,src/modules/workspace/sync.tsandsrc/modules/workspace/types.ts. Inindex.ts, this change's two import lines andensureSynced's doc fall inside its conflicts, and so do some of its lines inhandleReadand by grep's note. Its other lines there merge cleanly. Intypes.ts, this change's two lines inMountConfig.backend's doc fall inside the conflict feat(workspace): direct backend (default),watchtool,write { append }#271 already has withmainthere.main, if anywhere. fix(workspace): glob and grep's glob filter take a mount-prefixed pattern #401, feat(workspace): a mount's include names the files it holds, and is its boundary #536, feat(workspace): a read-only direct mount, reading disk as it is #564 and feat(code-execution)!: code_execution runs JavaScript in the isolate, and the Python runner goes #608, which this change met before, have merged, and feat(framework): gated operator body changes with journaled recovery (stacked on #250; merge after it) #255 has closed.Not verified
limitCharsover the description's "Keep limitChars when continuing".Reviewed in the house: an internal review of the change and one of its fix round, a third of everything since, the rebases' resolutions included, a fourth of that review's fix round, a fifth of the whole change at
60e17484a, whose findings723580051answers, a sixth of the two rounds since,60e17484a..bf379817f, whose findingsdcb7968c1answers, a seventh of what followed694421a15, that answer and the round that leaves the tools' text as onmain, whose findings456e5db1band8db582022answer, an eighth of that fix round, whose findings7d02af8d7andac6f05d8fanswer, a ninth of everything since, the merge ofmainincluded, whose findings2a2b02ead,2226bf1e5and677715d09answer, a tenth of that fix round, whose findings24b507d6b,766f9fa75anda40712ef3answer, and an eleventh of the tenth's fix round and the answer to Anarchid's fourth review, whose findings786cdc1b4anddeac5132canswer. The rebases onto #463's later heads changed this PR's code only where the two meet: the imports, the generated-file branch ofhandleToolCall,text.ts's header comment, where two tests sit, and, onto4da02df83, the start ofhandleReadand the doc comments ofensureSyncedandToolCall.resultMaxChars. Its merges of #463's8b039203c,39d0151d4ande6556ad29resolve nothing by hand. Its merge ofmainatb0b13d3c1,4c6171c0a, resolves four conflicts by hand, inreadBinary's doc, the start ofhandleReadand two places ingrep, so that a direct mount gets this change'sreadandgrep. Its merge ofmainat97ca7eba4,dd1dcd66a, resolves two conflicts by hand, both in comments:executeToolCallFrom's oncode_execution, andToolCall.resultMaxChars's doc, which keepsmain's account of a script's limit with this change's last sentence. Its merge ofmainat5834c2c4,107406a95, resolves three by hand:ensureSynced's doc, which keeps both sides' sentences,bytesOf, which keeps this change's, and two sets of tests at one place, which keep both. Its merge ofmainat9fdc3a65,b61d4e593, resolves one by hand, inMountConfig.backend's doc, which keeps this change's sentence for a store-backed mount andmain's for a direct one, which now takes writes;handleGrepismain's, with its mount-prefixed reading of the glob filter (#401), with this change's lines on top. Cuthbert-2453 read its code and description at244a0d360, each commit since then but the merges, and the last rebase's resolutions.cd7f83b83's doc comment and changelog sentence are his wording, taken after his read, and so is22becb53d's doc comment forlinePage, whose page he compared, on 40,000 generated files, with one found by measuring every page. The held-file tests ineec578167are cases he probed. He checked the fifth review's fix for lines whose own text ends in a CR by copying such lines from reads of eight files and editing them back, and each edit landed where its text came from. For the answer to Anarchid's second review,723580051..bf379817f, he ran itslinePagebeside a copy that tries every cut, on 106,640 generated cases, and the two gave the same page in each. He read the sixth review's fix round, and caught a wrong claim in one of its commit messages, reworded before the push. He read the round that leaves the tools' text as onmain, checked both tools' entries againstmain's line for line, and ran itslinePagebeside the copy that tries every cut again, on 106,640 cases, with the same page in each.813c60dd1makes grep's CR note take the escapes for a CR that he found it missed. He read the fix round after it too, and ran its pattern check beside regexpp, a parser for JavaScript's regular expressions, on 278,593 generated patterns, and the two agreed on every one. He read the eighth review's fix round as well. He read the merge ofmainand its resolution, comparingbytesOf's store branch with the inline disk check it replaced line for line, and pointed out that the notes named a workspace copy that a direct mount doesn't keep. He read both fix rounds since. He read the answer to Anarchid's fourth review, checking that no file is named twice or missed across mounts, globs and conflicts, and caught a comment that described the disk version's rule wrongly. He read that round's fix round, and the merge ofmainat97ca7eba4with what follows it, where he caught a stale figure for a script's limit in the changelog. He read the merge ofmainat5834c2c4and its text after F341, and suggested8b46eac2a, which hasreaddecide that its pass decided nothing withmain'sundecidedOf, as the other file tools do, and read it too. For this change, he was the first to runworkspace-path-spellingon Tabitha-2696's FUSE file system that folds case. Gordon-2414 read the merge ofmainat9fdc3a65against #401'sgrepandglob, and traced that the CRLF and BOM note names files in the order the results list them, on each kind of mount.Written by Abigail-2676, from part 2 of Ada-1017's design for the workspace's file tools, split from #463 with Cuthbert-2453. Phoebe-2419 pointed out that a recorded conflict keeps its kind after disk deletes the file, which is why the conflict note's ways out follow what disk holds now, and her measurement of what a changed tool description costs an agent's prompt cache is why this change leaves the tools' text as on
main.🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.