Skip to content

feat(workspace): read says what the text is, stops a page before it would spill, and settles its path against disk first - #465

Merged
nissa-seru merged 58 commits into
anima-research:mainfrom
nissa-seru:feat/workspace-read
Oct 23, 2026
Merged

nissa-seru merged 58 commits into
anima-research:mainfrom
nissa-seru:feat/workspace-read

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR: the workspace's read shows 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. grep matches and shows a CRLF file's lines without the CR, as read shows them. Both work the same way on a direct mount. It was stacked on #463, which merged on Oct 19, and the compare with main shows this change alone.

Problem

On main at 9fdc3a65, driving the workspace module through handleToolCall as a model's tool call reaches it (Linux, Node 24.22.0):

  • A CRLF file's lines carried their CR. "one\r\ntwo\r\n" read as 1\tone\r and 2\ttwo\r, so a line copied from a read into an edit carried a CR. grep for two$ found nothing in it.
  • A BOM read as part of the first line: U+FEFF before "first".
  • Bytes that aren't UTF-8 showed as U+FFFD with no note, though edit refuses such a file.
  • A page could be any size. 2,000 lines of an ordinary 3,000-line file came to 111,053 characters of JSON, and one 30,000-character line to 30,084. For a model's call the framework stores at most 24,000 characters whole by default. Past that it writes the whole result to a workspace file, where the agent has one, and stores only its start, cut mid-JSON.
  • A direct mount, which main gained 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, and grep for two$ finds nothing.
  • A read of a path the workspace held didn't look at disk. After another writer changed a file the workspace had read, a read showed the older copy: "v1", where disk held "v2 from another writer". Listings and edits already settle their paths first. A path the workspace didn't hold was observed, as it still is.

Changes

In src/modules/workspace/, using the resultMaxChars that #463 added to a tool call, with three doc comments and three other comments outside it.

  • What the text is (text.ts's fileText, read-page.ts's textNotes). 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, edit refuses the file, and write replaces 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 the offsetChars where its rest starts and the limitChars that 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's resultMaxChars, 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 on main. Its note says where to continue: the offset after its last line, or the offsetChars a character page also gives as nextOffsetChars. A character page that stops short of what it was asked for gives the limitChars left 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 is main'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 the offsetChars and limitChars of that line's rest, before the next line's offset if 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, as handleEdit does:

    • Where disk changed and the stored copy didn't, the change is taken in, and the note says the read shows that text. A mode changed on disk alone is taken in with no note, as an edit takes it.
    • Where disk's copy couldn't be checked, or the check gave up because the selected branch kept changing, the note says why and that the read shows the workspace's copy. A read of a file the workspace doesn't hold answers instead that the file isn't in the workspace and that disk couldn't be checked, with why. Where the check gave up, either answer also says to read again.
    • Where disk has no copy and nothing records whether it was deleted there, the note says so, and that the read shows the workspace's copy, as edit's answer says.
    • A file disk deleted answers that disk deleted it and the deletion was taken in. A file disk made binary or too large answers that it's on disk but not stored.
    • Where the stored copy is a draft and disk changed or deleted it too, the read shows the draft, and the note names the conflict and the ways to settle it, by what disk holds now: where disk has a copy, a sync keeps it and gives up the workspace's; where disk deleted the file, a sync takes the deletion and removes the workspace's copy. A forced materialize writes the workspace's copy to disk, and the note offers it only on a mount that isn't read-only. A read-only mount can still hold a copy disk doesn't, such as one a branch switch selected.

    MountConfig.backend's doc for a store-backed mount now lists read among 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 on main.

    readBinary still 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, and readBinary reads 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's read and grep show a file's text as a store-backed mount's do. bytesOf, which main added 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 of ensureSynced. On a direct mount it gives disk's bytes with no notes, since the mount keeps nothing else. handleRead sends both through fileText and linePage or characterPage. GrepText carries each file's bytes in place of a decoded string, so a direct mount's grep matches fileText's lines too, with lineEndings and the notes.

  • grep searches fileText'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. So foo$ 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's lineEndings (CRLF, or mixed). 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 \r or \x0d, and a file it searched has CRLF ends, the answer's note says how many files searched have CRLF ends and names them, each as CRLF or mixed, 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 character read shows 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 character read shows the character, since a direct mount keeps no workspace copy.

  • No tool's text changes. read's and grep's descriptions are main'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, and grep's lineEndings and its notes on a pattern that names a CR or U+FEFF.

  • fileText joins the text helpers feat(workspace): port hands-mcp's edit planner, and add edit_many, several edits stored all or none #463 added to text.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's read holds its page to its line limit alone, while edit and edit_many answers 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 by callTool'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 on code_execution named SubagentModule's ephemerals as its callers too, and now names the puppet and a host through executeToolCall, and a module through callTool. Its comment on utils, and handleUtilsToolCall's doc, named code_execution scripts and ephemerals as reaching utilities through it. They now name the same callers as the comment on code_execution. The comment says that a call through executeToolCall reaches what its agent's allowedTools permits, as the model's does, and an unidentified caller is unrestricted, as executeToolCall'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

  • The tools' text as on 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 on main was 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 on main. 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, \cm or \015, or as a backslash before a literal CR. An escaped backslash before one doesn't count, so \\r gets no note. U+FEFF is named as a literal one, as \uFEFF in either case, or as a backslash before a literal one.
  • The notes name the files. Whether a file has CRLF ends or a BOM is a fact about the file, not about a line, and grep matches what read shows, so \r$ and ^\uFEFF, which found those files on main, 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 with path or glob, 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, since read doesn't show it and the reader would otherwise look for the character where it isn't.
  • The same reading on a direct mount. read and grep are 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 character read shows a CR or a BOM in the workspace's copy would point at nothing there. The notes keep naming the workspace's copy, since grep also searches a conflict's disk version, which read doesn't show. The wording follows the host, as the tools' descriptions mention direct mounts only on a host that has one (anyRunsDirect answers 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.
  • A line whose own text ends in a CR shown as stored. edit reads a CR before a line feed in its old text as part of the line end. So a stored line x\r\r\n shown as x\r would 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.
  • The notes rather than a second representation. A read shows lines as an edit matches them, and says what it left out: CRs, a BOM, bytes that aren't UTF-8. An agent that writes the file whole can see what its own text would change.
  • A budget measured as the framework gives the result. jsonLength counts what JSON.stringify writes, 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.
  • No budget for a call made in process. The budget fits a page to what its receiver keeps whole. A call made in process states none and takes the data itself, as connectome-host's file viewer does, with a byte cap of its own and no way to page, so its page is what main gives it.
  • One rule for every call that states a limit. A script's line pages cut long lines and leave out CRs and a BOM, as a model's do. A character page gives the stored text exactly, and each cut line's marker names the offsetChars and limitChars that read its rest.
  • The longest page that fits, measured page by page. A page's note changes with how it ends: the page that ends the file has no stop note, the one at the line limit a shorter one, and one whose last line is cut a longer one. So each candidate page is measured as it would go out, and the longest that fits is kept. The estimate this replaced stopped at the first page that didn't fit, so it cut a page short where a longer one fit. The search stops once a page passes the limit with only the notes every page carries, since a longer page shows more.
  • The first line cut shorter, not refused. Every page moves on, and the note names where the cut line's rest starts.
  • A cut last line named in the note. Reading on in order starts with that line's rest, so a note that named only the next line led a reader past it. An earlier cut line is named by its marker alone, since the page already shows the lines after it, and the marker gives both numbers that read its rest. The marker gives them in place of "N more chars", so the rest's length appears in it once, and the first-line cut's search keeps its bound. edit's excerpts, which nothing reads on from, keep their count. The note gives the rest's length as limitChars too, since a character read given offsetChars alone 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 as limitChars, 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 whose toLine is 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.
  • A conflict's way out read from what disk holds now (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.
  • The disk check, only for reads. A read of a path the workspace holds takes disk's change into the branch, as a listing already does; before, only a path it didn't hold was observed. A write and a deletion still don't, since each would then overwrite disk's change unseen, and neither does 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 on main, 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, as ls does for such a file.
  • storedOf in eight tests of workspace-three-way.test.ts. They read the store through read to 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 install as CI runs it.

At this head, b61d4e593, which has main 9fdc3a65 merged in:

  • Each of its 58 commits since feat(workspace): port hands-mcp's edit planner, and add edit_many, several edits stored all or none #463's head e6556ad29 typechecks 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 of main included.
  • The five workspace test files this change touches, with feat(workspace): port hands-mcp's edit planner, and add edit_many, several edits stored all or none #463's workspace-edits and workspace-edit-plan, pass 677 of 679, with 2 skipped: two tests in workspace-direct that run only where the temporary directory folds case. On Node 20.20.2, the five pass 557 of 559, with 2 skipped.
  • New tests: one in test/workspace-text.test.ts, sixteen in test/workspace-read-text.test.ts, fifteen in test/workspace-file-tools.test.ts, ten in test/workspace-three-way.test.ts and three in test/workspace-direct.test.ts, with the 2,000-line default's test now run at a script's limit:
    • a CRLF file's lines without the CR, mixed ends counted, one line whose own text ends in a CR named in the singular, and a CR that doesn't end a line kept;
    • a BOM noted and not shown, and a file of only a BOM read as an empty file with one;
    • invalid UTF-8 noted on both kinds of page;
    • a long line cut before an emoji's surrogate pair, at a model's limit and at a script's, with an offsetChars that reads exactly the rest past a BOM and a CRLF, which the note names too where that line ends the page;
    • line pages within 24,000, 5,000 and 1,000 characters, one more line passing each, and the note's offset reading on;
    • a page that ends the file, a page at the line limit, and a page a line past a cut line, each fitting where the page a line shorter, with its stop note, doesn't;
    • a CRLF file's line pages and an invalid-UTF-8 file's character pages held to the limit with their notes, and a generated file read under a result limit;
    • a first line cut at the most that fits within 1,000, its note naming the rest, on a page that goes on and on a one-line file's page, which ends the file, and a one-line file whose whole page fits shown whole, where a cut one character shorter wouldn't fit;
    • a first line cut at the longest cut that fits at each limit from 700 to 760, across the cut where the rest's length loses a digit, and kept within 2,000 characters at a limit where a cut past it would fit;
    • a long line cut to fit as the first line of a page read from line 3, numbered as line 3, its rest's offsetChars counted past lines 1 and 2, with a note whose offsetChars and limitChars read exactly the rest of the line;
    • a page whose last line is cut, stopped by its limit, by the result limit and by the end of the file, naming that line's rest and its length, and a page that ends the file on a whole line with no note;
    • a cut line's 5,000-character rest read at a limit of 1,000 through several character pages, following only their notes, and ending exactly where the line does;
    • a call that carries no resultMaxChars paging by its line limit alone, with a 3,000-character line whole, and its character page giving 100,000 characters whole;
    • character pages of escape-heavy text within 24,000 and 1,000 that put the whole text back when each page's note is followed, and pages that never stop inside a pair and stay within each of four neighbouring limits;
    • grep's CRLF, mixed, LF and BOM files, and a pattern that names a CR finding nothing in a CRLF file, with a note saying why, for each spelling of a CR, over a directory where an LF file comes on each side of the CRLF one, and not where a glob passes over it and finds the file it takes; a CR inside a line found with no note, and identity escapes such as \X0D and \CM, and an escaped backslash, naming no CR;
    • a pattern that names U+FEFF finding nothing in a file that starts with a BOM, with a note saying why, over a directory where a file without one comes on each side of it too, and a U+FEFF inside a line found with no note, neither note given for the other's file, and a pattern that names both getting both notes;
    • fileText's lines, starts and counts for CRLF, LF, mixed ends, a lone CR, a line whose own text ends in a CR, a line ended by a bare LF with a CR before its last character, a BOM, an empty text and invalid UTF-8;
    • the disk check's answers (disk's newer text, a deletion, a file disk made binary, a conflict, a draft disk deleted, a conflict recorded as both-changed before disk deleted the file, a draft whose disk copy turned binary, an unchecked copy, a missing copy with no record), each on a line page, and the taken-in note on a character page too;
    • a sync and a forced materialize each doing what the deletion conflict's note says, and a sync doing what the note says where disk's copy turned binary: the draft goes, and disk keeps its bytes;
    • a read of a held file, and of a file only disk has, whose disk check gives up because the selected branch switches at every disk read, each saying to read again, and a read after the mount's root is gone, which doesn't;
    • readBinary serving a held binary file that disk changed as the workspace holds it;
    • a read of a held file beneath an ignored directory, and of one ignored by its own name, taking disk's change in first and then disk's deletion;
    • two lines copied from a read of a CRLF file with a BOM, and a line copied from a grep match, each matching in an edit, with the file keeping its CRLF ends and its BOM;
    • two lines whose own text ends in a CR, copied from a read, and such a line copied from a grep match, each editing the lines it came from;
    • a limit of 50, too small for a page's own fields, still moving a character page on, by both code units of a surrogate pair, and showing a first line's marker, and a character page that then holds all it was asked for with no stop note;
    • a read of a held file whose mode alone changed on disk, with no note;
    • a read of a conflict on a read-only mount, whose note names only the sync;
    • a direct mount's read of a CRLF file, a file 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, as line and character pages at result limits of 24,000 and 1,000 and with none, and its grep over CRLF, mixed, LF and BOM files with the notes on a pattern for a CR or U+FEFF, each giving a store-backed mount's answers but for the notes' mention of disk's file on a host with a direct mount, and each page with a result limit a page that fits it;
    • grep's notes on a store-backed mount beside a direct one naming disk's file too;
    • grep's notes for \r$ and ^\uFEFF over 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;
    • a conflict whose workspace copy and disk version both have CRLF ends and a BOM 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 named in each note for the version that has it; one whose disk version alone has CRLF ends named as that version; and a recorded disk version, once disk has changed again, named as the recorded one in each note;
    • a conflict's disk version with CRLF ends found by grep, with its 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.
  • fix(workspace): the watcher and a sync agree on what a mount passes over, and an outer mount leaves a nested mount's directory alone #460's three 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 tests in test/workspace-three-way.test.ts, which read a file the mount passes over by design that the store doesn't hold, now cover read's own pass, with the two held-file tests above. On the rebased branch before eec578167, 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.
  • Red-first: with the disk check undone in bytesOf, whose store branch then calls ensureSynced as on main, its fifteen read tests fail, and the other 427 tests in workspace-file-tools and workspace-three-way pass. readBinary's test is among those that pass, since it pins what readBinary did before this change.
  • With a direct mount left on main's read and grep, and the rest of the merge as it is, the new read and grep tests in workspace-direct fail, and every other test in the five workspace files passes.
  • Removals: each of 99 deliberate removals or breakages, made one at a time, fails a test that names it.
  • On a direct mount that takes writes, which main gained 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, an edit whose oldString is lines 2 and 3 as read shows 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 on main.
  • On a FUSE file system that folds case, on Linux, workspace-path-spelling passes 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 main 9fdc3a65, which this head holds (tree b35fc1e1dbf3), from a fresh install with no lockfile, as CI installs:

  • tsc --noEmit and the build are clean.
  • CI's npm test passes 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.
  • Each of the 286 test files, run on its own without --test-force-exit, passes: 6,597 tests pass and 44 are skipped, as under CI's step.
  • On Node 20.20.2, the five workspace test files this change touches pass 557 of 559, with 2 skipped.
  • main has since moved to f4bcb1d1 (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 than readBinary. 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 to resultMaxChars. The page bound here is read's.
  • A conflict on a path the workspace doesn't hold is still worded as the workspace having deleted the file and disk having changed it since (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 main 9fdc3a65 and with this head, which holds that main:

Not verified

  • Against a live provider: a model's actual reading of the notes and the cut-line marker, and whether it takes a stopped character page's limitChars over the description's "Keep limitChars when continuing".
  • The rest of the suite on a case-insensitive disk.

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 findings 723580051 answers, a sixth of the two rounds since, 60e17484a..bf379817f, whose findings dcb7968c1 answers, a seventh of what followed 694421a15, that answer and the round that leaves the tools' text as on main, whose findings 456e5db1b and 8db582022 answer, an eighth of that fix round, whose findings 7d02af8d7 and ac6f05d8f answer, a ninth of everything since, the merge of main included, whose findings 2a2b02ead, 2226bf1e5 and 677715d09 answer, a tenth of that fix round, whose findings 24b507d6b, 766f9fa75 and a40712ef3 answer, and an eleventh of the tenth's fix round and the answer to Anarchid's fourth review, whose findings 786cdc1b4 and deac5132c answer. The rebases onto #463's later heads changed this PR's code only where the two meet: the imports, the generated-file branch of handleToolCall, text.ts's header comment, where two tests sit, and, onto 4da02df83, the start of handleRead and the doc comments of ensureSynced and ToolCall.resultMaxChars. Its merges of #463's 8b039203c, 39d0151d4 and e6556ad29 resolve nothing by hand. Its merge of main at b0b13d3c1, 4c6171c0a, resolves four conflicts by hand, in readBinary's doc, the start of handleRead and two places in grep, so that a direct mount gets this change's read and grep. Its merge of main at 97ca7eba4, dd1dcd66a, resolves two conflicts by hand, both in comments: executeToolCallFrom's on code_execution, and ToolCall.resultMaxChars's doc, which keeps main's account of a script's limit with this change's last sentence. Its merge of main at 5834c2c4, 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 of main at 9fdc3a65, b61d4e593, resolves one by hand, in MountConfig.backend's doc, which keeps this change's sentence for a store-backed mount and main's for a direct one, which now takes writes; handleGrep is main'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 at 244a0d360, 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 is 22becb53d's doc comment for linePage, whose page he compared, on 40,000 generated files, with one found by measuring every page. The held-file tests in eec578167 are 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 its linePage beside 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 on main, checked both tools' entries against main's line for line, and ran its linePage beside the copy that tries every cut again, on 106,640 cases, with the same page in each. 813c60dd1 makes 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 of main and its resolution, comparing bytesOf'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 of main at 97ca7eba4 with what follows it, where he caught a stale figure for a script's limit in the changelog. He read the merge of main at 5834c2c4 and its text after F341, and suggested 8b46eac2a, which has read decide that its pass decided nothing with main's undecidedOf, as the other file tools do, and read it too. For this change, he was the first to run workspace-path-spelling on Tabitha-2696's FUSE file system that folds case. Gordon-2414 read the merge of main at 9fdc3a65 against #401's grep and glob, 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-seru account.

@greptile-apps

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[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

  1. P2 Clash checks repeat comparisons ▶
  2. P2 Receipts repeatedly scan every change ▶
  3. P2 Unfinished disk checks stay hidden ▶
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.

Summary

This PR gives tool calls a result budget and uses it to build bounded workspace reads and edit answers.

  • Read and grep show the same clean lines from text files.
  • Read pages fit the result size their callers can receive.
  • Reads check held files against disk before showing their text.
  • Workspace edits can plan exact replacements together, including across files.

Reviews (1) · Last reviewed commit: "docs(workspace): grep follows ripgrep's ..." · Reviewed by Greptile

Comment thread src/modules/workspace/edit-plan.ts Outdated
Comment on lines +379 to +384
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/modules/workspace/edit-text.ts Outdated
Comment on lines +329 to +334
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/modules/workspace/index.ts Outdated
Comment on lines 2221 to 2224
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…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>

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: ensureSynced calls passUnlocked(..., { named: true }) (main:src/modules/workspace/index.ts:2179), and so does settleBeforeMutation (: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 replaces ensureSynced in handleRead with a direct this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }) (src/modules/workspace/index.ts:2224), which has no named. The textual conflict is only at parsePath/parseFilePath and the doc comment, so the missing option doesn't show up as a conflict.
  • Reproduced: I merged this head into main and resolved the handleRead conflict the way "Open pull requests this meets" proposes (this change's body with #460's parseFilePath call). #460's reads, 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 the read call. 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 of workspace-file-tools, workspace-read-text, workspace-text, workspace-edits and workspace-edit-plan pass on that merged tree.
  • Failure: an agent reads work/build.log (ignored by the mount, not held). main takes 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 }) in handleRead, keep parseFilePath(input.path) so the other-mount refusal comes first, and keep { named: true } in ensureSynced. #460's #276 tests in workspace-three-way.test.ts are 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 sets resultMaxChars on 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 (main ac2e8bf) has two such callers. sendWorkspaceFileRead (src/modules/web-ui-module.ts:2424-2445) calls handleToolCall({ name: 'read', input: { path, limit: 5000 } }) with its own 256 KB byte cap and no resultMaxChars. The headless request-workspace-file handler (src/headless.ts:402-430, which the fleet module uses for child processes) does the same. The request message carries only path and scope (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) returned toLine: 543 at this head. Before, it returned all 3,780 lines, under the viewer's 256 KB cap. The viewer reports truncated: true and 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 resultMaxChars means 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 when resultMaxChars is 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 pass resultMaxChars, with connectome-host's two callers changed to pass it before the release reaches them. (b) keeps the contract #463 already documents on ToolCall.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() picks to === 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 is null whether or not rest is set. Reproduced: a one-line file of 1,500 " read at a limit of 1,000 returned 999 characters, totalLines: 1, toLine: 1, no note, and a cut line whose marker names offsetChars=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 offsetChars where 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 checks toLine < totalLines, as connectome-host's viewer does for truncated, won't.
  • Sol also said that with limit: 1 a budget-cut first line leads a reader past its rest. Rejected: the note there reads Truncated 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 !== null at the end of the file, emit Line N is cut: read its rest with offsetChars=K. The first-line search already measures final(), 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 at MAX_LINE_CHARS (src/modules/workspace/read-page.ts:60), whatever maxChars is. The changelog's sentence about scripts ("its pages still stop at 2,000 lines or limitChars, 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 through read now gets content with 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 to maxChars), or add to the scripts sentence that lines are still cut at 2,000 characters and that offsetChars/limitChars is 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 page path, totalChars, offsetChars, nextOffsetChars, content with an optional note added. The defaults (2,000 lines, 2,000 characters), the "not both" error, the surrogate-pair offsetChars error 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\n ends; 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 and limit: 3) and by character page. Results:

    • No page passed its limit.
    • Every line was covered exactly once.
    • Each continuation offset was toLine + 1.
    • Each cut line's offsetChars and "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: starts index 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. readBinary and deletion keep ensureSynced. A check that gave up (the branch kept changing) is now reported, which answers Greptile's comment.

  • grep: it shares fileText with read, so line numbers and text agree. lineEndings is CRLF or mixed exactly when the counts say so. The pattern is built with no flags, so test() carries no lastIndex from line to line.

  • Tests at this head: the four touched workspace test files plus #463's workspace-edits and workspace-edit-plan pass 334 of 334 on Node 22, as the description reports for Node 24. tool-call-result-max-chars passes 5 of 5, the full npm test passes 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".

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
… 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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

Addressed R1-2, R1-3 and R1-4 in 77b88c4e4 and 795f422da, on #463's d92344b9e. R1-1 isn't in this push: this branch is on d92344b9e, which doesn't have #460. The description's bullet on it says how that merge resolves.

R1-2. A call that carries no resultMaxChars now pages by its line limit alone, with every line whole, as on main (src/modules/workspace/read-page.ts:77). Its character page gives limitChars whole (:174). Read with limit: 5000 and no resultMaxChars, as connectome-host's file viewer reads it, this head's src/modules/workspace/index.ts comes back whole, all 3,783 lines. The ToolCall.resultMaxChars doc says so for read, while edit and edit_many answers keep 24,000, and the changelog names in-process callers. The test at test/workspace-read-text.test.ts:190 reads 2,500 lines of a 3,000-line CRLF file with its 3,000-character line whole, and a 100,000-character character page whole.

R1-3. The page that ends the file now names a cut last line's rest (src/modules/workspace/read-page.ts:102). Your case, one line of 1,500 " read as work/quotes.txt at a limit of 1,000, now returns "note": "Line 1 is cut: read its rest with offsetChars=401." within the 1,000 characters. A file whose last line is 3,000 characters, read at 24,000, names offsetChars=2006. The tests at test/workspace-read-text.test.ts:152 and :184 pin both cases, and :187 pins a page that ends on a whole line with no note.

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 offsetChars that reads its rest.

At this head, 795f422da, on Linux with Node 24.22.0, tsc --noEmit passes at each commit. The workspace test files this change touches, with workspace-edits and workspace-edit-plan, pass 334 of 334. On Node 20.20.2, this change's four pass 279 of 279.

Written by Abigail-2676.

— Tabitha-2696

Abigail-2676 and others added 8 commits October 18, 2026 15:19
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…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>
Abigail-2676 and others added 2 commits October 18, 2026 16:11
…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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

Addressed R1-1 in eec578167. This push rebases the branch onto #463's 4da02df83, which merges main in, #460 included. The commits that answered R1-2 to R1-4 are now 83aae2881 and cd7f83b83, with the same changes.

R1-1. read's own pass over its path now names the path with { named: true }, as ensureSynced's pass has since #460 (src/modules/workspace/index.ts:2703). A read of a file the mount passes over by design now observes it as a sync naming it would, whether the store holds the file or not.

Taking { named: true } out again fails all five.

At this head, fd7a44b49, on Linux with Node 24.22.0, tsc --noEmit passes at each commit. The workspace test files this change touches, with workspace-edits and workspace-edit-plan, pass 492 of 492, and on Node 20.20.2 this change's four pass 429 of 429. Merged with main at 19fc24682, CI's npm test passes 4,184 of 4,201, with 17 skipped.

Written by Abigail-2676.

— Tabitha-2696

Abigail-2676 and others added 9 commits October 19, 2026 22:23
…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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid R3-1 is addressed in 4c6171c0a, which merges main and gives a direct mount this change's read and grep, and in the tests that follow it. R3-2's description stays as main has it, for the reason below. The head is now a40712ef3, and we're asking you to re-read it, to settle R3's request for changes.

R3-1. 4c6171c0a merges main at b0b13d3c1 and resolves its four conflicts as you proposed:

  • bytesOf (src/modules/workspace/index.ts:3738) gives the read's notes with its bytes. On a store-backed mount it runs the disk check in place of ensureSynced. On a direct mount it gives bytesOnDisk's bytes with no notes. Its one caller, handleRead, sends both through fileText and linePage or characterPage, and readBinary keeps its own rule.
  • GrepText carries each file's bytes (:5593), so the search gets fileText's lines from textsFromStore and textsFromDisk alike (:4679), with lineEndings and the notes on a pattern for a CR or U+FEFF.
  • Two tests in test/workspace-direct.test.ts (e62ea5596) read and grep the same files on a direct mount and on a store-backed one, and assert the same answers. The files are a CRLF file, one 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 is read as a line page and as a character page, at result limits of 24,000 and 1,000 and with none, and each page with a limit is checked against it (2226bf1e5). With a direct mount left on main's read and grep, and the rest of the merge as it is, these two tests fail, and every other test in the five workspace files this change touches passes.
  • A direct mount keeps no workspace copy, so on a host with one, grep's notes now say that a character read shows the CR or BOM in the workspace's copy, or in disk's file on a direct mount (2a2b02ead). They keep naming the workspace's copy because grep also searches a conflict's disk version, which read doesn't show. The wording follows the host, as the tools' descriptions mention direct mounts only on a host that has one (24b507d6b). A host without a direct mount keeps the notes as they were, and a test greps a store-backed mount beside a direct one (a40712ef3).
  • MountConfig.backend's doc for a store-backed mount now names read among what takes disk's changes in (677715d09, narrowed to read in 766f9fa75), and the changelog has a line for direct mounts (ac535df13).
  • At a40712ef3, at a result limit of 24,000, a direct mount reads one\r\ntwo\r\n as 1\tone and 2\ttwo, with the CRLF note. Its page of the 60,000-character line is 2,226 characters of JSON, with the cut marker and a note naming the rest. grep for two$ finds line 2. On b0b13d3c1 the same mount gave one\r and two\r, 60,083 characters with no note, and no match.

R3-2. We've kept main's description. On our copy of R2-1's case at a40712ef3, following the notes reads the line's rest in pages ending at 25,743, 49,480 and 60,005, the line's end. Keeping limitChars=58000, as the description says, the pages end at 25,743, 49,480, 71,171 and 76,896, the file's end. The next release does change the tool list anyway, for edit's description, as you show. The clause stays out for another reason. A tool description is sent with every request of every agent that has the workspace's tools. This clause would serve a stop that's rare, costs one extra page of tokens and shows no wrong text, and the stop's own note already gives the numbers that avoid it.

At a40712ef3, on Linux with Node 24.22.0, tsc --noEmit passes at each commit. The five workspace test files this change touches, with workspace-edits and workspace-edit-plan, pass 661 of 663, with 2 skipped that run only where the temporary directory folds case, and on Node 20.20.2 the five pass 541 of 543. With the disk check undone in bytesOf, its fifteen read tests fail, and each of 89 deliberate removals fails a test that names it. Merged with main at 073f8be17, CI's npm test passes 5,759 of 5,803, with 44 skipped.

The description is updated to match.

Written by Abigail-2676.

— Tabitha-2696

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. grep now matches fileText'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, searchedCrlf and searchedBom (index.ts:4644, leftOutNote at :5941). It says that "a file searched has CRLF line ends" and names none.
    • lineEndings appears 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.
  • Measured. We ran the same tree through main 2099c40a and through the trial merge. The tree had 4 small files (LF, CRLF, mixed, BOM) and 50 ordinary source files, and every call had resultMaxChars: 24000.

    pattern main this 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 learn lineEndings per 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 found from a shell script checked out with CRLF ends. grep is the agent's search tool, and on main one 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:

    1. Collect the CRLF files and the BOM files searched, and have leftOutNote name them, bounded, for example the first 20 and then "and N more".
    2. When the pattern names a CR or U+FEFF (namedLeftOut), push a zero-match result carrying lineEndings (and a bom: true) for each such file. grep '\r$' would then list the files as it did on main, and totalMatches stays 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.

Verified fine

  • Builds and tests on today's main. On the trial merge with 2099c40a (Node 22.21.0) the build is clean. The full npm test runs 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, treeGet and getBlob all run inside withMount (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 with writeBinary while its disk copy was refused (inChronicle: true). It stays a draft, and read serves it twice with nothing lost. If a copy that did land on disk is later deleted there, the next read takes that deletion in and answers "File not found…". On main the read served the stored copy. This is the PR's stated rule, and an ls of the directory does the same on main. 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, at src/headless.ts:413 and src/modules/web-ui-module.ts:2524. Both read with limit: 5000 and no resultMaxChars, so they get whole lines held to the line limit. Their reads now settle against disk, as their tree view's ls already did. connectome-host never calls executeToolCall.
  • grep's cost. Sol pointed out that fileText validates 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 registerGeneratedTextFile is 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 sets resultMaxChars from the inline cap. The utils and code_execution comments match the dispatch code.
  • Tool text. read's and grep's descriptions are main's, byte for byte. The only change in getTools is the anyRunsDirect() 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.

Abigail-2676 and others added 6 commits October 21, 2026 11:51
… 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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid R4-1 is addressed in 5f89cc82b, with your first fix. grep's note on a pattern for a CR or U+FEFF now names the files searched that have CRLF ends or a BOM. The head is now 754b17fa0, which also merges main at 97ca7eba4 (#​608). We're asking you to re-read it, to settle R4's request for changes.

R4-1. For each file it searches, grep keeps the path of one with CRLF ends, as CRLF or mixed, and of one that starts with a BOM (src/modules/workspace/index.ts:4652). leftOutNote (:5970) gives the count and names the files:

  • On a 'chronicle' mount holding lf.txt, win.bat (CRLF), mixed.cfg (mixed ends), bom.txt and five LF source files, at a result limit of 24,000, main at 97ca7eba4 answers \r$ with mixed.cfg (2 lines) and win.bat (2 lines), and ^\uFEFF with bom.txt (1 line). At 754b17fa0, \r$ gives no results and the note "The pattern names a CR, and 2 files searched have CRLF line ends: work/mixed.cfg (mixed), work/win.bat (CRLF)." ^\uFEFF gives "The pattern names U+FEFF, and 1 file searched starts with a BOM: work/bom.txt." Each note goes on to say why the lines didn't match and where a character read shows the character.
  • The count is exact. Past 20 files the note names the first 20, in the order grep searched them, then "and N more; narrow with path or glob" (NOTE_FILES_MAX, :5939).
  • A conflict's disk version is named as that version, such as "work/a.txt (CRLF in its conflicting-disk version)", since read doesn't show it. It's named only in a note whose character the workspace's copy of that path lacks, so a path is named once in each note.
  • The results stay matches, and the tools' descriptions stay main's. We took your first fix over the second because a result with no matches would be new to callers that read results as matches.
  • The test you asked for is in test/workspace-file-tools.test.ts:711. It greps \r$ and ^\uFEFF over a tree of LF, CRLF, mixed and BOM files, with a binary file and one over the size limit that have both, and asserts exactly which files each note names and that the two skipped files are in neither. It also checks 23 CRLF files named as the first 20 and 3 more, and a glob narrowing them to 3. Three tests in test/workspace-three-way.test.ts (:6781, :6796, :6860) cover conflicts: both versions with CRLF ends and a BOM, a BOM in the workspace's copy over CRLF ends on disk, and a recorded disk version once disk has changed again (786cdc1b4).
  • The changelog's grep entry now says the notes name the files, and that a grep for \r or ^\uFEFF still finds which of the files it searches have CRLF ends or a BOM (1142d0813, deac5132c).

R3-2. Nothing changes for it here. As you say, it's the maintainers' to decide.

main at 97ca7eba4. dd1dcd66a merges it. Its two conflicts were comments both sides rewrote: executeToolCallFrom's on code_execution, and ToolCall.resultMaxChars's doc, which keeps #​608's account of a script's limit with this change's last sentence. A script's call now carries the longest result that reaches it whole, 7,456,527 characters at the default memory, so 754b17fa0 updates the workspace's comments on a read's limit, the changelog's figure for a script's call, and the two read tests that stand for a script's call.

At 754b17fa0, on Linux with Node 24.22.0, tsc --noEmit passes at each commit. The five workspace test files this change touches, with workspace-edits and workspace-edit-plan, pass 665 of 667, with 2 skipped that run only where the temporary directory folds case, and on Node 20.20.2 the five pass 545 of 547. With the disk check undone in bytesOf, its fifteen read tests fail, and each of 99 deliberate removals fails a test that names it. With main at 97ca7eba4, which this head holds, CI's npm test passes 5,968 of 6,012, with 44 skipped.

The description is updated to match.

Written by Abigail-2676.

— Tabitha-2696

Abigail-2676 and others added 3 commits October 22, 2026 10:27
…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>
@nissa-seru
nissa-seru merged commit 993f7a0 into anima-research:main Oct 23, 2026
12 checks passed
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@Anarchid This pull request merged into main at 20:15Z on Oct 23, as 993f7a0, with its head at b61d4e593. Your last review was R4, at a40712e. Below is what answered it, and what the branch gained after our R4 reply.

R4-1 is answered in 5f89cc82b, as our Oct 21 reply here sets out. When a pattern names a CR or U+FEFF, grep's note names the files searched that have CRLF ends or a BOM: the first 20, then a count. At the merged head, on a mount holding LF, CRLF, mixed and BOM files, \r$ finds no lines, and its note names work/mixed.cfg (mixed) and work/win.bat (CRLF). ^\uFEFF finds no lines, and its note names work/bom.txt. On main at 9fdc3a65, the same greps find lines in those files.

R3-2 stands as it was. The tools' descriptions are main's.

After our R4 reply, whose head was 754b17fa0, the branch merged main twice more and added two commits of its own:

The description lists the checks run at the merged head.

Written by Abigail-2676.

— Tabitha-2696

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 23, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 24, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants