Skip to content

feat(workspace): port hands-mcp's edit planner, and add edit_many, several edits stored all or none - #463

Merged
nissa-seru merged 31 commits into
anima-research:mainfrom
nissa-seru:feat/workspace-edit-planner
Oct 19, 2026
Merged

nissa-seru merged 31 commits into
anima-research:mainfrom
nissa-seru:feat/workspace-edit-planner

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

The workspace's edit now matches and replaces through a planner ported from hands-mcp, and a new edit_many tool applies several edits, in one file or across files of one mount, and stores every edit or none. A miss shows the closest region of the file, a count that differs shows the lines that matched, CRLF line ends and a BOM are kept, and a success shows the changed region. A tool call now carries resultMaxChars, how long its result may be and still reach its caller whole, and both tools hold their refusals and receipts to it, within the bounds given under Budgets, so that a closest region or a long receipt doesn't push an answer into a spilled file.

It was stacked on #456, the workspace's file-tool fixes, which merged at 23:34:58Z on Oct 17.

Problem

edit counted matches with String.split and replaced with String.replace or String.replaceAll (handleEdit in src/modules/workspace/index.ts, at main b96e918b).

  • A miss answered "String not found in …" and nothing more, so an agent had to read the file again to see why.
  • A count other than one answered "String found N times in …" without saying where.
  • An oldString with LF line ends missed in a file with CRLF ones, and a CRLF oldString with an LF newString mixed the file's line ends.
  • There was no way to make several edits together. A change across files went as several calls, and a failure partway left some files changed and others not.
  • A success answered with the path and the new size, so the agent couldn't see what changed without another read.

hands-mcp's Edit and EditMany tools solve each of these for files on disk, with a planner (src/match.ts) that does no I/O. The workspace edits a stored copy of each file, so the same planner can plan those edits.

Changes

  • ToolCall.resultMaxChars (src/types/events.ts). The framework sets it where a module call gets callerAgentName.
    • A model's call, a utility run through utils and an operator's puppet call carry the agent's effective inline cap (resolveToolResultInlineCap).
    • A call from a running code_execution script carries the script's limit, feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262's AgentFramework.SCRIPT_RESULT_MAX_CHARS (5,000,000), and so does a utility the script runs.
    • A call for an agent the framework doesn't hold carries the default.
    • A call made in process (ModuleContext.callTool, executeToolCall) carries what its caller put on it, and a utility it runs through utils carries what that call carried. The puppet's call carries the agent's cap this way, since the puppet stores its pair under it. Without it, a module holds its answer to a bound of its own, if it has one. The workspace's is the framework's default, 24,000 (DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS).
    • The module registry now passes a call on whole, with its un-prefixed name, where it copied five fields: id, name, input, callerAgentName and, since fix(discord): edit a turn's thoughts only into the agent's own post in that turn #445, callerTurnId. So resultMaxChars reaches a module.
  • src/modules/workspace/text.ts holds line numbering with a long line's cut, a BOM check and a string's length as JSON, which the planner and edit's answers use.
  • src/modules/workspace/edit-plan.ts is hands-mcp's planner, ported from anima-research/hands-mcp, a private repository, at 22b540d. It matches every edit in the file's text as read, never in another edit's result. It counts overlapping occurrences, matches straight and curly quotes as equal when the text is absent as given and in its other readings (below), keeps every byte outside the replaced text, and gives each new text the line ends of the text it replaces. A miss gets the closest region by bigram similarity, within one search of 2,097,152 UTF-16 code units for the whole call. It departs from hands-mcp's planner in these places:
    • The closest region leaves out the empty line after a final line end, as read numbers lines, and its hints point to grep.
    • Wherever an answer shows a line, in a closest region, a count's lines or a receipt's regions, a line whose own text ends in a CR shows its CRLF end's CR too, as read shows such a line. A CR before a line feed in oldString is read as part of the line end, so lines copied across one shown with a single CR could match the same text with plain CRLF ends. A receipt tells a line's own CRs from its line end's by the bytes the edit stores, so where the edit puts a CR before a bare LF, which makes the two a CRLF end, the line shows without it.
    • Occurrences that overlap are counted only until there are more than the 20 a refusal shows, since they're refused whatever their number, and the refusal then says "at least". Counting them all compared a needle that overlaps itself, such as a run of spaces, in full at every position of a run of the same.
    • The closest region is sought for the first line of the old text with two or more characters, since a one-character line, such as a closing brace, has no bigram to score, and for at most its first 2,000 code units, as many as a region shows of a line. Each line of the file is scored by counting its bigrams against that line's, so what scoring holds follows the old text's line, not the file's longest. A random old text of 3,240,000 code units against a two-line file is answered within a 128 MiB heap, and a miss in a random one-line file of 1,960,000 code units within an 80 MiB one, each in under a quarter of a second, with no region, as nothing in the file is like the old text (Anarchid's R2-2).
    • One pass over the text numbers every clash's line, and every replacement's, rather than a search to the text's end again for each replacement once no line end remains.
    • Once an edit is named in a clash, the scan for clashes links past its later replacements, so edits that overlap at many places cost their replacements, not their pairs at every place. 2,000 edits each replacing every x of a text holding 500 of them are planned in 0.3 s of CPU.
    • It holds each start of an edit's occurrences as a number, sorts several edits' starts as one Float64Array, and joins the new text's pieces 4,096 at a time, where hands-mcp's planner holds an object for each occurrence and an array entry for each piece. Of each edit's replacements it keeps the first, the next 20 distinct lines the rest fall on, and counts. An edit replacing each of 5,000,000 one-character occurrences runs within an 80 MiB heap, peaking at about 300 MiB, and runs out of a 64 MiB one (Linux, Node 24.22.0).
    • Two bounds limit what a file's plan builds and holds: its new text, to the mount's size limit, and the occurrences it keeps, to the text's length and 20 for each edit. From the counts, before it builds the text, the planner knows the least the new bytes can be: the file's, and for each replacement its new text's, less the most the text it replaces can take: in a file with CRLF line ends, each line feed in it as a CRLF, and each straight quote matched as equal as a curly one. A file whose least passes maxFileSize is refused before its text is built, as at least that many bytes. The floor can fall short of the bytes in a file with CRLF line ends or where quotes are read as equal. There a file the floor leaves within the limit can pass the limit once built, and is then refused by its bytes. And two or more edits whose asks, past the 20 occurrences a refusal shows, come to more occurrences than the text is long, in UTF-16 code units with its line ends read as LF, can't all apply, so they keep only what a refusal shows; when each finds what it asks for, or stops counting short of it, they're refused as crowded: they ask for more occurrences between them than the text is long, so some of their matches would overlap. When one finds a different number than it asks for, the refusal names that, and names the overlap only where the rest still ask for more occurrences than the text is long; where they don't, it names each that stopped counting short of its ask by its count, as a floor. A replaceAll of 3,000,000 a with 200 characters each is refused from its counts within an 80 MiB heap, and so is a replaceAll of 5,242,880 a with U+0800 each, which widens the file only in UTF-8 (Anarchid's R2-3). Twelve edits each asking for all 1,000,000 a of a file are refused as crowded within a 48 MiB heap.
    • A call counts the occurrences no answer needs, those past what an edit asks for or a refusal shows, whichever is more, and the one more that shows it finds more, only as many as the larger of its mount's limit and its largest file, in bytes, all of its edits together, so that a count in a call of its own is exact, even in a file stored before its mount's limit was lowered. Which counts reach the budget depends on the call's edits, its files and their order, as which misses get a closest region depends on their order within the call's one search. Past that, an edit's count is a floor, and its refusal says so, and that the edit sent in a call of its own has every one counted, unless they overlap. Within it a count stays exact, since edit_many has no replaceAll, so a count is what lets an agent name the occurrences to ask for. 200 edits of a each asking for one, in a file of 5,000,000 a, take a quarter of a second (Anarchid's R2-1).
    • An old text that starts with the file's BOM or ends with a line's CR, as a character page shows them, is also read without the one, the other or both, before quotes are treated as equal: without the BOM only at the text's start, and without the CR only before a CRLF line end. A BOM or CR at the same end of the new text is read the same way. An edit whose only change, read that way, is the BOM or CR is refused as one with nothing to change. One found in more than one reading is refused, naming the lines of each, since a line whose own text ends in one CR, shown with its end's, reads as given as a line ending in two. So is an old text that is the BOM or a CR alone, found as given, where it could be the file's BOM or a CRLF line end's CR. A reading in which the edit changes nothing counts as much as any, since a copy of a line end's CR or the BOM may mean the file's own, which an edit can't change, and the refusal says where it changes nothing. In a file that mixes line-end kinds side by side, that also refuses a deletion whose copy reads as a neighbouring line end of the other kind, which the text around it settles. An edit that replaces every occurrence counts neither such reading, since edits keep line ends and the BOM: it replaces the occurrences that change.
  • applyEdits (src/modules/workspace/index.ts) runs edit and edit_many.
    • A call whose input doesn't fit its schema is refused at its first such field before any of this, as every workspace tool's is. Then applyEdits checks each edit's own input: an empty oldString, an oldString and newString that are the same once CRLF is read as LF, and a lone surrogate. An oldString and newString that differ only in their line ends are refused with a note that an edit keeps the file's line ends and write replaces the whole file, line ends included. Then it takes each path as disk spells it, as every file tool does since fix(workspace): take a path as disk spells it on a disk that folds names #525, and checks it: a path no mount holds, a path in a mount nested inside it (by the rule every file tool applies, parseFilePath), a path not spelled plainly in its mount, such as one with an empty, . or .. segment, a trailing /, or on Windows a backslash (named with its plain spelling, which resolve gives as the mount's containment check does, since the workspace would keep it as an entry apart from the file's own), a read-only mount, a generated file (by the rule handleToolCall applies to edit's path, now naming set_tool_visibility and set_tool_description as the way to change what it shows), and edits naming files in more than one mount. A problem there refuses edit at once, and edit_many when its edits name more than one mount. Otherwise edit_many goes on to settle and plan the rest, so that one refusal names the problems it finds, as its length allows, and says what the settling took in.
    • It plans the edits of one stored path once, from one text, and names the file as disk spells it, so on a disk that folds names, two spellings of one file that differ in case are one file in one call. Under the mount's turn, one pass settles every path with disk, as edit's pass settled its one path, and names the paths, as every file tool's pass does, so a file the mount passes over by design is taken in as a sync naming it would take it. A mode taken in alone isn't a change to the text, and goes unsaid. That pass is the only step that awaits before the commit. After it, applyEdits reads and plans every file and sets every entry with no await between.
    • commitToolChanges commits several paths. Every path's adoption check runs before any entry is set, then the entries are set in one treeBatch, one record of the store, which takes all of them or none, then each path's intent, by fix(workspace): a mount that doesn't follow symlinks follows none on a path's way #468's rule, then one push takes the paths to disk. A deletion whose evidence holds no agreed disk copy clears its path's intent and is left out of the push, and one that clears a conflict naming a disk file notes that nothing was unlinked, as fix(workspace): a mount that doesn't follow symlinks follows none on a path's way #468's commitToolChange did. It returns, by path, each refusal of that push, and separately each mode the push kept as disk had it and each such note, as fix(workspace): keep a file's mode with its bytes #462's and fix(workspace): a mount that doesn't follow symlinks follows none on a path's way #468's commitToolChange return them for one path. commitToolChange calls it with one path, which it sets with treeSet or treeRemove as before, and write, delete and writeBinary commit through it. A failure before the entries are set leaves none set, and edit_many answers it as a call that stored none of its edits.
    • Chronicle 0.4 can throw from a set after its record lands: update_state_with_builder appends the record, then writes the state's auto-snapshot, whose error reaches the caller (src/store.rs at v0.4.0; A state update returns an error after its record is committed when the auto-snapshot fails chronicle#39). So a set that throws is read back with treeGet. Where every path it changes holds what its change sets, its blob, size and mode, the record is in, since one record takes all of them or none: the paths' intents follow as for any set, and the store's error goes on in place of the push. write, edit, delete and edit_many each answer that the change is stored and that the store failed after recording it, and on a mount that pushes, that its push to disk didn't run, so disk lacks it until a materialize takes it there. On a mount that doesn't push, edit's and edit_many's answers name any conflict with disk, as their success does. Where the entries can't be read back either, edit and edit_many say whether their change is stored isn't known, and to read the files before sending the call again, where a second call could apply it twice. A path that held what its change leaves before the set can't show whether the record is in, so it isn't read back, and a call whose every path held it, such as a write of the bytes its path holds, or an edit matched with quotes treated as equal that leaves its file's bytes as they were, is answered with the store's error, as before.
    • An edit that would take a file past its mount's maxFileSize is refused, as write refuses content past it, and the refusal says an edit that leaves the file within the limit applies. The planner refuses one whose least bytes pass the limit before building it, and the refusal gives the size as at least that many bytes; one the floor leaves within the limit but whose bytes pass it is built and refused by its bytes.
  • src/modules/workspace/edit-text.ts words the answers. Its problem wording and the way a receipt shows regions are ported from hands-mcp's src/edit.ts at 22b540d. edit's refusal names its first problem, and edit_many's every problem it finds in edit order, as its length allows, numbering each edit from 1 and three or more in a row as a range. What the call's settling with disk took in, and a conflict with disk, follow the problems. The problems are in the error's text alone, because an error's data reaches neither the model nor a script. A success gives replacements and changed, each edit's first changed region with two lines on each side, numbered as read numbers lines, a line over 2,000 characters as a stretch from at most 80 characters before each change on it. Its note says what the settling took in, and names a conflict on a mount that doesn't push, quotes matched as equal, an old text matched without its BOM or CR, line ends kept, and the lines of further replacements. Where the push left a file's mode as disk had it, modeKept says why, as fix(workspace): keep a file's mode with its bytes #462's edit answer does, and edit_many's gives it in that file's entry. A pushing mount's push that completed met no conflict, as on fix(workspace): the file tools store exactly what they're given or refuse, and say when a stored change didn't reach disk #456. A conflict's note says what a sync of the path does, from what disk holds at the call, whatever kind the conflict was recorded as: where disk has a copy, a sync keeps it and gives up the workspace's; where disk no longer has the file, a sync takes the deletion, which removes the workspace's copy. It also says that a forced materialize writes the workspace's copy to disk.
  • Budgets. What grows with a call is held to resultMaxChars.
    • A refusal names each problem by its first line, cut at 400 characters, before it explains any, then adds each problem's details in order while they fit. Its last line counts the problems and notes it leaves out, and when it shows a problem and leaves others out, says to answer the problems shown and send the call again to see the rest.
    • edit_many's answer to edits stored but not finished names each file disk refused, with its push's reason, while they fit, and its last line counts the rest and points to ls. It never says to send the call again. What failed after the entries were set goes in its head, which is never cut.
    • In edit_many's answers, a thrown message is cut at 400 characters, as a problem's first line is.
    • A receipt's regions take what its counts and notes leave, and past that are named by their lines. Its other-replacement notes name 20 edits and count the rest. edit_many lists its files while they fit, without their notes, then without their edits' numbers, where those don't fit, and counts the rest in its last line. edit's receipt, of one file, keeps its counts and notes and shows no region where those alone pass the cap.
    • At 1,000 characters, the least cap a residence or an agent can set, a refusal names at least its first problem. A strategy's per-message bound can set a cap below that. There a refusal may name no problem, and its last line then asks nothing of the agent. Its head and last line are never cut, so an answer passes a cap shorter than those, about 135 characters for a refusal of twelve problems.
    • edit's answer to an edit stored but not finished is the one write and delete give, one path's, whose length its templates and its path set.
  • inputProblem now checks an array of objects against its items' schema, with the tool's minimums, so edit_many's edits are checked item by item.

What callers will notice

  • An agent's edit answers a success with replacements, changed and, where they apply, more notes. A miss now reads "The edit did nothing: oldString isn't in …" with the closest region, and a count that differs reads "The edit did nothing: oldString occurs N times in … (on lines …)" with the lines. What the settling took in from disk follows the problem after a blank line, where it followed on the same line. Some edits now apply differently:
    • An LF oldString matches in a CRLF file, and the new text takes the line ends of the text it replaces, where before it went in as given.
    • When oldString isn't in the file as given, or read without a BOM or CR as below, straight and curly quotes are matched as equal.
    • An oldString that carries the file's BOM at its start or a line's CR at its end applies without them, and a BOM or CR at the same end of newString is read the same way, so the file keeps one BOM and its CRLF line end. An edit whose only change is that BOM or CR is refused, since the file keeps them. One found in more than one of these readings, as given among them, is refused, naming the lines of each, since which one the copy meant can't be told, and so is a lone CR or BOM found where it could be the file's.
    • An edit whose occurrences overlap one another is refused, with replaceAll too. Before, replaceAll of "aa" in "aaa" replaced the first one.
    • An edit that would take a file past the mount's maxFileSize is refused, from its counts where they show it: "would be at least N bytes".
    • A path not spelled plainly in its mount, such as work/sub//b.txt, is refused, naming its plain spelling. Before, edit took it as written, and the workspace could hold one file as two entries. A path naming the mount's root, such as work or work/., is refused as naming no file.
    • An edit whose only change is its line ends is refused, and the refusal names write. Before, edit changed the line ends of the text it replaced.
  • An agent whose write, edit or delete meets a store that throws after recording it reads that the change is stored and mustn't be sent again, and that the store failed after recording it, with its error. On a mount that pushes, it also reads that a materialize takes the change to disk, and on one that doesn't, an edit's answer names any conflict with disk, as its success does. Before, it read the store's error alone. A peer caller of writeBinary, such as save_recent_image, gets the same words with inChronicle: true, where writeBinary threw the store's error, so the image is listed as saved, with them as its diskCopyError.
  • An agent whose edit meets a conflict reads what a sync of the path does: where disk has deleted the file, it takes the deletion, which removes the workspace's copy; otherwise it keeps disk's copy and gives up the workspace's. fix(workspace): the file tools store exactly what they're given or refuse, and say when a stored change didn't reach disk #456's note says a sync takes disk's copy in both cases.
  • An agent with a generated file reads, when it tries to change the file, that set_tool_visibility and set_tool_description change what it shows. The same refusal reaches write and delete.
  • An agent has a new tool, edit_many, in the workspace module's tool list. Its refusal, and its answer to a failure before any edit is stored, opens "The edit_many call stored none of its edits:".
  • A module author's tool receives resultMaxChars on its calls, with every other field the framework sets on a call, where it received id, name, input, callerAgentName and callerTurnId alone.
  • A script, or code, that reads edit's refusal text sees the new wording given above.
  • The first request after the upgrade that carries the workspace's tools misses the prompt cache from its tool block on, since edit's description and its parameters' descriptions changed and edit_many is new.

What each part is for

The change lets an agent editing the workspace's files act on a refusal without another read, and make a change across files in one step.

  • The planner carries what hands-mcp's reviews found for its tools. edit-plan.ts is 954 lines, 579 of them code and 345 comments, in place of the few lines of handleEdit that matched and replaced. Writing it anew would cost as much and lose what those reviews found.
  • edit_many costs one tool, a list schema and the disk half of its answer. Without it, a change across files goes as several calls with no way back from a failure partway.
  • One engine for both tools keeps one copy of the checks, the settling, the commit and the wording, where two copies would each need every fix.
  • commitToolChanges is commitToolChange over several paths. Setting them in one treeBatch keeps a call's edits all or none in the store, so the answer to a failure is either a refusal or every edit stored, and never a list of some files stored. A single path keeps its treeSet or treeRemove, so write, delete and edit record what they did before.
  • resultMaxChars is one optional field, set at three call sites and carried by the registry. Without it, an answer budgeted against the 24,000 default would still be spilled for an agent whose cap is lower, and paging a script's results at that default would take away what a script gets now.
  • The budgets cost the fitting in edit-text.ts, which builds a region's lines only while they fit. Without them, a few misses with long closest regions push the answer past what reaches the agent whole, and the framework spills it to a file; and a receipt of a change of millions of lines numbers every one of them before naming the change by its lines.
  • Holding no object per replacement costs about 140 lines of the planner: the edits' starts as numbers and one Float64Array, a text builder that joins pieces 4,096 at a time, the new text's lines counted as the replacements go, and of each edit's replacements only what a receipt names. Without it, one replaceAll of millions of occurrences holds an object for each and aborts a host whose heap is 512 MiB.
  • Matching an old text without its BOM or a line's CR costs about 85 lines and a note, 40 of them a search that finds every place in one pass, which it needs since only the text's start, or a place before a CRLF line end, takes such an old text. Without it, an oldString copied from a character page misses, with a closest region that reads the same as the text sent. Searching again from each place instead, a run of 200,000 characters and an old text of 50,000 took 3.3 to 5.6 s of CPU on Linux with Node 24.22.0.
  • A long line's excerpt costs about 35 lines. Without it, a change past column 2,000 succeeds with a receipt that doesn't show it.
  • Linking past a named edit's replacements costs about 20 lines. Without it, 2,000 edits matching one text at 500 places hold the event loop for 34 s.
  • Bounding what a file's plan builds and holds costs about 50 lines: the size floor in the planner, counted in bytes, and its wording, and the crowded asks with their problem and wording. Without it, a replaceAll that lengthens or widens millions of occurrences, or a few edits asking for millions each, aborts the host's process where main refuses or applies them.
  • The count budget costs about 100 lines: the budget, its charge in the scan, crowded edits that stop counting short of their asks, and the floor's words. Without it, each of many edits of a text with millions of occurrences counts them all, and 200 such edits hold the event loop for 24 s.
  • Holding a closest region's search to its old text's line costs about 20 lines: the cap on the line it scores against, and counting each file line's bigrams against that line's. Without it, a long random old text, or a long random line in the file, runs a small heap out of memory on a miss.
  • Reading a set that threw back costs about 95 lines: changesLanded and holds, CommitUnknownError and StoredThenFailed, and each tool's answer when the store failed after recording its change or can't be read back. Without it, a store that throws after recording the edits, as Chronicle 0.4 does when its snapshot fails, makes edit_many say it stored none of them, and a second call can apply them twice.
  • Carrying fix(workspace): keep a file's mode with its bytes #462's kept mode through commitToolChanges and into both tools' answers costs about 20 lines. Without it, edit_many would say nothing where its push left a file's mode as disk had it, where edit, write and writeBinary say so as modeKept.
  • Showing a line as read does costs about 40 lines, half of them comments. Without it, lines copied from a receipt or a closest region across a line whose own text ends in a CR can edit the same text elsewhere, where its lines end in CRLF alone, and where an edit puts a CR before a bare LF, the lines its receipt shows can edit another line.
  • Refusing an old text that reads more than one way costs about 100 lines: the scan of each reading, the problem, its words, and the line saying where the edit changes nothing. Without it, a line whose own text ends in one CR, copied as read shows it, edits a line whose own text ends in two, and so does such a copy of a line that starts with a BOM of its own; and a copy of a CRLF end's CR or the file's BOM, deleted, deletes another CR or U+FEFF, some on another line.
  • The plain-path check costs about 15 lines. Without it, one file named two ways in one edit_many call is planned twice from one text and set as two entries, and one edit never reaches disk.
  • The tests are 44 on the planner alone, 82 on the tools (76 in a new file, 3 added to workspace-file-tools and 3 to workspace-three-way), 5 on resultMaxChars and 3 on the text helpers. Each piece fails a test when it's taken out (Tests).

Cut:

  • hands-mcp's put-back, renames, file identity and retries, which serve files on disk. The workspace sets its entries in one record of its store, so there is nothing to put back.
  • Edits across mounts in one call, which would take two mounts' turns in a fixed order to save an agent a second call.
  • A bound on the bytes one edit_many call holds together, beyond each file's maxFileSize.
  • A bound on the edits in one call. Each edit's matching scans its file's text, as an edit call's does, and a call's matching and commit run without yielding. What it counts past what its edits need is held to the call's count budget (above), and each count problem reads the text from its start through the lines its first occurrences fall on, to number and show them, so many problems on one long line each read all of it: 20,000 edits of a file of 5,000,000 a took 3.3 s on one line, and 0.3 s on 1,000 lines. On Linux, Node 24.22.0, on a shared machine of 12 CPUs at a load near 26, 3,000 edits of one 4.97 MiB file held the event loop for 3.7 to 3.9 s, and planning 1,000 took 1.2 to 1.4 s. A model's call carries few edits.
  • The problems as data on a refusal, which reaches neither the model nor a script.
  • Fitting edit's answer to an edit stored but not finished, which is one file's and is shared with write and delete.

Tests

  • Full check of this head, which holds main aae86b7fc, on Linux from a clean detached worktree, with npm install as CI runs it, Node 24.22.0 and npm 11.19.1: tsc --noEmit and the build are clean, and CI's npm test passes 4720 of 4737 tests. It skips the other 17, fix(workspace): take a path as disk spells it on a disk that folds names #525's tests that run only where the temp directory folds case. Each of its 243 test files, run on its own without --test-force-exit, passes, with the same 4720 tests passing.
  • Main aae86b7fc, the same way: CI's npm test passes 4586 of 4603 and skips the same 17, and each of its 239 files on its own passes, 4586 of 4603 tests. This head runs every test main runs, by name, and 134 more, this change's.
  • Node 20.20.2, focused: the nineteen files this change touches or that run its paths pass 911 of 928 tests and skip the same 17 (workspace-edit-plan, workspace-edits, workspace-file-tools, workspace-three-way, workspace-text, tool-call-result-max-chars, workspace-materialize-freshness, puppet-tool-call, utils-surface, tool-result-spill, module-context-integration, tool-result-guard, turn-id, workspace-parent-link, workspace-read-file-from-disk, workspace-path-spelling, save-recent-image, workspace-nested-mount-roots and code-execution-structured-results).
  • GitHub's CI passed at this head on Ubuntu and macOS with Node 20 and 24, as did its changelog check and the packed release's install. On macOS, whose temp directory folds case, it runs fix(workspace): take a path as disk spells it on a disk that folds names #525's tests of paths spelled otherwise, the edit_many case among them, which skip on Linux.
  • Red-first: test/workspace-edits.test.ts as it stood at 6a1d0e3d9, less its one test of edit-text.ts's fitting, which needs a module this change adds, fails all 30 of its tests at 41e3c5fdb, the commit before the planner. The edit tests fail on behaviour; the edit_many tests fail because the tool doesn't exist there, which shows nothing about their checks, so the removals below carry them. test/tool-call-result-max-chars.test.ts fails all 5 at fix(workspace): the file tools store exactly what they're given or refuse, and say when a stored change didn't reach disk #456's head with only the field's type added. A test added since for a fix fails at the commit before it. A test that pins what the code already did, such as the one of a CRLF file that its CRs take past maxFileSize, passes there, and the removals below show what the merges' tests catch. At 6a1d0e3d9, the memory test fails because the planner runs out of its 80 MiB heap, and so do the tool tests of an oldString carrying a BOM or CR and of a long line's receipt. At 706da54a7, the tests of an edit whose only change is that BOM or CR fail, and so does the test of three edits' changes on one long line. At a3eaa3334, the test of 2,000 edits that clash at one place fails: their planning takes 37 s of CPU, which the test holds under 5 s. At d92344b9e, the tests of the size floor, of a growing replaceAll and of edits asking for millions in a small heap, of crowded edits, of plain paths, of the refusal past maxFileSize, of the line-end refusal's wording, and of a store that throws after recording fail. At 072f5a8ef, the tests of a path that resolves to the mount's root or leaves it and comes back, of the answers after a store that failed after recording, on a mount that pushes and one that doesn't, and of the crowded refusal's wording fail. At 43f857baf, the first merge of main, the tests of every spelling of the mount's root, of writeBinary's answers after a store that failed after recording, and of a partly refused edit_many beside a kept mode fail. At 2f9219c37, the tests of the root refusal's wording, of writeBinary's words on a mount that pushes, and of a partly refused edit_many's kept mode fail. At c63578fce, the tests of edit refusing its own input before its path is looked up, of a receipt's last line counting the files it doesn't list, of a region of a million lines, of a read form in a long run, of a receipt's room for its last notes with eight-digit line numbers, of calls of 200,000 edits, and of a store failure's conflict note on a mount that doesn't push fail. At fbe0635bc, the test of one edit in a file of one long line, whose region now shows where it fits, fails. At 39d0151d4, the test of lines copied across a line whose own text ends in a CR fails. At 4abad1fd5, the tests of a receipt's lines where the edit puts a CR before a line feed, and of an old text found both as given and read without its BOM or CR, fail. At 5395501a8, the tests of a copy read without its last CR before quotes are read as equal, of a copy that starts with a BOM of its own text read without its CR alone, of the size floor in bytes, of a widening replaceAll and of a closest region's search in a small heap, of a count past the call's budget, and of the wording of the readings and crowded refusals fail. The planner's test of the budget needs exports 0d95cb31f adds, so the removals below carry it. At 0d95cb31f, the tests of a crowded edit the budget stopped short of its ask or at it, of an edit's count alone in a text longer than the call's limit, of a crowded call with an edit replacing every occurrence, of the floor in a file with no CRLF line end, and of a lone CR or BOM copied fail. At b8be9d25e, the tests of an edit replacing every occurrence in a crowded call whose budget is all but spent, of a copy of the file's BOM and an empty first line's CRLF end beside another reading, of a replaceAll of a lone CR or BOM, and of the floor's words for occurrences that overlap fail. At 6d839f12a, the tests of a single edit deleting a copy of a CRLF end's CR or of the BOM, and of the refusal saying where the edit changes nothing, fail. e6556ad29's two new cases pin what 383f9696b already does and pass there, and the removals below carry them.
  • Each piece taken out: 239 removals, each run alone at this tree, and each fails at least one test. 92 are in the planner, such as counting occurrences apart, dropping the quote match or the BOM, taking the quote match before an exact one or before the readings, not spending the call's search, searching for line ends again from each index, giving every occurrence the first one's line ends, joining the new text only at its end, reading a BOM anywhere or a CR before any position, applying an edit that read form leaves unchanged, dropping a reading of an old text or the refusal of one read more than one way, the one-pass search's fallbacks and its stop, walking a named edit's replacements again, building the new text before bounding its size, counting the floor in code units, keeping every start of edits that ask for more occurrences than the text is long, counting past the call's budget or taking a count it stopped as exact, scoring the closest region against a whole old text, and showing a line's own CR without its CRLF end's. 136 are in the engine and its wording, such as planning each edit as its own file, setting each path on its own instead of in one record, checking paths after the sets, an await between the plans and the commit, sizing the count budget by the mount's limit alone, wording the answer to edits stored as a refusal, answering a failure before the set without saying nothing was stored, choosing a conflict's advice by its recorded kind, cutting no first line or cutting inside a surrogate pair, labelling without ranges, adding details past the budget, measuring a region by its length rather than as JSON, keeping aside more or less than a receipt's notes can need, showing a receipt's line other than as the stored bytes hold it, leaving out a conflict's note, showing only a long line's first change, not reading a set that threw back, reading it back by presence, without the entry's mode, or with the entries the tree already held, skipping a landed set's intents, answering a landed set as the rest of the call failing, leaving the unrun push unsaid, and making a path plain by collapsing doubled slashes alone. Among them are the pieces this branch's merges of main brought in: the nested-mount parse, the named pass, a mode taken in alone, a kept mode collected, passed on and shown in each answer, every spelling of the mount's root, writeBinary's answers after a store that failed after recording, kept modes in a partly refused answer, fix(workspace): a mount that doesn't follow symlinks follows none on a path's way #468's deletion rule, the push it skips and its note, and naming an edit's file as disk spells it. workspace-three-way, workspace-edits and workspace-file-tools catch these, and workspace-path-spelling catches the last on a filesystem that folds case. One is in the text helpers, the BOM check's third byte, and 10 are in resultMaxChars, one for each place it's set or carried. Numbering the clashes' lines in one pass has no removal, since a pass for each clash finds the same lines more slowly, and a test pins the lines. Nor has the width a receipt keeps aside for a region's last line: without its new text's lines or its last line, it passes every test, and a probe at 39d0151d4, whose reserve this head keeps, found none of 134,252 receipts past its cap, 117,967 of them naming a region that ends on a power of ten. Nor has the guard there for a file with no edits, which by our reading no call reaches.
  • What each new test protects: the planner's matching, line ends, size and memory bounds, and time, a read form's search in one pass, its fallbacks and its stop among them, the size floor in bytes, a widening replaceAll and a closest region's search in a small heap, and the count budget (workspace-edit-plan); the tools' refusals, receipts, all-or-none commit, plain paths, conflict notes, budgets, and answers after a store that throws, and with them edit's refusal before its path is looked up, the branch the edits are set on, surrogate pairs at each cut, calls of 200,000 edits, what a receipt keeps aside for the notes its call can add, for the longest of its files' notes and for a long line, a line's own CR wherever an answer shows the line, a receipt's lines as the stored bytes show them and its walk through them, an old text read more than one way, each reading tried before quotes, a BOM and a CR read each way and each reading's count, and a count past the call's budget (workspace-edits); the answers of write, edit, delete and writeBinary after a store that throws, a deletion of a draft whose store throws after recording it among them (workspace-file-tools); edit_many refusing a path in a nested mount, editing files the mount passes over by design, giving a kept mode in a success and in a partly refused answer, and the event for a file whose push disk refused (workspace-three-way); edit_many refusing a path that leads into a nested mount through a link, a case added to fix(workspace): a nested mount is its directory wherever disk puts it #535's test of a directory replaced by such a link (workspace-nested-mount-roots); edit_many naming one file two ways, a case added to fix(workspace): take a path as disk spells it on a disk that folds names #525's test of paths spelled in another case (workspace-path-spelling); and the field's value at each kind of call (tool-call-result-max-chars).
  • Reviews: an internal review read the whole change twice, as first built and at c63578fce, and in each of its thirteen other rounds the changes since the round before, the last through 383f9696b, except that no round read 39d0151d4's. That commit makes a receipt keep aside only the note its own call's edits can need, and adds tests and wording; a colleague read it. e6556ad29 changes only tests and comments; a colleague read it, and no internal review has. Greptile's reviews of this pull request and of feat(workspace): read says what the text is, stops a page before it would spill, and settles its path against disk first #465, and Anarchid's two nightly reviews, found defects and costs in this branch's code, and each is fixed here. At 383f9696b, whose code this head keeps, a colleague tried with feat(workspace): read says what the text is, stops a page before it would spill, and settles its path against disk first #465's read every copy of up to six characters that a page shows at one place, in 4,589 small files: of 112,983 edits, every one that lands does so where its copy came from, and 116 deletions in files that mix line-end kinds side by side are refused, each naming both readings. fix(workspace): take a path as disk spells it on a disk that folds names #525's tests, the edit_many case among them, also ran on a FUSE filesystem on Linux that matches names without regard to case or Unicode normalization.

Not verified

  • Whether two stored paths that differ only in case can reach one file on a case-insensitive disk.
  • The plain-path check on Windows, where no run of this change has been made. On Node's path.win32, a copy of its computation passes a name ending in . or a space, which Windows strips, as parsePath's containment check passes it too.

Out of scope

  • read and grep, which number lines and hold their answers as they did.

Open pull requests this meets

This branch merged main five times, at d9bd6dc97, c7ad41d4d, c0155d390, 5227741b3 and aae86b7fc, so it holds #460, #462, #466, #424, #445, #425, #468, #525, #526, #531, #532, #535, #533, #262, #537, #234, #528, #529, #524, #538, #539, #372 and #210, and it merges with main as of 09:10Z on Oct 19, when main is 4fd2bfb9e. Of the 50 other open pull requests then, by git merge-tree, 9 merge with both, among them #465, which is stacked on this branch, and 4 merge with this branch but not with main. Two merge with main but not with this branch. #536, in handleEdit's body, whose work this change moves into applyEdits, spells the path in the settling's note as the call gave it. #564, a read-only direct mount, adds its helpers in index.ts at the place where this change adds its commit's types and helpers. Two that conflict with main too meet lines this change adds:

The other 33 conflict with main already, away from this change's lines.


  • Changelog fragments added: changelog.d/tool-call-result-max-chars.added.md, changelog.d/workspace-edit-many.added.md and changelog.d/workspace-edit-planner.changed.md.

🤖 Written by Cuthbert-2453 (Claude Opus 5.5), contributing through nissa-seru, to a design by Ada-1017 (Claude Opus 5.5) built on Phoebe-2419's (Claude Opus 5.5) account of the workspace's tools. Abigail-2676 (Claude Opus 5.5) read it, ran its planner side by side across its rounds, and read the engine's round answering Anarchid's first review, the fourth and fifth merges of main, and every commit from c63578fce on that answers a review, and the text helpers' commit is hers. Langston-2479 (Claude Opus 5.5), who wrote #468, read the first two merges of main and the fixes that followed each, and Tabitha-2696 (Claude Opus 5.5), who wrote #525, read the third.

Description checked and posted by Lottie-2661 (Claude Opus 5.5).

Cuthbert-2453 and others added 7 commits October 17, 2026 16:37
… reach its caller whole

A tool that pages or budgets its answer had no way to know the bound the
framework holds its result to. The framework resolves the agent's inline
cap, from the agent's setting, the residence's config and the strategy's
per-message bound, and only when it stores the result. So a module that
budgets against the 24,000 default still spills for an agent whose cap is
lower, and it would page a running script's results far below the 5,000,000
characters a script takes. The workspace's file tools are about to bound
what one read or one edit's answer holds, and need the bound that applies
to each call.

ToolCall gains resultMaxChars, which the framework sets at dispatch:
- A model's call to a module tool (dispatchToolCallEvent), a utils run of a
  utility, and an operator's puppet call carry the agent's effective inline
  cap (resolveToolResultInlineCap), the cap the store then applies.
- A call from a running code_execution script carries the script's limit,
  SCRIPT_TOOL_RESULT_MAX_CHARS. handleScriptToolCall now names it where it
  had the literal 5_000_000 twice, so the field and the script's bound are
  one number.
- A call for an agent the framework doesn't hold carries the default,
  24,000.
- A call whose caller takes the module's result in process
  (ModuleContext.callTool, a subagent's executeToolCall) carries none, and
  the module uses its own default.

The framework sets the field where a module call gets callerAgentName.
dispatchToolCall's enriched call serves the framework's own routes. Its
route to a module pushes the call as given, and dispatchToolCallEvent
enriches it there.

The module registry passed id, input and callerAgentName to the module
field by field, so a field set at dispatch never reached it. It now passes
the call as it came, with the un-prefixed name.

Tests: test/tool-call-result-max-chars.test.ts (4). A model's call carries
the strategy-bounded cap (4,000 under a 5,000 residence cap), a result that
long is stored whole and one a character longer isn't, and a utils run in
the same turn carries the same cap. The puppet's call carries it too. A
script's call carries 5,000,000, and the script gets a result that long
whole and one a character longer cut. A call for an agent the framework
doesn't hold carries 24,000.

Runs, on Linux with Node 24.22.0:
- The typecheck is clean, and a focused run of this file with
  puppet-tool-call, utils-surface, module-context-integration and
  tool-result-spill passes 39 of 39.
- Red-first: at the parent, with only the field's type added, all 4 fail.
- Removals: each of eight pieces, taken out or made wrong alone, fails the
  test that names it. They are the dispatch site, the script branch, the
  unknown agent's default, the utils site, the puppet site, the registry's
  copy, the script bound against the field, and the resolved cap against the
  default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read, grep and edit each need lines numbered as cat -n numbers them with a
long line cut, a BOM's check, and a string's length as JSON, which is what the
framework stores of a result and what a page's or a receipt's budget counts.
src/modules/workspace/text.ts holds them:

- numberedLine and numbered: a six-wide number, a tab and the line, cut past a
  length without splitting a surrogate pair (safeCut), saying how many
  characters follow and, if asked, how to read them. hands-mcp's numbered and
  safeCut, which edit's port of hands' planner uses too. MAX_LINE_CHARS is
  read's cut, 2,000 characters, as hands' Read cuts.
- hasBom: whether bytes open with a UTF-8 byte order mark.
- jsonLength and jsonCharLength: a string's length inside JSON's quotes, as
  JSON.stringify writes it, escapes and lone surrogates included.

Tests: test/workspace-text.test.ts (3). jsonLength matches JSON.stringify for
every UTF-16 code unit alone and for 2,197 triples of escapes and surrogates;
numberedLine numbers and cuts without splitting a pair; hasBom finds the mark
at the start alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The workspace's edit matched with String.split and replaced with
String.replace. An LF oldString missed in a CRLF file, a miss said only
"String not found", a count that differed named no lines, and nothing could
apply several edits together. hands-mcp's planner (src/match.ts) does each
of these for that project's Edit and EditMany tools, with no I/O, so it can
plan edits to the workspace's stored copies as it plans them to files.

src/modules/workspace/edit-plan.ts is that planner, ported from
anima-research/hands-mcp, a private repository, at 22b540d:
- decodeForEdit decodes strictly. A file that isn't valid UTF-8 has no text
  to edit, since writing it back would change bytes outside the edits.
- planFile matches every edit in the text as read, never in another edit's
  result, with CRLF line ends read as LF. An occurrence is a position, so
  overlapping occurrences each count and can't each be replaced. When the
  text as given is absent, planFile matches straight and curly quotes as
  equal.
- Two edits' matches may touch but not overlap. Each edit is named in at
  most one clash, so a thousand edits of one stretch are at most a thousand
  problems.
- Only the replaced text changes. Each new text takes, by position, the line
  ends of the text it replaces: a span with none takes its own line's, or
  for an unterminated last line the line end before it. A BOM and a lone CR
  stay as they were.
- A miss gets the closest region by bigram similarity, numbered, within one
  search of 2,097,152 UTF-16 code units for the whole call, so a call of
  many misses holds the event loop no longer than one could. A count that
  differs shows the lines of its first 20 occurrences, a long line as an
  excerpt around its match.

It departs from hands-mcp's planner in two places:
- The closest region leaves out the empty line after a final line end, as
  read numbers a file's lines, rather than showing a line the file doesn't
  have.
- Its hints say to find the text with grep, the workspace's tool, where
  hands-mcp's say Bash.

Tests: test/workspace-edit-plan.test.ts (13), on the planner alone. They
cover order and matching in the text as read, overlapping occurrences, the
lines a count shows and a long line's excerpt, touching and overlapping
edits, clashes named once per edit, the closest region, the call's search
budget and a file over it, quotes, CRLF with a BOM, a mixed file's line
ends by position, a match at a CRLF's line end and an unterminated last
line, a lone CR, invalid UTF-8, and an empty old text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in the store

edit now plans its one edit with the planner, and the new edit_many plans
several, in one file or across files of one mount, and stores every edit or
none. Both run through applyEdits:

- applyEdits checks each edit's own input before it reads anything: an
  empty oldString, oldString and newString that are the same once CRLF line
  ends are read as LF, and a lone surrogate in either. Then it checks each
  path: a path no mount holds, a read-only mount, a generated file (edit's
  generated file is refused in handleToolCall, as before), and edits naming
  files in more than one mount. edit refuses at its first problem, its
  input's before its path's.
- It plans the edits of one stored path once, from one text. Under the
  mount's turn, one pass settles every path with disk, by the rule a
  listing uses, as edit's pass settled its one path. That pass is the only
  step that awaits before the commit. After it, applyEdits reads and plans
  every file and sets every entry with no await between, so no pass, branch
  change or other call moves the tree between a file's read and its set.
- A refusal names every problem it found, in edit order (edit-text.ts).
  edit_many labels each by its edits, numbered from 1, and edit opens with
  "The edit did nothing:" unless the problem is a whole sentence already,
  as for a file it can't find or an unknown or read-only mount. What the
  settling took in, which copy each file's edits looked in, and a conflict
  with disk follow the problems, since the agent needs them either way. The
  problems are in the text alone, since an error's data reaches neither the
  model nor a script.
- An edit that would take a file past its mount's maxFileSize is refused, as
  write refuses content past it.
- commitToolChange becomes commitToolChanges, for several paths, and
  commitToolChange calls it for one. Every path's adoption check runs before
  the first entry is set, then every entry is set, then one push takes the
  paths to disk and reports each refused path. write and delete commit as
  before.
- When disk refuses some files' pushes, or the call fails after setting some
  entries, edit answers as before (storedButUnfinished). edit_many leads
  with what is stored and must not be sent again, then names each file disk
  refused with its reason, or the files stored and those not.
- The receipt gives `replacements` and `changed`, each edit's first changed
  region with two lines on each side, numbered as read numbers lines. Its
  `note` covers the settling (disk's newer copy taken in, a copy that
  couldn't be checked, or a copy disk lacks with nothing to say it was
  deleted), a conflict, quotes, line ends and the lines of further
  replacements. edit words these notes as it did before, and edit_many names
  each file and its edits. edit_many's receipt gives each file's `size`,
  `replacements`, `edits` and `note`.
- applyEdits holds each answer to the call's resultMaxChars, measured as the
  framework stores it. A refusal names every problem by its first line
  before it explains any, then adds each problem's details in order while
  they fit, and counts what it leaves out. A receipt's regions take what its
  counts and notes leave, and past that are named by their lines. edit_many
  lists its files while they fit and counts the rest.
- inputProblem checks an array of objects item by item, against the items'
  schema and with the tool's minimums, so edit_many's edits are checked as
  every tool's input is.

The problem wording and the receipt's regions are ported from hands-mcp's
src/edit.ts. hands-mcp's put-back, renames, file identity and retries have
no part here, since the store's entries are set in one synchronous span and
there is nothing to put back.

Tests: test/workspace-edits.test.ts (22) covers edit's miss, count and
receipt, CRLF and a BOM on disk, quotes, identical strings and maxFileSize.
For edit_many it covers all or none across files, every problem in edit
order, overlapping and touching edits, occurrences, one mount, a read-only
mount, an empty list and malformed items, a generated file, disk refusing
one file, a failure after one file is stored, every check before any set,
the settling notes, a conflict and a copy disk lacks, the shared search for
closest regions across files, and the refusal's and the receipt's budgets.
The miss and count expectations in test/workspace-file-tools.test.ts and
test/workspace-three-way.test.ts take the planner's wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rs within small caps, and give a utility run its call's cap

A review of the planner and edit_many found these, each fixed here:

- commitToolChanges set each path's entry with its own treeSet, so a
  store that failed after the first set left some of a call's files
  stored. Several paths are now set with one treeBatch, one record of the
  store, which takes all of them or none; a single path keeps treeSet or
  treeRemove. The caller is told the entries are set before any intent is
  updated, so an answer that says they're stored is true when an intent
  update then fails.
- So the answer to edits stored but not finished no longer lists files
  stored and not stored: every file is stored, or none and the call fails.
  It lists the files disk refused while they fit, and its last line points
  to ls rather than saying to send the call again, which would apply the
  edits twice.
- fitted kept a fixed reserve for its last line and dropped a part whose
  first line didn't fit, so a refusal could pass a small cap, or name none
  of its problems when one label or path was long. The last line's room is
  now measured, a part's first line is cut at 400 characters, and a label
  names three or more edits in a row as a range, and at most ten ranges or
  edits before it counts the rest. At 1,000 characters, the least cap a
  residence or an agent can set, a refusal names at least its first
  problem. The last line counts problems and notes apart, and only a
  refusal's says to answer the problems shown and send the call again.
- A receipt's other-replacement notes named every edit with more than one
  replacement. They now name 20 and count the rest, and a file whose notes
  don't fit is listed without them, where it was left out whole.
- A count named one line per occurrence, so two occurrences on one line
  read as two lines. It names each line once, and says when more
  occurrences follow those shown. Other-replacement notes do the same.
- Occurrences that overlap were all counted, comparing a needle that
  overlaps itself in full at every position of a run of the same: a
  1,000-space oldString in a 200,000-space file took about 200 million
  comparisons. Overlapping occurrences are refused whatever their number,
  so the scan now stops once it has the 20 a refusal shows, and the
  refusal says "at least".
- A miss whose old text began with a one-character line, such as a closing
  brace, got no closest region, since such a line has no bigram to score.
  The probe is now the first line of two or more characters.
- Each clash's line was found by a pass from the file's start. One pass
  now numbers every clash.
- edit_many refused a generated file in words of its own, without checking
  whose it was. It now refuses one by edit's rule and in edit's words:
  another agent's belongs to that agent, and the agent's own is read-only.
- The maxFileSize refusal named the setting and gave no way forward. It
  now says an edit that leaves the file within the limit applies, and that
  only whoever runs the host can change the limit.
- A utility run through utils from the operator's puppet got no
  resultMaxChars, since executeToolCall's utils route is in process. An
  in-process utils run now passes on what its own call carries: the
  puppet's agent cap, or nothing.

Texts now say what the code does: edit_many's refusal says it stored none
of its edits, since its settling with disk may have taken in disk's copy,
which it reports; edit's refusal names its first problem; the hint budget
bounds the closest-region search, not matching; ToolCall.resultMaxChars
names its in-process callers as they are; edit's description no longer
gives a file's size as the only reason a miss has no region; text.ts says
edit uses it; lineEnding loses a branch no input reaches.

Tests: test/workspace-edits.test.ts gains the one-record commit (a failure
after the entries says every edit is stored, and one in the batch stores
none), a generated file's rule for both tools, edit_many's refusal of a
file that isn't valid UTF-8, overlapping occurrences counted to the ones
shown, notes counted apart in a refusal's last line, a refusal at the
1,000-character floor with a 300-edit range, scattered edits and a cut
path, the answer to edits stored under its cap, and a receipt's bounded
notes. test/workspace-edit-plan.test.ts gains the scan's stop at an
overlap, each clash's line, and a one-character first line's closest
region. test/tool-call-result-max-chars.test.ts gains a puppet's utility
run, a script's utility run, in-process calls with and without a cap, and
exact cuts past the cap and the script's limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wn overlaps, and name a file whose edits' numbers don't fit

A second review, of the round before this one, found these, each fixed
here:

- lineNumbersAt found each index's line by searching for line ends from
  the previous index, and once no line end remained it searched to the
  text's end again for every index. A replaceAll of 20,000 occurrences
  before 10 MB without a line end read those 10 MB 20,000 times. It now
  keeps the next line end it found, so it reads the text once.
- The scan of overlapping occurrences stopped at the 20th, so exactly 20
  read as "at least 20". It now stops at the 21st: "at least" means more
  were found than are shown, and 20 are counted as 20.
- A refusal that showed no problem, at a cap too small for any, still told
  the agent to answer the problems shown. The instruction now follows only
  a refusal that shows one.
- edit_many's receipt listed each file with every edit's number, so a file
  of 300 edits didn't fit at a 1,000-character cap and was counted rather
  than named. A file whose entry doesn't fit is now listed without its
  notes, then without its edits' numbers, before it is left out, and the
  last line says which of these it left out.
- The refusal of a generated file named the presentation configuration and
  no way to change it. It now names set_tool_visibility and
  set_tool_description, which change the tool presentation the file shows.

Texts now say what the code does: edit's and edit_many's descriptions say
the changed region is shown as the answer's length allows;
ToolCall.resultMaxChars says a script's error reaches it uncut, names the
routes that set it and the framework's read_image tool among in-process
callers, and says a module bounds an answer itself without it; the
changelogs give the closest-region search as one per call, the least cap
the answers keep to, the house default for a call with none, and the four
fields the registry passed before; MatchProblem.lines is one line per
occurrence.

Tests: test/workspace-edit-plan.test.ts gains lineNumbersAt over 20,000
indices before a million code units with no line end, given a text that
counts what each search reads, which must read the text at most once, and
exactly 20 overlapping occurrences counted exactly. test/workspace-edits.test.ts gains edit_many's note for a file
disk lacks on a mount that pushes, and for a path that couldn't be checked;
two edits in a conflicted file; an edit that leaves a file at exactly
maxFileSize; a fractional occurrences; a floor past the count asked for; a
refusal that shows no problem; a receipt that names a 300-edit file
without its edits' numbers; and the 1,000-character refusal of scattered
edits checked for length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es the files disk refused exactly

A call that fails before its entries are set now answers "The edit_many
call stored none of its edits:" and what failed, cut at 400 characters as
a problem's first line is. The commit sets every entry in one record or
none, so nothing is stored then, and the call can be sent again. Before,
the answer was the bare error, which didn't say whether anything was
stored.

The answer to edits stored but not finished:
- names each file disk refused "with its reason", where it and the tool's
  description promised "the way to finish it", which some push reasons
  don't give;
- words one file, and one refused file of several, in the singular, where
  it read "Disk took 0 of the 1 files. These pushes didn't complete";
- puts what stands for the whole call in its head, which is never cut: how
  many files disk took, or what failed after the entries were set. Its last
  line then counts only the files disk refused, by that name, and points to
  ls only when it leaves some out. ls was named twice when the call failed
  after the set.

A conflict note over a file disk no longer has said to "Sync the path to
take disk's copy instead", but disk has no copy: a sync takes the deletion,
which removes the agent's draft. Where the pass finds disk without the file
(diskNow), whatever the conflict's kind, the note now says that a sync takes
disk's deletion, which removes the workspace's copy, and that a forced
materialize writes the workspace's copy back to disk. Disk's state at the
pass decides it, since that is what a sync takes, and not the conflict's
kind: a recorded conflict keeps its kind, so one recorded as both-changed
reads so after disk deletes the file, and a recorded deletion still reads
deleted-on-disk after disk writes the file again. Where disk has a copy, the
note now says a sync keeps disk's copy and gives up the workspace's, where
it said a sync takes disk's copy: a copy the workspace can't hold, binary or
past maxFileSize, isn't taken in, and the sync gives up the workspace's copy
all the same.

applyEdits' comment and the changelog say that edits naming files in more
than one mount are refused at once, before any settling, so that refusal
can't name what matching would find. The changelog gives the bound below
1,000 characters as it is: a refusal there may name no problem, and an
answer is never shorter than its head and last line. It also names the
generated-file refusal's new wording, which write and delete give too, and
a test now pins it for them, and says that edit's changed description and
the new tool cost the first request after the upgrade the prompt cache from
its tool block on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The changes reach workspace file editing and framework tool-call dispatch.

Fix the planner's memory growth before merging; one valid replaceAll can terminate a memory-limited host.

Findings

  1. P1 One edit exhausts memory ▶
  2. P2 Read text fails to match ▶
  3. P2 Long-line receipts hide changes ▶
Fix with agent prompt
### Issue 1
src/modules/workspace/edit-plan.ts:369
A single valid `replaceAll` can exhaust the host's memory even when both file versions fit `maxFileSize`. The planner keeps every match in `starts`, `spans`, output arrays, and `changes`. Replacing `a` with `b` in a 5 MiB file aborted Node with a 512 MiB heap; the previous replacement code completed under the same limit. This can interrupt every agent running on that host. Stream replacements and keep only the positions needed for checks and bounded receipts.

### Issue 2
src/modules/workspace/edit-plan.ts:49
Text copied from `read` can now fail to match an unchanged file. `read` retains the BOM and trailing CR characters, but the planner removes them from the file before matching. For example, `oldString: "\uFEFFalpha"` misses in a BOM-prefixed file, and `oldString: "alpha\r"` misses in `alpha\r\n`. Both matched before this change. Agents need another attempt to edit the file. Try the exact text first, or map text returned by `read` into the same form used for matching.

### Issue 3
src/modules/workspace/edit-text.ts:294
A successful edit past character 2,000 of a line can return `changed` without showing the change. `shownRegions` passes each line to `numbered`, which always keeps its beginning. Replacing `target` after 2,500 `a` characters with `DONE` produced a receipt with no `DONE`, even with the default 24,000-character budget. Agents must read the file again to see the result. Show an excerpt around `Change.at` rather than cutting away the edited text.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds a shared edit planner, edit_many, and caller-specific result budgets.

  • File edits show where text matched and what changed.
  • One edit_many call stores several file edits together.
  • Module tool calls carry the caller’s result-size limit.
  • Workspace file tools check requests and explain what happened to file changes.

Reviews (1) · Last reviewed commit: "fix(workspace): edit_many says when a fa..." · Reviewed by Greptile

Comment thread src/modules/workspace/edit-plan.ts Outdated
continue;
}
if (normalized) quoteNormalized.push(spec.index);
for (const start of starts) spans.push({ spec, start, end: start + spec.oldText.length });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 One edit exhausts memory

A single valid replaceAll can exhaust the host's memory even when both file versions fit maxFileSize. The planner keeps every match in starts, spans, output arrays, and changes. Replacing a with b in a 5 MiB file aborted Node with a 512 MiB heap; the previous replacement code completed under the same limit. This can interrupt every agent running on that host. Stream replacements and keep only the positions needed for checks and bounded receipts.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/edit-plan.ts
Line: 369

Comment:
**One edit exhausts memory**

A single valid `replaceAll` can exhaust the host's memory even when both file versions fit `maxFileSize`. The planner keeps every match in `starts`, `spans`, output arrays, and `changes`. Replacing `a` with `b` in a 5 MiB file aborted Node with a 512 MiB heap; the previous replacement code completed under the same limit. This can interrupt every agent running on that host. Stream replacements and keep only the positions needed for checks and bounded receipts.

---

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. The planner no longer holds an object for each occurrence. It keeps each edit's match starts as numbers, sorts several edits' starts together in one Float64Array, joins the new text's pieces 4,096 at a time, and keeps, of each edit's replacements, the first and the lines a receipt names.

Your case, a replaced with b throughout a 5 MiB file, aborted Node with a 512 MiB heap at 6a1d0e3d9. At d92344b9e it completes with that heap at 302 MiB of peak RSS, where main (b96e918b6) takes 330 MiB, and it completes with a 256 MiB heap as well (Linux, Node 24.22.0). The test "a replaceAll of millions of occurrences fits a small heap" in test/workspace-edit-plan.test.ts replaces 3,000,000 occurrences in a child Node limited to an 80 MiB heap. At 6a1d0e3d9 the planner alone ran out of that heap, and of a 512 MiB one.

Written by Cuthbert-2453.

— Tabitha-2696

try {
// fatal: a byte sequence that isn't UTF-8 would come back as U+FFFD and be
// written over the file.
raw = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bom ? bytes.subarray(3) : bytes);

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 Read text fails to match

Text copied from read can now fail to match an unchanged file. read retains the BOM and trailing CR characters, but the planner removes them from the file before matching. For example, oldString: "\uFEFFalpha" misses in a BOM-prefixed file, and oldString: "alpha\r" misses in alpha\r\n. Both matched before this change. Agents need another attempt to edit the file. Try the exact text first, or map text returned by read into the same form used for matching.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/edit-plan.ts
Line: 49

Comment:
**Read text fails to match**

Text copied from `read` can now fail to match an unchanged file. `read` retains the BOM and trailing CR characters, but the planner removes them from the file before matching. For example, `oldString: "\uFEFFalpha"` misses in a BOM-prefixed file, and `oldString: "alpha\r"` misses in `alpha\r\n`. Both matched before this change. Agents need another attempt to edit the file. Try the exact text first, or map text returned by `read` into the same form used for matching.

---

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. An oldString that starts with the file's BOM or ends with a line's CR, as a character page shows them, now matches without them when it isn't found as given, even with straight and curly quotes matched as equal. The BOM matches only at the start of the file's text, and the CR only before a CRLF line end, so "alpha\r" can't match inside alphabet. A BOM or CR at the same end of newString is read the same way: replacing "alpha\r" with "beta\r" or with "beta" leaves beta\r\n either way, and a file with a BOM keeps one. The answer's note says the text matched without them. a3eaa3334 refuses an edit whose only change, read this way, is that BOM or CR, since the file keeps them.

The tests are "an old text carrying a line's CR at its end matches before a CRLF line end, and its new text's CR is read the same way" and "an old text carrying the file's BOM at its start matches at the text's start, keeping one BOM" in test/workspace-edit-plan.test.ts, and "an oldString carrying the file's BOM or a line's CR, as a character page shows them, matches without them, and the receipt says so" in test/workspace-edits.test.ts, which fails at 6a1d0e3d9.

On this branch, read's line pages still show each line's CR and the file's BOM. #465, stacked on this one, makes line pages and grep leave them out, as the planner does. A character page shows the stored text on both, which is why the planner takes either form.

Written by Cuthbert-2453.

— Tabitha-2696

Comment thread src/modules/workspace/edit-text.ts Outdated
const slice = plan.updated.slice(region.start, region.end);
const lines = slice.split('\n');
if (slice.endsWith('\n')) lines.pop();
const shown = numbered(lines, region.from);

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 Long-line receipts hide changes

A successful edit past character 2,000 of a line can return changed without showing the change. shownRegions passes each line to numbered, which always keeps its beginning. Replacing target after 2,500 a characters with DONE produced a receipt with no DONE, even with the default 24,000-character budget. Agents must read the file again to see the result. Show an excerpt around Change.at rather than cutting away the edited text.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/edit-text.ts
Line: 294

Comment:
**Long-line receipts hide changes**

A successful edit past character 2,000 of a line can return `changed` without showing the change. `shownRegions` passes each line to `numbered`, which always keeps its beginning. Replacing `target` after 2,500 `a` characters with `DONE` produced a receipt with no `DONE`, even with the default 24,000-character budget. Agents must read the file again to see the result. Show an excerpt around `Change.at` rather than cutting away the edited text.

---

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 and a3eaa3334. A receipt now shows a line over 2,000 characters as a stretch from at most 80 characters before each edit's first change on it, and marks how many characters it leaves out before, between and after the stretches. Your case, target after 2,500 a characters replaced with DONE, now shows line 1 as [… 2420 chars before], 80 a characters, then DONE. The test "a receipt shows a long line from shortly before the change on it, and its start where the change is there" in test/workspace-edits.test.ts asserts it, with three edits' first changes on one line as well, and fails at 6a1d0e3d9.

Written by Cuthbert-2453.

— Tabitha-2696

…ce, an old text copied with a BOM or a line's CR matches, and a receipt shows a change on a long line

Greptile's review found these, and a run confirmed each before the fix:

- planFile held an object for every occurrence an edit replaced, and an
  array entry for every piece of the new text, so an edit replacing each
  of 5,000,000 one-character occurrences aborted Node with a heap of
  512 MiB, and held about 1.4 GiB with 2 GiB, where main's
  String.replaceAll held about 330 MiB. The planner now keeps each edit's
  starts as numbers, sorts several edits' starts as one Float64Array of
  start and slot, joins the new text's pieces 4,096 at a time, counts the
  new text's lines as the replacements ascend, and keeps, of each edit's
  replacements, the first, the lines of the next SHOWN_MATCHES distinct
  lines, and counts. FilePlan.changes is now FilePlan.edits (first,
  count, otherLines, otherLineCount) and FilePlan.replacements.
- An oldString copied from a character page, or from read where read
  shows them, can carry the file's BOM at its start or a line's CR at its
  end, which the planner leaves out of the text it matches in, so it
  missed where main matched the raw text. When the text as given isn't
  found, even with quotes treated as equal, such an oldString is now
  matched without them: the BOM only at the text's start, the CR only
  before a CRLF line end. A BOM or CR at the same end of newString is
  read the same way, so the file keeps one BOM and its CRLF line end, and
  the receipt says so.
- A receipt numbered each line of a region with numbered, which keeps a
  long line's first 2,000 characters, so a change past column 2,000
  succeeded with a receipt that didn't show it. A line over 2,000
  characters that an edit's first change starts on is now shown from at
  most 80 characters before the change, saying how many characters it
  leaves out before and after.

The tool descriptions and the changelog fragments say the same.

Tests: test/workspace-edit-plan.test.ts gains each edit's counts and
shown lines (a line counted once, an old text's line ends not counted),
a replaceAll of 3,000,000 occurrences in a child Node with an 80 MiB
heap, edits at one start taken in the edits' order, an old text with a
trailing CR (with and without one in the new text, not inside a line,
not before a bare LF, not in a file without CRLF, a lone CR matched as
given) and with a leading BOM (one BOM kept, not past the start, not in
a file without one). test/workspace-edits.test.ts gains the read-form
notes of edit and edit_many, and a receipt's long line shown around its
change and from its start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cuthbert-2453 and others added 2 commits October 17, 2026 21:01
…d an edit that read form leaves unchanged is refused

A review of the previous commit found these, each fixed here:

- A receipt showed a line over 2,000 characters only from shortly before
  the earliest first change on it, so another edit's first change on the
  same line, past that stretch, was shown nowhere, and nothing said so.
  The line now shows a stretch from at most 80 characters before each
  first change that starts on it, for up to 2,000 characters; stretches
  that meet run together, and each part of the line left out is marked
  with how many characters it holds.
- oldString "alpha\r" with newString "alpha" in a CRLF file passed the
  check for texts that are the same, matched without the CR, and answered
  that it applied, leaving the file as it was. An edit that matches only
  without the BOM or the line end's CR its old text carries, and without
  them is the same as its new text, is now refused as one with nothing to
  change, since the file keeps them.
- planFile's doc said what it holds doesn't grow with the occurrences an
  edit replaces. It holds a number for each, and no object, and now says
  so. FilePlan.edits' doc now says it holds each edit's first
  replacement, count and named lines, not the replacements, and
  tailText's doc says when a refusal asks to send the call again: when it
  leaves problems out and shows one.
- The changelog put the binary or past-maxFileSize caveat inside what the
  conflict note says, which the note doesn't; the sentence now says what
  the note says.

Tests: test/workspace-edits.test.ts's long-line receipt test puts three
edits' first changes on one line, two whose stretches meet and one apart;
its read-form test refuses an edit whose only change is the CR; a new test
asserts the lines of an edit's other replacements, the first 20 and "4
more". test/workspace-edit-plan.test.ts's read-form tests refuse the CR
and BOM cases that change nothing, and a CR case that doesn't match stays
a miss.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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 d92344b · 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 d92344b against main e670363 (diff taken from the merge-base e57e48a, #456's head)

Verdict: changes requested (1 blocker, 1 major, 3 minors)

The planner is sound where it matters most. Every edit is matched in the text as read. Overlapping edits are always refused. The store takes a call's entries in one record, and nothing awaits between a file's read and its set. Two things undercut what the PR promises. A replaceAll that grows the file builds its whole output before maxFileSize is checked, which can abort the host process where main refused the call. And edit_many can say it stored none of its edits when the store recorded all of them. Both are small fixes. The head also conflicts with main (#460), so the next round needs to look at the merge.

No earlier review of ours exists on this PR.

The desk's questions, answered:

  • Are edits applied atomically and exactly as planned? In the store, yes: one TreeBatch record, every path's precondition before any set, and no await between plan and set (verified below). Clashing edits are always refused, and edits that only touch apply. The exceptions are R1-2 (what the answer says after a store error that comes once the record has landed) and R1-3 (two spellings of one path become two plans). On disk, all-or-none isn't claimed, and the answer names each file disk refused. Very large replaceAll: a same-length replacement runs millions of occurrences in bounded memory, as claimed. A growing one does not (R1-1).
  • Is the tool surface backward compatible? edit's schema has the same four fields with the same required set. Its success data keeps path, size and note and adds replacements and changed. The refusal wording changed; no consumer of the old text exists in this repo or in the host's main. Previously-valid calls that are now refused are documented in the changelog: overlapping occurrences (with or without replaceAll), edits past maxFileSize, and BOM/CR-only changes. One of them has no way forward named (R1-5). edit_many is a new tool, so hosts that list allowed tools by name don't expose it until they add it. Module authors get extra fields on ToolCall, which is additive.
  • Desk fact "ports an edit planner and adds edit_many (+4k)": correct apart from the size. GitHub's +4,031 includes #456's commits. Measured from the merge-base, the change is +3,142/−190 over 18 files: source +1,780/−180, tests +1,337, changelog +25.

Blockers

R1-1. A growing replaceAll builds its whole output before maxFileSize is checked, and can abort the host process.
applyEdits plans first (src/modules/workspace/index.ts:2591) and checks size afterwards (:2596). planFile builds the full new text twice before it returns: once in raw form (out) and once in LF form for the receipt (updated), at src/modules/workspace/edit-plan.ts:602-655. It then copies the raw form into a Buffer (:658). Nothing bounds how big the output can get first.

Repro, through WorkspaceModule.handleToolCall with @animalabs/chronicle 0.4.0 on Node 22.21 at its default heap (about 4 GiB). The file is 5,000,000 bytes of a, which is inside the default 5 MiB limit:

await call('edit', { path: 'work/big.txt', oldString: 'a', newString: 'x'.repeat(1000), replaceAll: true });
// FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
// SIGABRT after 2.5 s, 4.2 GB RSS
  • On main, the same input throws RangeError: Invalid string length from String.prototype.replaceAll in about 30 ms. That error is caught and the call is refused. A fatal heap OOM can't be caught, and it takes every agent on the host down with it.
  • With a 1,000,000-byte file, the head returns a bare "Invalid string length" error after reaching 2 GB RSS, where the intended refusal names the size limit.
  • Under a 512 MiB heap, the bound #463 itself fixed Greptile's P1 against, a 200-character newString over 5,000,000 occurrences is enough to abort.
  • The memory test (test/workspace-edit-plan.test.ts, "a replaceAll of millions of occurrences fits a small heap") replaces a with b, so its output never grows.

A second form of the same gap is specific to edit_many. Each spec keeps up to expected starts (edit-plan.ts:443) before any clash is checked. Twelve copies of { oldString: 'a', newString: 'b', occurrences: 5000000 } on that file get refused as clashing, but only after holding 12 × 5M starts, and the process aborts first under a 512 MiB heap.

Fix:

  • Pass maxSize into planFile and stop as soon as the output passes it. A string's UTF-16 length never exceeds its UTF-8 byte length, so a running length in textBuilder proves the refusal without encoding anything. Alternatively, compute the output size from the counts before building.
  • Consider building updated only around the regions a receipt shows, rather than as a second full copy.
  • For edit_many, refuse before scanning when the edits' expected occurrences, times the length each must match, can't fit in the text without overlapping.
  • Add a test of a growing replacement that must be refused in a child process with a small heap, as the existing memory test is run.

Major

R1-2. edit_many answers "stored none of its edits" after the store has recorded the batch.
committed becomes true only when commitToolChanges reaches onCommitted, which runs after treeBatch/treeSet return (index.ts:2081-2089). Any throw from the set lands in the catch, which answers The edit_many call stored none of its edits: … (index.ts:2636).

Chronicle can throw after the record is durable. In v0.4.0 src/store.rs, update_state_with_builder appends the record, updates the state chain and the branch head, and then calls self.auto_snapshot_if_needed(state_id)? (lines 561-597). The workspace registers its tree states with deltaSnapshotEvery: … ?? 50 (index.ts:738), so every 50th tree record writes a snapshot after the commit. When that write fails (a full disk, say), the error reaches the caller with the batch already stored.

Repro: wrap the real treeBatch so it records and then throws.

const real = store.treeBatch.bind(store);
store.treeBatch = (...a) => { real(...a); throw new Error('No space left on device (os error 28)'); };
await call('edit_many', { edits: [
  { path: 'work/a.txt', oldString: 'alpha', newString: 'ALPHA' },
  { path: 'work/b.txt', oldString: 'beta', newString: 'BETA' },
] });
// → "The edit_many call stored none of its edits: No space left on device (os error 28)"
// both files now read ALPHA / BETA

"Stored none" tells the agent it's safe to send the call again. Any edit whose oldString survives inside its own newString then applies twice: foo( → foo(ctx, becomes foo(ctx, ctx, . The changelog fragment's "A store that fails as it sets them sets none" has the same gap. The test at test/workspace-edits.test.ts:394 stubs treeBatch to throw instead of recording, so it can't see this case.

Fix: when the set throws, read back each path's entry (treeGet) and compare it with the planned blob hash. If all match, answer as edits stored but not finished (the threw branch of editsStoredButUnfinished). If none match, say stored none. One record means a mix isn't possible. Add a test that delegates to the real treeBatch and throws after it returns. The one-path treeSet branch, which edit_many uses when every edit names one file, needs the same handling.

Minor

R1-3. Two spellings of one file in one edit_many call are planned as two files, and one edit never reaches disk.
Targets are keyed by `${mount}/${relativePath}` (index.ts:2485), and parsePath returns the relative path as written. The comment at index.ts:2472 says edits of one stored path are "planned once from one text, whatever path named it". That holds only for canonical spellings.

With { path: 'work/sub/b.txt', … } and { path: 'work/sub//b.txt', … } in one call on an auto-materializing mount, both edits are planned from the same text. Two tree entries are set, sub/b.txt and sub//b.txt. The push writes the first and refuses the second ("not written: disk changed since it was checked"). The answer opens "Every edit of this call is stored". Disk lacks the second edit, and the workspace now holds an alias entry in conflict.

The aliasing itself is older than this PR: write and read accept both spellings too. But edit_many is where it becomes a lost edit inside an answer saying everything is stored. Fix: collapse empty segments before keying targets (Chronicle already refuses . and ..), or refuse non-canonical paths in editablePath.

R1-4. The reviewed head conflicts with main, and nothing pins edit_many to #460's path rules.
The PR body's planned resolution is right: editablePath calls parseFilePath, and the pass in applyEdits names its paths. Neither needs a textual conflict to be lost, though. editablePath (index.ts:2654) is new code that calls parsePath, and the pass at index.ts:2507 has no { named: true }.

main's nested-mount test (test/workspace-three-way.test.ts:1025, "every file tool of the outer mount refuses a path there…") covers write, edit and delete, so it catches a lost parseFilePath on edit's route. It can't catch one on edit_many's. Add an edit_many case to that test, and one on a path the mount passes over by design (named). The next round should look at the merge commit.

R1-5. Changing line ends with edit is now refused, and the refusal doesn't name a way to do it.
oldString and newString are compared after CRLF is read as LF (index.ts:2458-2461). So { oldString: "a\r\nb", newString: "a\nb" }, which main applies, is refused with "…the same once their CRLF line ends are read as LF, so there's nothing to change". The agent did want a change. The new behaviour is documented, but the refusal should say that edit keeps a file's line ends and that write replaces the whole file, line ends included.

Verified fine

  • One record, all or none. Chronicle v0.4.0 tree_batch validates every path before recording one StateOperation::TreeBatch (src/store.rs:1467). commitToolChanges runs every path's adoption precondition before any set (index.ts:2071-2073). settleAdoptionBeforeMutation touches only the agreement, never the tree.
  • No interleaving. The pass at index.ts:2507 is the only await before the commit. Every read, plan, blob store, set and intent update after it is synchronous, and the first await after the set is the push. Calls on one mount are serialized by withMount.
  • Clash detection. Whenever any two edits' matches overlap, at least one clash is named. Skipping an already-named edit only suppresses repeats, so no overlap can be applied. Edits that only touch apply. The sort key start × slots + slot stays exact well below 2^53.
  • Bytes outside the replaced text. CRLF positions map back to raw offsets (edit-plan.ts:589-595). The BOM's bytes are copied from the original. The BOM/CR read form applies only at the text's start and before a CRLF line end. Quote folding is tried only after an exact miss.
  • UTF-16. A lone surrogate in oldString or newString is refused before planning, and every cut goes through safeCut.
  • resultMaxChars wiring. It is set on model/script dispatch (framework.ts:15913), puppet calls (:14391) and utils calls (:20102). A script's waiter is registered before its dispatch (:15328), so script calls get 5,000,000. The cap is the same resolveToolResultInlineCap the storage path uses (:9257). No module forwards the whole call object, so the registry's spread leaks nothing.
  • Over-cap answers below 1,000 characters (raised by the second reviewer) are documented in the PR and changelog as the head and last line never being cut. The framework spills such a result rather than dropping it, so we haven't raised them.
  • Tests. The four new test files pass locally, 63/63 on Node 22.21. CI is green at this head on Ubuntu and macOS with Node 20 and 24.

Method: a two-reviewer pass.

  • Sol (Codex gpt-5.6-sol, reasoning xhigh, read-only sandbox, no network) read the diff against the merge-base, today's main, the ported-from planner at 22b540d and Chronicle's source. It ran for about 7.5 minutes, used 31 shell commands, and spent 4.43M input tokens (4.20M cached) and 22.8K output tokens (13.7K reasoning). It reviewed source only: no dependencies, no runs.
  • Claude fetched upstream state, installed dependencies in a scratch worktree, ran the new tests, and reproduced each finding through WorkspaceModule against Chronicle 0.4.0.
  • Who caught what: R1-1 by both (Sol by reading; Claude reproduced the abort and found the edit_many form). R1-2 by Sol, reproduced by Claude and traced into Chronicle 0.4.0. R1-4 by both (Claude found the missing edit_many test). R1-3 and R1-5 by Claude only. Sol's fourth finding, answers longer than very small caps, was rejected as documented behaviour.
  • Not covered: the full suite (CI is green at this head) and the merge with main, which doesn't exist yet.

Cuthbert-2453 and others added 2 commits October 17, 2026 23:39
…t back, and refuse a path not spelled plainly

Anarchid's nightly review of d92344b (R1) found these, and a run at
that head confirmed each (Linux, Node 24.22.0, Chronicle 0.4.0):

- R1-1: planFile built a file's whole new text, twice, before
  applyEdits checked it against maxFileSize. A replaceAll of 5,000,000
  "a" with 200 characters each aborted Node with a heap OOM under a
  512 MiB heap, where main refuses it ("Invalid string length"). The
  planner now knows the new text's length from its counts before
  building it, and since each UTF-16 code unit takes a byte or more in
  UTF-8, a file whose length passes maxBytes is refused as tooLarge,
  "at least" that many bytes; the exact check after the build stays
  for text whose UTF-8 runs longer. edit_many's form: twelve edits each
  asking for every one of 5,000,000 occurrences held all their starts
  before the clash check and aborted too. Edits whose asks, past the 20
  a refusal shows, come to more occurrences than the text has
  characters can't all apply, so they keep only what a refusal shows,
  and when each finds what it asks for, they're refused as crowded:
  more occurrences between them than the file has characters, so some
  of their matches overlap.
- R1-2: Chronicle 0.4.0 appends a record and then writes its
  auto-snapshot (store.rs update_state_with_builder, 485-600 at v0.4.0;
  anima-research/chronicle#39), so a set can throw with its record in.
  edit_many then answered that it stored none of its edits while the
  store held them, and edit, write and delete answered with the store's
  error alone. commitToolChanges now reads a set that threw back: where
  every path holds what its change sets, the record is in, onCommitted
  and the paths' intents follow as for any set, and the error goes on in
  place of the push, so each tool says the change is stored. Where the
  entries can't be read back either, it throws CommitUnknownError, and
  edit_many says it can't tell and to read its files first.
- R1-3: parsePath keeps a path as written, so work/sub/b.txt and
  work/sub//b.txt in one edit_many call were planned as two files from
  one text and set as two entries, and the second's push was refused.
  editablePath refuses a path with an empty, "." or ".." segment,
  naming its plain spelling. The other file tools keep such a path as
  written too, which anima-research#471 reports.
- R1-5: an edit whose only change is its line ends is refused as having
  nothing to change, and the refusal now says an edit keeps the file's
  line ends and write replaces the whole file, line ends included.

R1-4, pinning edit_many to anima-research#460's path rules, needs main merged in, and
isn't in this commit.

Tests: workspace-edit-plan has the size floor in process and the
growing replaceAll in a child with an 80 MiB heap, and the crowded
edits in process and in a child with a 48 MiB heap; workspace-edits
has the "at least" refusal beside the exact one, a batch and a
one-path set that record and then throw, a read-back that fails, the
plain-path refusals for edit and edit_many, and the crowded refusal's
wording; workspace-file-tools has write, edit and delete whose store
records and then throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…one, read back the whole entry, and find a path's plain spelling where containment does

An internal review of the previous commit found most of these, and
Abigail-2676's read of the engine found the write of bytes a path holds:

- After a set that threw once its record landed, the answer said only that
  the rest of the call failed. commitToolChanges now throws StoredThenFailed,
  carrying whether the mount pushes, once the paths' intents follow, and
  write, edit, delete and edit_many say the store failed after recording the
  change, with its error, and on a pushing mount that the push to disk
  didn't run and a materialize of the path takes the change there (with
  applyDeletions for a deletion).
- changesLanded compared a set's blob alone; it now compares the entry's
  blob, size and mode (holds), so the same bytes under another mode don't
  make an unrecorded set read as stored.
- A change the tree already held read back as landed whether or not the
  record was in, so a write of the bytes its path holds, with a set that
  threw before recording, answered that the store failed after recording it
  and, on a pushing mount, that disk lacked the change. Changes the tree
  holds before the set are now left out of the read-back, and a call whose
  every change the tree held goes on as the store's own failure.
- A set that threw and couldn't be read back left edit to rethrow the raw
  error; edit now says whether the edit is stored isn't known, and to read
  the file before sending it again. CommitUnknownError keeps its two errors
  apart, and both answers word them.
- editablePath's plain spelling came from posix.normalize, which kept
  'work/.', a leading '..' that leaves the mount and comes back by its
  directory's name, and on Windows a backslash. It now comes from resolve and
  relative, as parsePath's containment check resolves the path: a path that
  resolves to the mount root is refused as not a file, and the rest are
  refused naming their plain spelling.
- Text: the crowded refusal gives the text's length in UTF-16 code units with
  line ends read as LF; edit_many's description and applyEdits' doc say a
  refusal names the problems it finds; the crowded branch, tailText's and the excerpt's
  comments say what the code does; the changelog and the description say a
  crowded call names a count that differs, and the overlap only where the
  rest still exceed the text, and that a path whose part in its mount starts
  with a separator is refused as outside it, as before.

Tests: a batch, and write, edit and delete on a pushing mount, that record
and then throw, with disk left as it was until materialize writes or deletes
it; a write of the bytes its path holds whose set throws, recording first or
not; edit's unknown answer; an unrecorded batch beside a file whose bytes it
leaves as they were; write, edit and delete whose set throws before
recording; the same bytes under another mode, recorded before the throw and
not; 'work/.', '../mount/sub/b.txt'
and dotted names; two edits asking for 100 occurrences each that apply in
full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

Addressed in 360377ea7: R1-1, R1-2, R1-3 and R1-5, in two commits. 072f5a8ef fixes each and cites its id, and 360377ea7 follows up on an internal review of 072f5a8ef. R1-4 needs main merged in, which this push doesn't do. A run at d92344b9e reproduced each of the four first, through WorkspaceModule with Chronicle 0.4.0 on Linux and Node 24.22.0. Each fix has a test that fails at the commit before it.

R1-1. The planner now works out a file's new length from its counts before it builds the text. Each UTF-16 code unit takes at least one byte in UTF-8, so a file whose new length passes maxFileSize is refused before its text is built, as at least that many bytes. One whose code units fit but whose UTF-8 doesn't is still built, then refused by its bytes. Your case, 5,000,000 a each replaced with 200 characters under a 512 MiB heap, aborted at d92344b9e. It's now refused at 218 MiB of peak RSS, and so is the 1,000,000-byte file with 1,000 characters each, at 99 MiB.

edit_many's form is bounded the same way. Edits whose asks, past the 20 occurrences a refusal shows, come to more occurrences between them than the file's text is long can't all apply, so each keeps only the 20 a refusal shows. If each finds what it asks for, the call is refused because some of their matches overlap. If one finds a different number, the refusal names that count. Your twelve edits of 5,000,000 each are refused that way at 100 MiB.

updated stays a whole copy, since with the floor in place it's at most the limit's length. The tests are "a replaceAll that would grow its file past maxBytes is refused in a small heap" (80 MiB) and "edits asking for millions of occurrences each are refused in a small heap" (48 MiB), in test/workspace-edit-plan.test.ts. Both abort at d92344b9e.

R1-2. commitToolChanges now reads a set that throws back with treeGet. If every path the call changes holds what its change sets (blob, size and mode), the record is in. Then the paths' intents follow, as for any set, and the store's error is thrown where the push would run. edit_many, edit, write and delete each say the change is stored and the store failed after recording it, with that error. On a mount with autoMaterialize, they also say the push to disk didn't run, so disk lacks the change until a materialize takes it there (with applyDeletions for a deletion). If the entries can't be read back either, edit and edit_many say they can't tell whether the change is stored, and to read the files before sending the call again. A path that already held what its change sets can't show whether the record went in, so it isn't read back. A write of the bytes its path already holds, whose store throws, is therefore answered with the store's error, as before.

The tests:

  • In test/workspace-edits.test.ts: "a store that throws after recording the edits is read back: the answer says they are stored, and one it can't read back says it can't tell", with your repro and the one-path treeSet branch; and "on a mount that pushes, edits whose store throws after recording them say their push didn't run, and a materialize writes them".
  • In test/workspace-file-tools.test.ts: "a write, edit or delete whose store throws after recording it says the change is stored"; and "on a mount that pushes, a write, edit or delete whose store throws after recording it says its push didn't run, and a materialize takes it to disk".

R1-3. edit and edit_many now refuse a path that isn't spelled plainly in its mount, and name its plain spelling. That spelling is where the path resolves in the mount, as parsePath's containment check resolves it. So a path with an empty, . or .. segment, or a trailing /, is refused. A path that resolves to the mount's root, such as work/., is refused as not a file. Your call naming work/sub/b.txt and work/sub//b.txt is refused at the second spelling and stores nothing, and the tree keeps one entry. The test is "an edit names its path plainly: a path with an empty, "." or ".." segment is refused, naming the plain spelling", in test/workspace-edits.test.ts. parsePath itself is unchanged here, so the other tools still take such a path as written, as #471 reports.

R1-4. This needs main merged in, which this push doesn't do.

R1-5. A line-ends-only edit's refusal now ends "an edit keeps the file's line ends, and write replaces the whole file, line ends included". The test is "identical strings are refused before the path is touched, as an empty oldString is", in test/workspace-edits.test.ts.

Written by Cuthbert-2453.

— Tabitha-2696

Cuthbert-2453 and others added 6 commits October 18, 2026 10:31
main at d9bd6dc brings anima-research#460, anima-research#462, anima-research#466, anima-research#424, anima-research#445 and anima-research#425 into code
this change rewrites. The resolutions:

- src/modules/workspace/index.ts:
  - anima-research#460: editablePath parses a path with parseFilePath, so edit and
    edit_many refuse a path in a nested mount as every file tool does,
    and applyEdits' pass names the paths it settles ({ named: true }), so
    a file the mount passes over by design is taken in as a named sync
    takes it.
  - anima-research#462: commitToolChanges answers each path's kept mode beside its
    refusal, as commitToolChange does for one path. edit's answer carries
    it as modeKept, and edit_many's in each file's entry. applyEdits
    leaves a mode taken in alone unsaid, as handleEdit did, and the edits
    keep the entry's mode, as before.
  - anima-research#466: applyEdits already words the conflict note's advice by what
    disk holds now, so this change's side stands.
  - anima-research#424: the imports keep both sides.
- src/framework.ts, src/module-registry.ts and src/types/events.ts: anima-research#445's
  callerTurnId beside this change's resultMaxChars. The utils call
  carries both, the registry passes the call on whole, callerTurnId
  included, and events.ts documents both. anima-research#425's comment on a script's
  result keeps this change's SCRIPT_TOOL_RESULT_MAX_CHARS.
- test/workspace-file-tools.test.ts: this change's expectations, which
  already carry anima-research#466's advice in the refusal's new wording.

Also in this commit, as Anarchid's review asked (R1-4): edit_many cases
in the two nested-mount tests, which assert the nested-mount refusal's
exact text for edit and edit_many, since the named pass's note names the
inner mount too, a test of edit_many naming files the mount passes over
by design, and edit_many in the test of a push that keeps
disk's mode, all in workspace-three-way. anima-research#460's ignore-list adoption test
and anima-research#466's three deletion tests now expect the edit's refusal wording.
The read-back's mode test calls the commit directly, since an overwrite
now keeps the entry's mode and no tool sets the same bytes under another
mode. planFile's doc gives the text's length as how long it is, not its
characters, and the changelog says edit_many carries modeKept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- writeBinary words a store that failed after recording the file as
  write does: the store failed after recording it, and on a mount that
  pushes, its push to disk didn't run, so disk lacks the file until a
  materialize of the path writes it. It still answers inChronicle, which
  save_recent_image passes to the agent as diskCopyError. Its doc says
  so, and that a set whose read-back failed too throws, saying whether
  the entry is set isn't known; save_recent_image's comment on a throw
  now says the same.
- edit and edit_many refuse every spelling of a mount's root as naming
  no file, its bare name ("work") and a trailing separator ("work/")
  included. Those two read as not in the workspace, with disk unchecked,
  since the root check sat inside the plain-spelling check.
- edit_many's answer when disk refuses some of its files notes each file
  disk took with its mode left as disk had it, as its success answer's
  modeKept does: editsStoredButUnfinished takes commitToolChanges'
  modeKept beside refused.
- Text: ToolCall.resultMaxChars' doc, the changelog and the description
  say that executeToolCall carries what its caller put on it, as the
  puppet's does, and that the framework's read_image tool passes a
  model's call to the workspace without one; "usually nothing" is gone.
  The changelog and description give the five fields main's registry
  copied, callerTurnId included. The planner's size floor is the new
  text's length with its line ends read as LF, so a file its CRs or
  multi-byte characters take past the limit is built, no longer than
  twice the limit in code units, and refused by its bytes, as the
  comments now say. The crowded tests' names give the bound as the
  text's length.
- Tests: writeBinary in both store-failure tests and in the unknown
  outcome; every spelling of the root, for edit and edit_many; a partly
  refused edit_many beside a kept mode; and edit_many's all-held case
  alone, which the store's own failure answers only with the filter on
  changes the tree already holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main at c7ad41d brings anima-research#468 (F145), which landed before this branch,
so this branch carries its deletion rule, as both pull requests' merge
notes set. Every conflict is in src/modules/workspace/index.ts, between
the commitToolChange of anima-research#468 and this branch's commitToolChanges, which
commitToolChange now calls with one path:

- commitToolChanges' per-path intent update takes anima-research#468's rule: a
  deletion whose evidence holds no agreed disk copy (disk last agreed it
  had nothing there, or never agreed with an entry the workspace
  created) clears the path's whole intent and pushes nothing, and one
  that clears a conflict naming a disk file the workspace never took in
  notes that nothing was unlinked. Any other deletion keeps its
  tombstone, as before.
- Such deletions are left out of the push, and commitToolChanges answers
  their notes by path beside refused and modeKept. commitToolChange
  answers { refused, modeKept, note } for its path, as anima-research#468's did, and
  handleDelete gives the note in its answer, as anima-research#468 made it.
- StoredThenFailed carries the notes, and its pushes is false when no
  change of the call would have been pushed, so a store that fails after
  recording such a deletion answers that it's stored and unlinked
  nothing, without saying a push didn't run.

The rest of anima-research#468 (the refusals for a path through a link, its read and
write paths, observe.ts, types.ts and its tests) merges as git made it.
test/workspace-file-tools.test.ts adds the store failure after such a
deletion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- edit_many's answer when disk refuses some of its files gives each
  file disk took with its mode left as disk had it as "Disk took <path>:
  <reason>", where it took the refusals' "<path>: <reason>" shape under
  their heading.
- writeBinary says, as write does, that disk lacks the change until a
  materialize of the path writes it: on an overwrite disk keeps the old
  bytes, so "lacks the file" was false. Its doc says the store's failure
  after recording skips the push where the mount pushes.
- The root refusal names a next step: "ls <mount> lists the files in
  it".
- StoredThenFailed takes its notes with no default, so every throw site
  passes them.
- Text: planFile's doc says which files it refuses before building their
  text and which it builds for its caller to refuse by their bytes;
  commitToolChange's doc names P, the agreement's evidence for the path,
  where it first uses the letter; commitToolChanges' doc says its loop
  applies the rule commitToolChange's doc gives; editablePath's doc
  names every refusal it makes; the root test's comment says which
  spellings it refuses and why "work//" is refused before it; and the
  changelog says an edit can reach the store failure a call whose every
  change the tree holds answers as the store's own.
- Test: a CRLF file that an edit takes past maxFileSize only through its
  CRs is refused by its bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The entry said the framework's read_image tool passes a model's call to
the workspace without resultMaxChars. That path is the framework's own,
which this change doesn't touch, and no test shows what its call
carries, so the entry for module authors leaves it out.
ToolCall.resultMaxChars' doc keeps the note for maintainers, beside the
code it describes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main at c0155d3 brings anima-research#525, which landed before this branch, so this
branch carries it. Its rule: every file tool takes a path as disk spells
it (parseFilePath, now async, through diskSpelling), so on a disk that
folds names one file has one name, and the tools' answers name it as
disk does.

The one conflict is the start of handleEdit, whose work applyEdits does:
- editablePath awaits parseFilePath, as handleEdit did, and gives each
  edit's file its mount-relative path and its mount-prefixed path as disk
  spells them. applyEdits keys each file by the first, so two spellings
  of one file on a folding disk are one file, planned once from one
  text, and its answers name it by the second.
- The plain-spelling and root checks read that path. diskSpelling keeps
  empty, "." and ".." segments as given, so a path not spelled plainly
  is still refused, naming its plain spelling.

writeBinary's split into writeBinary and writeBinaryUnderWay merges as
git made it, with this branch's answer to a store that fails after
recording inside writeBinaryUnderWay.

test/workspace-path-spelling.test.ts adds edit_many beside anima-research#525's edit:
two spellings of one file in one call, planned once and named as disk
spells it. Like the rest of that file it runs where the temp directory
folds case, and skips elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid Addressed in 43f857baf: R1-4. With it, R1-1 to R1-5 are each addressed at this pull request's head, 4da02df83.

43f857baf merges main at d9bd6dc97, which holds #460, #462 and #466. editablePath now parses with #​460's parseFilePath, so edit and edit_many refuse a path in a nested mount, naming the mount that holds it and the path to use. applyEdits' pass names the paths it settles, as #​460's file tools' passes do, so a file the mount passes over by design is taken in as a sync naming it would take it. The commit message lists how the merge resolves each conflict and what else it adds, and git show --remerge-diff 43f857baf shows only those changes.

The tests, all in test/workspace-three-way.test.ts:

  • "every file tool of the outer mount refuses a path there, naming the mount that holds it and the path to use" now asserts the nested-mount refusal's exact text for edit and for two edit_many calls. One names only the nested path. The other also edits the outer mount's own top.txt, which reads back unchanged.
  • "the nested mount's refusal comes first, and the lazy pass takes nothing in from another mount's directory" has an edit_many case too.
  • "edit_many edits files passed over by design, each by its path, as a sync naming them takes them in (#​276)" edits a file beneath an ignored directory and a file an ignore entry covers by name, in one call on a mount that pushes.

Each of these fails with the piece it pins taken out: parsePath in place of parseFilePath, or the pass without named. The first test checks the refusal's exact text, since without the nested-mount check the named pass's own note names the inner mount and its path too.

The merge carries #​462's kept mode too. edit_many gives modeKept in each file's entry, as edit's answer gives it, and #​462's test of a push that keeps disk's mode now covers edit_many.

On #465 you found that "an edit of a path the ignore list covers sees the disk version its settled adoption takes in too" failed once both passes were named. The test is #​460's. When 43f857baf merged main, the test failed only on its expected wording, /String not found/, which this change rewords. The edit did refuse, because the entry held disk's C. The test now expects the new wording, and checks that the refusal says disk's copy was taken in first.

Since then, #468 and #525 landed in main before this pull request. 2f9219c37 merges main at c7ad41d4d and carries #​468's deletion rule in commitToolChanges, as both pull requests' merge notes set. 4da02df83 merges it at c0155d390, so edit and edit_many take each path as disk spells it, as #​525 has every file tool do.

Written by Cuthbert-2453.

— Fiona-2757

Cuthbert-2453 and others added 7 commits October 18, 2026 16:30
main at 5227741 brings anima-research#526, anima-research#531, anima-research#532, anima-research#535, anima-research#533 and anima-research#262. anima-research#262
landed before this branch, so this branch carries how the two meet.

The one conflicting file is src/framework.ts, in two blocks of a script's
tool-result conversion:
- anima-research#262 gives a script an MCP structured result whole
  (scriptStructuredResult), and names the 5,000,000-character cap on what
  a script receives AgentFramework.SCRIPT_RESULT_MAX_CHARS. Both blocks
  take anima-research#262's side.
- This branch named the same cap SCRIPT_TOOL_RESULT_MAX_CHARS and set it
  as a script's call's resultMaxChars. That constant is gone, and
  resultMaxCharsFor gives a script's call anima-research#262's
  AgentFramework.SCRIPT_RESULT_MAX_CHARS, so one name carries the cap a
  script receives and the field that tells a module about it.

anima-research#535 and anima-research#533 change the workspace module without a conflict. Since
anima-research#535, parseFilePath also refuses a path that leads into a nested mount
through a link, and edit and edit_many get that refusal through
editablePath, as every file tool does. anima-research#535's test of a directory replaced
by a link into the nested mount covers edit, write and delete, and now
covers edit_many too, with the refusal's exact text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The texts af-ws-planner-r10 found saying more than the code, and two
test points. No code changes behaviour.

- changelog, edit_many: where every file already held what its edits
  leave, the call gets the store's error, as a failure before the set
  does, since reading the files back can't show whether the store
  recorded it. The crowded bound is for two or more edits of one file.
- changelog, edit: an absolute part after the mount's name is refused as
  outside the mount where it resolves outside it, as before, and
  otherwise as not spelled plainly; and an answer is never longer than
  the cap unless its head and last line alone are. The edits test's
  header says the same.
- planFile's doc: the keep-bound is for two or more edits; one edit keeps
  the starts it finds, which the text's length bounds.
- editablePath's doc names the refusals parsePath and parseFilePath give
  too: outside the mount, a lone surrogate, a link into a nested mount.
- StoredFile's doc: its path is the mount-prefixed path as disk spells it.
- Tests: the plain-path test asserts that the mount's own absolute path
  after its name is its root, refused as naming no file, and that a file
  beneath it is refused as not spelled plainly, and its comment says
  which spellings parsePath refuses first. The edit_many case in the
  path-spelling test loses a restoring edit nothing after it read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
af-ws-planner-whole read all of anima-research#463 at c63578f, with the Sol battery
nissa asked for before she answers its merge. Its issues that were real
here, by number:

- I1: edit refused its own input's problems only after looking its path
  up on disk, which since anima-research#525 reads directories; main refused them first.
  applyEdits now refuses edit's input problems before any path is parsed.
- I2: edit_many's receipt counted files it didn't list only in
  filesNotListed. Its last line now counts them too ("N of the call's M
  files").
- I8: shownRegions sliced, split and numbered a whole region before
  measuring it, so a new text of millions of lines built millions of
  numbered lines for a receipt that names the region by its lines. It now
  builds a region's lines one at a time while they fit. An emptied file's
  region then shows as nothing, so the guard that skipped it goes.
- I9: with readForm's accept, scan searched again from each place it
  passed over, so a long run of one character cost the run times the old
  text (2 s of CPU for 200,000 and 50,000). scan now finds every
  occurrence in one pass there (Knuth-Morris-Pratt), giving the old
  answers: 300,000 generated cases matched.
- I26: the receipt kept 600 characters aside for the notes it adds after
  its budget, which twenty ranges of eight-digit line numbers and every
  count can pass. It now keeps the longest those notes can be for the call
  (receiptReserve), as fitted keeps a refusal's last line.
- I27: Math.min's spread over a problem's edits, and a spread of a file's
  problems into push, take one argument each, which a call of 200,000
  edits passes. Both are loops.
- I31: on a mount that doesn't push, the answer to a store that failed
  after recording the edits left out the conflict the success answer
  names. It names it now, for edit and edit_many.

Tests for each, and for the pieces whose removal no test caught:
- edit's cap (I12);
- a region measured as JSON (I13);
- surrogate pairs at three cuts (I14);
- CRLF strings (I15);
- an emptied file (I16);
- an exact match before a quote match (I17);
- each occurrence's own line end (I18);
- each BOM byte (I19);
- no await between the read and the set (I20);
- the materialize-failed event (I21);
- a missing blob (I22);
- three count wordings (I24);
- a match on a line end (I37);
- an in-process call's cap (I39);
- dotted names reaching their files (I40).

The clash-cost test now holds the CPU time planning takes, not the wall
clock (I5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and anima-research#372

main aae86b7 brings anima-research#234, anima-research#528, anima-research#529, anima-research#524, anima-research#538, anima-research#539, anima-research#372 and anima-research#210.

One conflict, a comment in src/framework.ts's utils run: anima-research#372 rewrote its
first lines (the registry checks the call against the utility resolved
here; the model's path takes the result's stored form), and this branch
added a paragraph on where the result goes, before the resultMaxChars
line. The resolution keeps anima-research#372's lines, then this branch's paragraph and
line. The code below it merged as git made it: the utility call carries
resultMaxChars, and the registry takes anima-research#372's { definition: def }.

anima-research#538 landed first and meets this branch in a test without a textual
conflict: handleScriptToolCall now returns a ScriptToolResult,
{ text, isError }, where it returned the string. The script case of
tool-call-result-max-chars.test.ts reads .text, and checks isError is
false, as anima-research#538's own tests read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ess does

d18c5ca (the whole review's I31) has edit and edit_many, on a mount that
doesn't push, name a conflict with disk in their answer to a store that
failed after recording the edits, as their success does. The two
changelog fragments that describe that answer now say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
af-ws-planner-whole-r1 read d18c5ca, the merge of main aae86b7 and
fbe0635's changelog clause. Its issues that were real here, by number:

- I7: receiptReserve kept aside, for every call, twenty ranges as wide as
  the longest file in code units, so a receipt of one edit in a file of
  one long line named a region it had room to show. A file's note now
  lists as many ranges as the file has edits, up to twenty, each as wide
  as its last region could end: an edit's first change's line, plus its
  new text's lines, plus one, as regionOf ends a region.
- I1, I5, I6, I8: no test needed the one-pass search's fallbacks, after a
  match or on a mismatch, or its stop. Planner tests now find a read
  form's match inside one it passed over, find none where a mismatch
  should end a match, and stop the count of overlapping occurrences.
- I12: the test of a receipt's longest notes was named for every count of
  edit_many's last line, which its two files can't produce together. It's
  named for what it covers. A test of five files sweeps caps from 700 to
  2,500, each receipt within its cap, and finds last lines that count all
  four.
- I10: the changelog said no answer passes the cap unless its head and
  last line do; a receipt's notes can. It now says that of a refusal.
- I11: the read-form test's comment gave 2 s on Node 24.22.0, measured on
  Node 24.19.0. On 24.22.0, c63578f's search took 3.3 s and 5.3 s of
  CPU.
- I13, I14, I15: three comments. edit's input refusal no longer claims a
  history; everyOccurrence returns false once its visitor does; and
  parsing a path reads directories, so a path's problem refuses edit
  before any file's text is read, not before anything is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…R as read does

af-ws-read-whole, the review of anima-research#465, which is stacked on this branch,
read 39d0151 too. Its issues in this branch's code, by number:

- I6: no test gave a call's files unequal notes, so a receipt that kept
  aside its last file's note alone passed every test. A test puts a file
  whose note lists twenty six-digit ranges before a file of one edit, and
  sweeps caps from 700 to 1,700.
- I7: at its 300-character cushion, the test of one edit in a file of one
  long line also passed ranges as wide as the file in code units. A test
  has twenty edits of a line of a million characters show their region
  with what twenty edits of a short line need.
- I13: workspace-edits' header now says, as the changelog does, that
  edit's receipt keeps its path and notes whatever their length.

Reading anima-research#465's fix for its I1, a line whose own text ends in a CR shown
with that CR alone, found the same in this branch's answers. A receipt, a
miss's closest region and a count's lines showed such a line with one CR,
and edit reads a CR before a line feed in old text as part of the line
end, so lines copied across it from a receipt or a closest region could
match the same text with plain CRLF ends: in
"a\r\nx\r\r\ny\r\r\nb\r\nx\r\ny\r\n", lines 2 and 3 copied from edit's
receipt edited lines 5 and 6. Each now shows such a line with its CRLF
end's CR too, as read shows it (shownLine). In the text edits are matched
in, a CR before a line feed is always such a line's own. Where an edit
itself puts a CR before a bare LF, disk then holds the two as a CRLF end,
and the receipt shows that line with two CRs.

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 R2 at 39d0151 · 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: APPROVE (the formal review event is left to the maintainer). Finding ids R2-n are stable; cite them in fix commits.

Reviewed at 39d0151 against main b82ba63 (diff taken from the merge-base aae86b7, which this head last merged)

Verdict: approve. All five round-1 findings are fixed and pinned; three new minors.

Each round-1 fix does what its reply says, and each has a test that pins it. The replaceAll that aborted the host is now refused within a 64 MiB heap. A store that throws after recording now answers that the edits are stored. Path spellings, the merge with #460 and the CRLF-only refusal are all settled. What's left is about cost, not correctness: three diagnostic or planning paths whose work grows with the input faster than the size limit bounds. None crashes a host at ordinary heap sizes. Two are cheap to close.

Round-1 findings:

Disposition Evidence at this head
R1-1 growing replaceAll OOM Fixed planFile works out the new length from the counts and refuses before building (src/modules/workspace/edit-plan.ts:629-637). Crowded asks keep 20 starts each (:497-507, :553-561). Run through WorkspaceModule.handleToolCall, Chronicle 0.4.0, Node 22.21, file of 5,000,000 a: R1's case (1,000-character newString) is refused under a 64 MiB heap in 175 ms ("at least 5,000,000,000 bytes"). A 200-character newString is refused in 154 ms at 64 MiB. The 1,000,000-byte file is refused in 52 ms. Twelve edits asking for 5,000,000 each are refused as crowded under a 48 MiB heap in 476 ms. Pinned by the two small-heap child tests in test/workspace-edit-plan.test.ts:102 and :138. What remains is a bounded multi-byte path (R2-3).
R1-2 "stored none" after the record landed Fixed commitToolChanges reads the changed paths back after a throw (index.ts:2781-2797, holds/changesLanded at :4572-4596). It throws StoredThenFailed once the intents are written (:2849), and applyEdits answers "stored" (:3431-3435). Reproduced in six cases (see the desk's second question below). Pinned in test/workspace-edits.test.ts and test/workspace-file-tools.test.ts.
R1-3 a//b and a/b as two files Fixed editablePath refuses a path whose resolved spelling differs from the given one (index.ts:3487-3493). work/sub//b.txt, work/./sub/b.txt, work/sub/b.txt/ and work/sub/../sub/b.txt are each refused with "name it work/sub/b.txt". work and work/ are refused as the mount's root. The tree keeps one entry.
R1-4 #460 conflict, edit_many nested-mount case Fixed editablePath parses with parseFilePath (:3477) and the pass is named (:3289). The exact-text nested refusal for edit and two edit_many calls is at test/workspace-three-way.test.ts:1061-1072. A passed-over-by-design case is at :919-933, and the lazy-pass case at :950-951. The head merges cleanly with today's main (git merge-tree). main's six newer commits touch no workspace code.
R1-5 CRLF-only refusal names no way forward Fixed index.ts:3232: "…an edit keeps the file's line ends, and write replaces the whole file, line ends included."

The desk's questions, answered:

  • Are R1-1..R1-5 fixed and pinned, with the 5 MB replaceAll in a small-heap child? Yes, as in the table. The growing case runs in 64 MiB, where d92344b aborted at the default ~4 GiB heap. A same-length a→b success over 5,000,000 occurrences needs about 80 MiB: it aborts at 64 and passes at 80, matching the PR's own pin. That is proportional to the file, not unbounded.
  • Is edit_many's all-or-none claim true under a snapshot failure after commit? In the store, yes. The batch is still one TreeBatch record, and the answer now reports what that record did. Repro: wrap treeBatch so it records and then throws "No space left on device".
    • Answer: "Every edit of this call is stored … The store failed after recording them". Both files read as edited.
    • A throw before recording answers "stored none" and leaves both files as they were.
    • The one-path treeSet branch answers "stored".
    • On an autoMaterialize mount the answer adds that the push didn't run. A later materialize writes both files. So disk, too, holds all of the edits or none of them.
    • A no-op file (a quote-folded edit that changes no byte) next to a real edit answers "stored". Only the changed path is read back, and that is enough, since one record takes all of them.
    • When the read-back also throws, the answer says it can't tell, and to read the files before sending the call again.
    • Chronicle's trigger is still live: main of chronicle still calls auto_snapshot_if_needed(state_id)? after the commit (chronicle#39 is open). So this path does run in production.

Blockers

None.

Major

None.

Minor

R2-1. edit_many's planning cost is edits × occurrences, not edits × file, so the "bound on the edits" cut understates it by more than an order of magnitude.
The PR body lists, under Cut, "a bound on the edits in one call", measured as 3,000 edits of a 4.97 MiB file holding the event loop for 3.1 s. That holds for needles that occur a few times. For a count other than the one asked, scan keeps 20 starts but counts every occurrence: take returns true unless occurrences overlap (edit-plan.ts:153), so the loop at :160-162 runs to the end of the text. Every spec scans its file again (:505-509). All of this runs between the pass and the commit with no await (index.ts:3290-3417).

Measured on a 5,000,000-byte file of a, each edit { oldString: 'a', newString: 'b' } (occurrences 1):

  • 200 edits took 7.5 s. A 10 ms timer's longest gap was 7,527 ms, so the event loop didn't turn.
  • 1,000 edits took 34.3 s.
  • 200 edits of an absent needle took 47 ms.

So the time goes to counting matches the refusal never shows. It answers "found 5,000,000", where "at least 21" would serve as well. Edits that each ask for millions cost the same, about 40 ms each (twelve took 476 ms). A code_execution script can send tens of thousands of edits. The second reviewer measured 50,000 absent edits against one 5 MiB file at 6.3 s.

  • Fix: for a numeric expected, stop counting at max(expected, SHOWN_MATCHES) + 1 and say "at least". The refusal already words "at least" for overlaps. That removes the pathological case for small asks.
  • For many edits each asking for millions, add a call-wide budget on occurrences counted, as hintBudget does for hints. Past it, refuse with a request to split the call.
  • The body's "Two bounds limit what a call builds and holds" (Changes, under the planner) describes one file's plan. The call-wide bound is the one listed under Cut. Saying "a file's plan" there would match.

R2-2. The closest-region search holds a Map of every distinct bigram of the oldString's first line, which nothing bounds, and of each file line.
closestRegion caps the file's length (edit-plan.ts:258-264) but not the probe. It takes the first line of oldString longer than one character, whole (:267-270). It builds grams(probe) (:273, grams at :226-233), then a second Map for every file line (:283). For text with few distinct characters this is small: a 2,000,000-character one-line ASCII file costs 91 MiB RSS and 150 ms. For high-entropy text it isn't:

  • A random 3,240,000-code-unit oldString (BMP, no surrogates) against a small file aborts a 128 MiB heap. At 256 MiB it uses 203 MiB of heap for 1.1 s.
  • A random one-line file of 1,960,000 code units (3.9 MB, under both HINT_MAX_CHARS and the 5 MiB limit) aborts an 80 MiB heap on a miss. At 128 MiB it reaches 213 MiB RSS.
  • Both produce an empty hint, so the memory buys nothing.

A model's oldString is far too short for this to matter, and the file case is bounded by HINT_MAX_CHARS. The open end is a script-built oldString: about 60 bytes of heap per code unit.

  • Fix: cap the probe, say at its first 2,000 code units, the most a hint line shows.
  • Score each line by looking its bigrams up in the probe's map, with one counter per probe bigram reset per line. Memory then follows the probe rather than the longest line.

R2-3. A replaceAll that grows a file only in UTF-8 bytes is still built in full before it's refused.
The floor counts UTF-16 code units (edit-plan.ts:636), so an edit that keeps the length in code units but widens the bytes passes it. It is then built whole: out and updated, then a Buffer (:685-742). Only index.ts:3389 refuses it, by its bytes. The PR documents this path (edit-plan.ts:473-474), and it is bounded at 3 bytes per code unit, so it isn't the host-killer R1-1 was.

But it costs more than the success it stands in for. 5,242,880 a replaced with U+0800 aborts under an 80 MiB heap, the heap the PR's own same-length success test runs in. At 128 MiB it is refused ("15,728,640 bytes") in 0.38 s, at 242 MiB RSS. The changelog's "refused before its new text is built" can hold for this case too, at the cost of a few lines.

  • Fix: count the floor in bytes. Start from the blob's length. Per occurrence add Buffer.byteLength(newText) and subtract the most bytes the replaced text can take: oldText's bytes, plus one per line feed in it (a CR it may carry), plus two per quote in it where quotes were folded (a curly quote is 3 bytes). That is exact for LF files and a valid floor otherwise.
  • Alternatively, stop the builder once the bytes it has put out pass maxBytes, caching each new text's byte length.
  • Either way, add the multi-byte case to the small-heap test.

Verified fine

  • One record, no interleaving. Every path's adoption precondition runs before any set (index.ts:2769-2771). The commit is one treeBatch (:2790), or one treeSet for one file. The named pass at :3289 is the only await before the commit. Chronicle 0.4.0's tree_batch validates every path and records one operation.
  • Read-back logic. Only paths the tree didn't already hold are read back (:2781), which is sound because one record takes all or none. A removal is checked as absence, and a set by blob, size and mode. A call whose every change the tree already held gets the store's own error, as documented, and the tree is unchanged either way. write, delete and writeBinary share the same words (:3137-3143, :3608-3618, :1892-1906).
  • A log fsync failure after the record is written (Chronicle 0.4.0 records/log.rs:213-218) leaves the running store's state unchanged. The answer "stored none" matches what a retry plans from, and tree sets are whole entries, so a replay after restart doesn't apply an edit twice.
  • Answers within resultMaxChars. edit_many refusals (60 misses, 60 count mismatches on 5,000-character lines, 60 clashes) and a 60-file success all stayed within caps of 300, 1,000, 2,500, 8,000 and 24,000. At 1,000 a refusal names at least one problem, as documented. edit misses stayed within 200, 1,000 and 24,000.
  • Bytes outside the replacement. Checked on disk after the push:
    • CRLF with a BOM, where the new text gains a line.
    • Mixed CRLF/LF, which keeps its mixture by position.
    • A lone CR before a CRLF line end.
    • The BOM/CR read form.
    • A change at column 2,500, whose receipt shows it.
    • A surrogate-pair replaceAll.
  • Path rules. A symlinked directory is refused as unverified (the mount doesn't follow it). A case-different name misses. Chronicle refuses an absolute tree key, so work/<absolute mount path>/x creates no alias.
  • Memory across files is proportional to what the call asks for, and the PR discloses it under Cut. 20, 40 and 80 files of 5,000,000 bytes, each with 5,000,000 replacements, took 0.50, 0.79 and 1.35 GiB RSS (mostly off-heap) and 5.9, 11.5 and 22.8 s under a 512 MiB heap. If it ever matters, the dominant on-heap cost is each plan's full updated copy, which the receipts need only around their regions.
  • Not counted against this PR: a deletion whose treeRemove records and throws, while the intent write after it keeps failing too, loses its tombstone. The answer says the deletion is stored and "ls shows the path's state". ls then shows the disk file as not-in-branch, and materialize with applyDeletions doesn't delete it. Reproduced. main's commitToolChange has the same order (tree mutation, then a separate intent record) and loses the tombstone in more cases, so this PR narrows the gap rather than opening it. We've noted it for a separate issue.
  • Tests and CI. The eight workspace and resultMaxChars test files pass locally: 554 tests, 537 passed, 0 failed, and 17 skipped (they run only where the temp directory folds case). CI is green at this head: Ubuntu and macOS on Node 20 and 24, the changelog check, and the packed-install check.

Method: a two-reviewer pass.

  • Sol (Codex gpt-5.6-sol, reasoning xhigh, read-only sandbox, no network) read the diff from the merge-base, the round-1 review and replies, Chronicle v0.4.0's source and hands-mcp at 22b540d. It ran for about 20.5 minutes, used 94 shell commands, and spent 8.22M input tokens (7.55M cached) and 45.6K output tokens (25.9K reasoning). Its sandbox couldn't write temp files, so it couldn't run the tool-level suites or git show --remerge-diff. It checked the merges through combined diffs.
  • Claude fetched upstream state (agent-framework main, Chronicle's main and npm tags), built the head in a scratch worktree with Chronicle 0.4.0, and ran every case above through WorkspaceModule.handleToolCall in heap-capped child processes. It also re-ran Sol's R2-2, R2-3 and lost-tombstone reproductions. Sol's 50,000-miss timing is quoted, not re-run.
  • Who caught what:
    • R2-1: both independently (Sol by reading and its 50,000-miss run; Claude measured the event-loop stall).
    • R2-2: Sol. Claude reproduced it and measured what realistic text costs.
    • R2-3: both. Claude first judged it bounded; Sol showed it costs more than the success it stands in for.
  • Severity changes: Sol rated R2-1 to R2-3 as blockers. They're minors here: none aborts a host at ordinary heap sizes, two are bounded by the size limit, and the third needs a script-sized oldString. Sol's fourth finding, the lost tombstone, was reproduced and is pre-existing, as above.
  • Not covered: the full suite (CI is green at this head), Windows backslash spellings, and case-folding filesystems.

Cuthbert-2453 and others added 3 commits October 18, 2026 23:02
…ext that reads two ways

af-ws-planner-whole-r2 read 4abad1f. Its issues that were real here, by
number:

- I1: a receipt showed a line of the updated text with its CRLF end's CR
  whenever the line's text ended in a CR. Where the edit itself put that
  CR before a bare LF, disk holds the two as a CRLF end and read shows the
  line without it, so the receipt showed two CRs too many, and its lines
  copied back, beside a line whose own text ends in a CR before the same
  next line, edited that line instead: from "p\nq\nx\r\r\nq\n", an edit
  of "p" to "x\r", and then its receipt's lines 1 and 2, edited lines 3
  and 4. A receipt now tells a line's own CRs from its line end's by the
  stored bytes: for a line whose text ends in a CR, the CRs before its line
  feed in the new bytes say whether that end is a CRLF.
- I2: a line whose own text ends in one CR, shown with its end's, reads
  as given as a line whose own text ends in two, and the scan as given,
  which runs before the read form, placed a copy of it there: in
  "aa\r\nxx\r\r\nxx\r\r\r\n", line 2 copied edited line 3. Copies from
  read on anima-research#465 met the same. An old text that carries the file's BOM at
  its start or a CR at its end, and is found both as given and read
  without it, is now refused, naming the lines of each, since which the
  edit means can't be told. With its line end, such a copy is its own
  line's alone.
- I3, I4: no test had a last line with no line end, or an excerpt that
  reaches a line's end or stops after a CR inside it. The CR test now has
  the last line in a closest region, a count's lines and a receipt, and in
  a count's lines a long matched line whose excerpt reaches its end and one
  cut after a CR inside the line; the receipt test has a long line's
  stretch that reaches its end.
- I6: edit-text.ts's header says, as the changelog does, that edit's
  receipt keeps its path and notes whatever their length.

d18c5ca's message says "300,000 generated cases matched" of the
one-pass search against the search before it. Its generator repeated, so
they were 386 distinct cases. 300,000 drawn with mulberry32, 240,065 of
them distinct, matched too, with Bun 1.4.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a call's counting, a miss's search and a file's bytes

af-ws-planner-whole-r3 read 5395501, under a message corrected since
for its I5 and I10. Anarchid's nightly review R2 read 39d0151. Their
issues that were real here, by number:

- r3 I1: an old text found neither as given nor read without its last CR
  was matched with quotes read as equal before it was read without that
  CR, so a copy of a line whose own text ends in a CR, holding a curly
  quote, edited the next line whose own text ends in two CRs with a
  straight one: in "aa\r\nx’\r\r\nx'\r\r\r\n", line 2 copied edited line 3.
  An old text is now read every way before quotes are read as equal.
- r3 I2: an old text that starts with a BOM of its own text and ends in a
  CR was read only without both, and only at the text's start, so a copy
  of such a line, which is also the next such line's text as given,
  edited that line: in "\uFEFFaa\r\n\uFEFFxx\r\r\n\uFEFFxx\r\r\r\n", line 2
  copied edited line 3. readingsOf now gives a reading without the CR,
  without the BOM, and without both, each where the file has what it
  leaves out, and an old text found in more than one reading, as given
  among them, is refused, naming each one's lines.
- r3 I4, I12, and Abigail's read of 5395501: the refusal said to include
  the line end after the old text, which a last line has none of, and
  which a copy stopping at a CR inside a line doesn't need. It now says to
  include more of the text around it, such as the line before or after
  it, and gives the lines of each reading on a line of its own, so a cut
  first line (cutLine, 400 characters) keeps the hint however many lines
  they run to.
- r3 I3, I6, I7, I8, I9: tests of a BOM and a CR read each way, each
  reading's count past 20, the refusal in edit_many beside an edit that
  matches the same lines, a receipt's walk through the bytes, and a
  receipt line whose own text ends in two CRs.
- r3 I10, I11, I13: asStored's comment and a test's name said a receipt
  shows lines as read shows them, which holds of anima-research#465's read, not this
  branch's; they now say a receipt tells a line's own CRs from its line
  end's by the stored bytes. readingsOf's doc replaces readForm's, whose
  "which" named nothing. The BOM's three bytes, which asStored's walk
  skipped, hold no line feed or CR, so it starts at 0.
- R2-1: a count other than an edit's ask was counted to the file's end,
  so 200 edits of "a" each asking for one, in 5,000,000 "a", held the
  event loop for 24 s (J21066, at 5395501, Node 24.22.0). A call now
  counts the occurrences no answer needs, those past what an edit asks
  for and the one more that shows it finds more, only as many as its
  mount lets a file hold bytes (CountBudget): so a count is exact in a
  call of its own, and past that a floor, which the refusal says, with
  that the edit sent alone has every one counted. Counts stay exact under
  it, since an exact count is what lets an agent name the occurrences to
  ask edit_many for. The 200 edits now take 0.2 s (J21067).
- R2-2: a miss's closest region held a map of every distinct bigram of the
  old text's first line and of each line of the file. A random old text
  of 3,240,000 code units ran a 128 MiB heap out of memory, and a random
  one-line file of 1,960,000 an 80 MiB heap (J21066). The region is now
  sought for at most the first 2,000 code units of that line, as many as
  a region shows of a line, and each line of the file is scored by
  counting its bigrams against that line's.
- R2-3: the size floor counted UTF-16 code units, so 5,242,880 "a" each
  made U+0800, which widens the file only in UTF-8, was built before its
  bytes refused it, and ran an 80 MiB heap out of memory (J21066). The
  floor now counts bytes: the file's, and for each replacement its new
  text's, less the most the text it replaces can take, a CRLF for each
  line feed and a curly quote for each straight one matched as equal.
  Over 55,084 generated plans of LF, CRLF and mixed files, with BOMs,
  lone CRs, multi-byte characters, curly quotes, read forms and new line
  feeds, no floor passed the bytes the plan built, and none refused a
  limit of its own size (J21105).

Under the same cut, a count's floor sentence now follows the hint on a
line of its own, where it ended the hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ad a lone CR or BOM both ways

af-ws-planner-whole-r4 read 0d95cb3, and so did a colleague. Their issues
that were real here, by number:

- A colleague's read, and r4 I7: in a crowded call, an edit whose count
  the budget stopped at or below its ask went into no problem where the
  crowded check named none: in "ab" x 1,000 at a limit of 2,500, edits
  asking 1,500 "a", 1,500 "b" and 900 "ab" were refused as 2 problems,
  edits 1 and 2, and edit 3 met its own on the next call. One the budget
  stopped at exactly its ask was taken as exact. Each is now named by its
  count, as a floor, saying whether it occurs the times it asks for can't
  be told from the call; its problem is made only then, so a crowded
  refusal doesn't build shown lines for each of its edits.
- r4 I1: the crowded total added each edit's ask, so an edit asking for
  every occurrence added the string "all". No tool reaches it, since only
  edit's replaceAll asks for "all" and a crowded call has two edits or
  more, but planFile is exported. Such an edit now asks for its count.
- r4 I2: an old text that is a CR or a BOM alone left a reading of
  nothing, which was dropped, so a copy of line 1's CRLF end's CR in
  "\r\nx\r" edited line 2's own CR, and a copy of the file's BOM, as a
  character page shows it, edited one in line 2's text. Such a reading is
  now found where what it leaves out is the file's, the BOM at the text's
  start and a CR before each CRLF line end, and beside a match as given
  the edit is refused as read more than one way. Alone it is never taken.
- r4 I4: a file stored before its mount's limit was lowered can hold more
  occurrences than the budget, which was the limit, so "sent in a call of
  its own, the edit has every one counted" could fail. planFile now
  raises the budget to each text's length, so it holds the limit or the
  longest text, whichever is more.
- r4 I21: the size floor took each line feed an edit replaces as a CRLF
  in every file, so in one with no CRLF line end it sat a byte per line
  feed below the bytes. It does so only in a file with CRLF line ends,
  and is exact otherwise for a match as given, as its comment says.
- r4 I3, I9: planFile's doc said quotes were tried before the readings;
  the description compares receipts with anima-research#465's read, not this branch's.
- r4 I5, I6, I10-I19: tests of two readings other than as given, and of
  three, of a reading's new text keeping what it doesn't leave out, of a
  multi-byte old text at the limit, of the budget across files, past an
  ask over 20, in the quote pass and in a reading, of the other reading's
  stop, of the closest region's first 2,000 code units, of a line's
  bigrams counted no more often than the old text's, and of 100,000 lines
  sharing one.
- 0d95cb3's test of each reading's count wanted 17 numbers with commas
  after "26, 27," where the list has 16 and then "44 and 45", so it failed
  at that commit (J21175). It now asserts the text exactly.

r4 I8 is intended: an edit that replaces every occurrence holds each one's
start, since those are its replacements, and only edit's replaceAll asks
for "all", one edit to a call. r4 I20 isn't real: the size test's case
of "ab\r\n" x 5 at a limit of 20, with "b\n" made "bcd", applies only with
each replaced line feed taken as two bytes, and the removal that takes it
as one fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cuthbert-2453 and others added 3 commits October 19, 2026 00:11
…ole, and size a call's budget before it plans

af-ws-planner-whole-r5 read b8be9d2, and so did a colleague. Their issues
that were real here, by number:

- r5 I14, and the colleague's read: a reading that changes nothing, as the
  empty reading of a lone CR or BOM does where the new text is empty too,
  counted toward refusing the edit beside a match as given, so deleting a
  lone CR in "a\rb\r\n" was refused. A reading that changes nothing no
  longer counts beside another match. And a replaceAll of a lone CR or
  BOM, in a file with CRLF ends or its own BOM, was refused where it can
  only mean those in the text, since edits keep line ends and the BOM; an
  edit that replaces every occurrence now counts no empty reading.
  Replacing a lone CR with other text in one edit is still refused where
  a CRLF end's CR could be meant, as r4's I2 decided.
- r5 I7: an empty reading counted only beside a match as given, so beside
  another reading it was dropped and that one taken: in a file whose line
  1 is empty with a CRLF end, a copy of the file's BOM and that CR edited
  a BOM in line 2. It now counts beside any other match, and alone is
  never taken.
- r5 I1: an edit replacing every occurrence counted only a crowded call's
  free 20, so the budget could stop it, and its floor was summed as its
  ask. It now counts every occurrence freely. No tool reaches it: a
  crowded call has two edits or more, and only edit's replaceAll asks for
  every occurrence.
- r5 I3: the budget rose only as each file was planned, so with a file
  stored before its mount's limit was lowered, an earlier file's floor
  could say the call had counted all it may while a later raise went on.
  applyEdits now sizes the budget before planning, to the larger of the
  limit and the largest file the call names, in bytes. Which counts it
  stops still follows the edits' order.
- r5 I8: the floor's line said an edit sent alone has every occurrence
  counted, which a count of occurrences that overlap one another doesn't
  get; it now ends "unless they overlap".
- r5 I6: resultMaxChars' doc and changelog say how a result carrying
  structured reaches a script: as one JSON object, measured whole, saved
  to a workspace file past the limit or, where it can't be, not delivered.
- r5 I15, I18: comments.
- r5 I2, I4, I5, I9-I13, I19: tests of a lone CR or BOM absent as given,
  one budget raised for two long texts, the empty reading of a BOM and an
  empty first line's CR, a crowded refusal's edits whose problems aren't
  made, 199,999 edits named by the loop, the closest region and readings
  the cost tests find, and the words for a count stopped at its ask.

r5 I20 is intended: a reading beside a match as given need only be found,
and its lines shown as "the first 20 on lines …", which claims no total.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…edit can't change, and size the count budget as said

A colleague read 6d839f1 with a probe, and af-ws-planner-whole-r6 read
it too. What was real here:

- The colleague's probe, and r6 I6 and I3: 6d839f1 stopped counting a
  reading in which the edit changes nothing, its new text read the same
  way as its old, so deleting a lone CR applied. But a character page
  shows a CRLF end's CR and the BOM, and a copy of one, deleted, may mean
  it, which an edit can't change: six such edits deleted another CR or
  U+FEFF instead, some on another line, as "\r" made "" in "\r\nx\r"
  deleted line 2's own CR. For a single edit or a count such a reading
  counts again, as at b8be9d2, and the refusal adds where the edit
  changes nothing, since the file keeps its line ends or BOM. Deleting a
  lone CR takes its text around it. An edit that replaces every
  occurrence still counts no such reading, nor one that leaves nothing,
  since edits keep line ends and the BOM: it replaces the occurrences
  that change. planFile's doc, the changelog and the description say so.
- r6 I1, I7, I8: the changelog and description gave the count budget as
  the limit in bytes or the longest file in characters, and said only
  the edits' order decides which counts reach it. applyEdits sizes it
  to the larger of the limit and the largest file the call names, in
  bytes, and which counts reach it depends on the call's edits, its files
  and their order. CountBudget's size field and planFile's comment say
  where its size comes from.
- r6 I2, I4, I5, I9: tests of a file stored before its mount's limit was
  lowered sizing the budget for a file planned before it, through a
  second module over the same store; of a replaceAll beside a reading
  that changes nothing; of a deleted CR in a CRLF file with no lone CR,
  which misses; and of the readings refusal's marked reading and the
  same edit replacing every occurrence, in the planner.

r5's I14, which 6d839f1 answered, is judged intended instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s comments

af-ws-planner-whole-r7 read 383f969. Its issues that were real here,
by number:

- r7 I2, I8: a test of "\uFEFF\r" made "\uFEFF" in a file whose line 1
  is empty, where the reading that leaves nothing counts beside a
  nonempty reading in which the edit changes nothing, and the refusal
  says of both, joined, that the edit changes nothing, since the file
  keeps its BOM and line ends.
- r7 I3: a test of a replaceAll of "x\r" made "x" where no occurrence
  changes, answered as one with nothing to change.
- r7 I4, I6: planFile's comment says applyEdits sizes the count budget
  to the larger of the mount's limit and the largest file the call
  names, and the readings problem's doc says `same` is where the file
  keeps the BOM or line end's CR that reading leaves out. CountBudget's
  doc is rewrapped.

r7 I1, the description's claim that order changes nothing else, is
fixed in the description: the call's one closest-region search is spent
in planning order too. r7 I5 and I7 are not worth a change here: a
replaceAll answered "same" holds the starts of a reading that changes
nothing, bounded by the file as its line-end positions are, and a
refusal whose every reading changes nothing asks for the text around
the old text, which then gets "same". r7 I9 isn't real: applyEdits
refuses an edit whose old and new texts are the same before planning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

All three costs in the R2 review of 39d0151d4 (R2-1 to R2-3) reproduced, and 0d95cb31f fixes them. The branch's head is now e6556ad29. Each case below ran as R2 describes it, on Linux with Node 24.22.0, at 39d0151d4 and at e6556ad29. The times are wall-clock on a shared machine.

R2-1. 200 edits of a, each asking for one occurrence, in a file of 5,000,000 as held the event loop for 19.7 s at 39d0151d4 and for 0.21 s at e6556ad29. A call now counts occurrences past what its edits need within one budget shared by all its edits, as many as the larger of the mount's maxFileSize and the largest file the call names, in bytes. A count that reaches the budget is a floor, and the refusal says so. An edit sent in a call of its own is counted exactly unless its occurrences overlap. The planner test "a call counts occurrences past what its edits need only within its budget, and past it a count is a floor" holds 200 such edits under 2 s of CPU. The description's planner bullet now says the two bounds limit what a file's plan builds and holds, and its Cut entry gives this head's timings.

R2-2. A random old text of 3,240,000 code units ran out of a 128 MiB heap at 39d0151d4, and a miss in a file of one random 1,960,000-unit line ran out of an 80 MiB heap. At e6556ad29 both are refused as misses, in 0.16 s and 0.20 s. The closest-region search now scores lines against the first 2,000 code units of the old text's first line, with a counter for each of that line's bigrams, so what it holds follows that line and not the file's longest line. The test "a miss's closest region holds what its old text's first line and the file's lines need, not their every bigram, in a small heap" runs both cases in a 64 MiB heap.

R2-3. A replaceAll of each of 5,242,880 as with U+0800, at a 5 MiB limit, ran out of an 80 MiB heap at 39d0151d4. At e6556ad29 it's refused in 0.75 s in the same heap. The size floor counts the result in UTF-8 bytes before the new text is built. The test "a replaceAll that widens its file past maxBytes only in UTF-8 is refused in a small heap" runs it in an 80 MiB heap.

Since 39d0151d4 the branch also carries the fixes of our later internal reviews.

Written by Cuthbert-2453.

— Cuthbert-2453

@nissa-seru
nissa-seru merged commit d7e6ea3 into anima-research:main Oct 19, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
main brings anima-research#463's edit planner and edit_many, anima-research#350, anima-research#562, anima-research#563 and anima-research#566.
anima-research#463 and this change both add declarations at the end of
src/modules/workspace/index.ts, and both stand. Read along this change's
paths, anima-research#463's edit_many parses each target with parseFilePath and refuses
a read-only mount before it reads the store, so a direct mount refuses it
as it refuses write, edit and delete; a test now pins all four.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tel-0s added a commit that referenced this pull request Oct 19, 2026
…ge entry for bounded jobs, the batched-wake hold

What main brings since 9c6964c: 0.23.0 and 0.23.1 — #360 (hold-round
speech), #250 (surgery marks opt-in), #463 (the workspace edit planner),
#168 (non-conversational marker tags: a reaction, edit or deletion never
retargets the publish locus, apart from an edit the coalescer marks as the
agent's first view), #558 (every ephemeral agent's usage in one shared
entry), #570 (the batched-wake tests hold the gate's batch), #569, #565,
#566, #571, #572, #575, #576, #578 and the 0.23.x merge-backs.

Conflicts in five files, resolved by composition:

- src/agent.ts: the request strips the framework's token-estimate stamp
  (main) and then presents the agent's own turns under its name (ours).
- src/framework.ts: `ephemeralCandidates` keeps this branch's record
  (config, spec, resume) beside main's `ephemeralAgents` WeakSet, and
  createEphemeralAgent adds to both; `isAddressedMessage` stays tag-only
  (main's second argument is unread) with main's `readsDeferredWrite`
  spread; the re-pin rule's doc is main's with the `speech.locus` sentence;
  the turn-start flush is main's `flushForStart`, which gains this branch's
  `releaseLandedMail` so held mail whose message lands is released.
- src/mcpl/channel-registry.ts: the three Dendrite options
  (`latestInboundLocus`, `onToolClose`, `focusChannelResolver`) beside
  main's `onRouteFailure` doc; `markAccepted` takes main's `firstView` but
  records no publish locus — admission does, through `noteDelivered`, and
  the framework's three `noteDelivered` sites now apply main's marker rule
  (`isMarkerTagged(tags, firstView)`), so a reaction, edit or deletion is
  never the latest delivered inbound either; `channel_publish`'s default
  comment says the same.
- test/batched-wake-locus.test.ts: this branch's `batchedWakeRig` keeps
  its shape and gains main's `holdBatches`, reading the rig's framework;
  every test that pairs two sends holds the batch, the two ambient-only
  tests included.
- test/present-while-acting.test.ts: `createFramework` takes `speech`,
  `maxStreamTokens` and main's `proseRouting`; main's new re-pin tests
  (traces and typing after a re-pin, the budget restart, the inbound-only
  arrival) run under `LEGACY`, the latest-inbound rule the re-pin belongs
  to, beside this branch's default-rule test.

Three seams the suite found, all at the registry's reply anchor, which main
sets where it accepts a message and this branch sets where admission
delivers one:

- #168's marker rule now lives in `noteDelivered` (tags and the coalescer's
  first-view mark are passed by the framework's three delivery sites), so
  the registry's unit tests exercise it as the framework calls it.
- `buildChannelContext` reported no `incoming` when the speech fallback was
  null, and no context at all when nothing was open and no outgoing channel
  resolved — under `speech.locus: 'delivery'` that hid the reply anchor
  whenever the latest inbound wasn't a speech fallback. The anchor now
  depends only on `takesGlobalFallback` (an ephemeral run has none), and a
  context with an anchor is returned whatever else it lacks.
- A coalesced acceptance's anchor is committed when the acceptance is
  acknowledged, not when the coalescer delivers: an acceptance whose commit
  fails after its delivery moves no anchor, and the retry the receipt
  answers commits the one the first attempt parked (`parkedAnchors`, keyed
  by server and event id, bounded). #157's tests pinned both halves.

Test adaptations beyond the conflicts: #157's two re-pin tests and
outgoing-stream's re-pin test run under the legacy locus rule, as the others
do; two compiled tests main deleted (`mcpl-awareness-barrier`,
`prose-stream-router`) were still in `dist/test` and failing from there —
removed, since tsc doesn't clean outputs whose sources are gone.

Two lines git doesn't show, named by nissa-seru's review of #546:

- #558 reads `config.subconscious.name` for the shared usage entry's
  exemptions; a forks reader has no agent of its own, so the name is read
  only when `reader !== 'forks'` (the union has none otherwise).
- resumeAgent makes its agent with `new Agent`, so it adds the agent to
  `ephemeralAgents` itself: a resumed job's usage counts in the shared
  entry, not under its own name.

Composed rig: npm test 5408 tests, 5378 pass, 0 fail, 30 skipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 21, 2026
… and the Python runner goes

code_execution now runs each script in the isolate: JavaScript in
QuickJS-ng compiled to WebAssembly, in a process of its own, on the host's
own runtime. A script reaches only its agent's tools. One kind of script
replaces foreground and background ones: it runs in the agent's turn,
yields with its output so far past yield_time_ms (default 30 s), and keeps
running until the agent waits on it, stops it or leaves it.

- code_execution takes a run ({code, yield_time_ms?}), wait, stop and
  list. Its result is a text block, and isError is true for the endings
  that wake a left script (failed, crashed, stopped at its limit) and for
  refusals of its input or of capacity. background, time_limit_ms and the
  cancel action are refused by name, each saying what replaced it. Once
  the host has begun to stop, a run starts no script.
- A script's call goes through dispatchScriptToolCall as its agent's, with
  the agent's allowlist and every gate. It carries the RFC-007 origin of
  the code_execution call that ran the script, and, when it reaches the
  host while a call watches the script, that call's turn. A call that ends
  the turn ends it at the code_execution result of the call watching when
  its result arrives, a stop's included. When no result comes for a call,
  the bridge answers for it after toolCallTimeoutMs and 30 s more, as
  before, at most the longest a timer can wait, and marks the answer so
  the script's runner keeps the call's place in the host. A script's call
  carries resultMaxChars as the longest result that reaches the script
  whole whatever its characters, from its memory: (maxResultBytes - 80) /
  6, 7,456,527 at the default, and no more than the host's longest string
  allows. It can't count what the stored measure leaves out (a structured
  result's value, its blocks past their text and an image's data), deep
  nesting, or a parse past the memory left, which its documentation says.
  main gave it the Python runner's cut, 5,000,000.
- A script's calls count toward its round's routing, as main's #360 made
  them for the Python runner: those that reach the host while a run or
  wait call of the round watches it, whether or not the round waits on
  that script, and a call that arrives while none of the round's calls
  watches it counts toward the round of a later run or wait watching then;
  one the bridge refuses counts as one the allowedTools refuses, and each
  kind is kept once. A round waits for such a call, until it returns at
  the script's end or its yield, only when the watched script's code names
  a tool whose call changes the round's routing in the turn's mode, as a
  whole JavaScript identifier anywhere in the code (routingToolsNamed); a
  wait reads the code of the script it names (AgentScripts.codeOf). A
  comment or a string naming one holds its round too, for no longer than
  the call's yield, and a tool the code doesn't spell out goes unseen.
  #360's round machinery is re-keyed to the run or wait call's return: the
  calls are kept by the watch that saw them (scriptRoundCalls). A stop, a
  list and a code_execution call the allowedTools refuses hold nothing; on
  main the last held its round though no script ran, and the round was
  withheld.
- Every result reaches a script as { content, structuredContent, isError
  }. content is what the model would read: a structured result's blocks as
  its tool sent them, an MCPL peer's references as their stubs (fetched
  for a structured failure too), and otherwise one text block. A refused
  call (ToolResult.refused) throws ToolRefused in the script.
- Turn ends: a stream's settle ends its own turn for the agent's scripts
  (AgentScripts.turnEnded), whether or not the stream still owns the
  agent, naming the turns still live: the open logical turn, and the turn
  of the start holding the agent's turn (activeTurnTokens) until that turn
  settles (settledTurns), and a start that runs on a turn a settle had
  ended, as a resume can, takes that mark off. A non-primary agent's
  scripts whose turn isn't live stop, and, when the turn ended abnormally,
  a script its calls watch that hadn't made its first return stops first,
  whatever its turn. Each stop path that aborted the Python runner's
  script abandons its own stream's turn the same way, a quiesce its
  stream's, and the host stopping every turn. A non-primary agent's run
  made in a turn that has ended is refused, and a conversation fork
  disposed lets its scripts go. An ephemeral agent's end stops its
  scripts, and stop() stops every script and waits for their processes.
- A left script's wake enters its agent's context with metadata.source
  code-execution-script and its scriptId, and asks for a turn. A host that
  has begun to stop wakes no one, whether it began before the wake or
  while the wake's text was being written to the workspace.
- Configuration: create() refuses each key the Python runner took, naming
  what to do, and refuses codeExecution on a Bun older than 1.4.0, a
  prerelease of 1.4.0 included, which loses a child's fd 3 at some starts.
  maxScriptChildren and memoryMaxBytes are new, and memoryMaxBytes'
  documentation gives both the child's figure and the host's, the host's
  with a term for each wake maxWakesPerScript allows and the frame from
  the child being read, its text and its parse taken at two bytes a
  character. buildCodeExecutionToolDefinition() takes the same settings,
  and says a duration that isn't a whole second exactly. The tool's text
  says a script whose body returns waits for the calls its pending work
  goes on to make too.
- CI: a job of its own runs code_execution's files from source under Bun
  1.4.0, the oldest release create() admits, and under Bun 1.4.3, on Linux
  and macOS, one process per file: the isolate's, the engine's and
  code-execution.test.ts. The pack job runs the settings file under Bun
  1.3.14, which create() refuses.
- Removed: py-runner.ts, runtime-py.ts, their exports,
  test/code-execution-env.test.ts, test/helpers/pytc-stop-rows.ts, #495's
  tests of the runner's output, F55's result-text.ts (#599), the Python
  runner's ending as the model read it, with its test, and #360's Python
  reader, script-tool-names.ts, with its test. ToolResult.structured's doc
  keeps F55's wording but names no code_execution field: the isolate's
  result carries none.
- Changelog: 0.23.0 published the Python runner's last entries, and 0.23.1
  changed nothing of code execution, so code-execution-isolate.breaking.md
  says what changes from 0.23.1, as from 0.23.0. main's unreleased
  hold-round-speech.fixed.md (#360), tool-call-result-max-chars.added.md
  (#463) and mount-include.added.md (#536) now describe what ships, as do
  the workspace's notes on where the framework keeps its own files, which
  name no background-scripts folder. #495's two fragments, #596's and
  F55's two (#599) go: no release carried them, and the release with this
  has no Python runner for them to describe.

Tests: code-execution.test.ts is rewritten for the new surface, and each
framework test that drove Python scripts drives JavaScript ones on the
engine, still checking what it checked. #360's round tests are ported to
JavaScript scripts, a call sent past a script's tools through the bridge
in place of Python's forged one, with new ones for a round's yield, two
scripts in a round, a left script's calls, a wait's round, a wait on a
script that names no such tool, a stop, and a code_execution call the
allowedTools refuses; its mark and interpreter tests go with the reader. A
start that resumes a turn its settle ended is checked to run with that
turn live again. tool-call-result-max-chars runs a real script at 8 MiB,
which takes a result of exactly its figure in NUL characters whole, and
skip-reply-wake-xml-mode's script is JavaScript. The resident wake test
gives each wake's turn its own response, since the stand-in membrane hands
a stream every response queued when it opens, and checks each wake's own
inference request by text only that wake has. mcp-modern-framework's test
of a request that couldn't be written matches main's words for it ("The
request wasn't sent").

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