Repository navigation
feat(workspace): port hands-mcp's edit planner, and add edit_many, several edits stored all or none - #463
Conversation
… 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>
[Medium impact] The changes reach workspace file editing and framework tool-call dispatch. Fix the planner's memory growth before merging; one valid FindingsFix 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.SummaryAdds a shared edit planner,
Reviews (1) · Last reviewed commit: "fix(workspace): edit_many says when a fa..." · Reviewed by Greptile |
| continue; | ||
| } | ||
| if (normalized) quoteNormalized.push(spec.index); | ||
| for (const start of starts) spans.push({ spec, start, end: start + spec.oldText.length }); |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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
| 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); |
There was a problem hiding this 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.
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.There was a problem hiding this comment.
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>
…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
left a comment
There was a problem hiding this comment.
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
TreeBatchrecord, 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 largereplaceAll: 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 successdatakeepspath,sizeandnoteand addsreplacementsandchanged. The refusal wording changed; no consumer of the old text exists in this repo or in the host'smain. Previously-valid calls that are now refused are documented in the changelog: overlapping occurrences (with or withoutreplaceAll), edits pastmaxFileSize, and BOM/CR-only changes. One of them has no way forward named (R1-5).edit_manyis a new tool, so hosts that list allowed tools by name don't expose it until they add it. Module authors get extra fields onToolCall, 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 throwsRangeError: Invalid string lengthfromString.prototype.replaceAllin 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
newStringover 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") replacesawithb, 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
maxSizeintoplanFileand 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 intextBuilderproves the refusal without encoding anything. Alternatively, compute the output size from the counts before building. - Consider building
updatedonly 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_batchvalidates every path before recording oneStateOperation::TreeBatch(src/store.rs:1467).commitToolChangesruns every path's adoption precondition before any set (index.ts:2071-2073).settleAdoptionBeforeMutationtouches only the agreement, never the tree. - No interleaving. The pass at
index.ts:2507is 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 bywithMount. - 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 + slotstays 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
oldStringornewStringis refused before planning, and every cut goes throughsafeCut. resultMaxCharswiring. It is set on model/script dispatch (framework.ts:15913), puppet calls (:14391) andutilscalls (:20102). A script's waiter is registered before its dispatch (:15328), so script calls get 5,000,000. The cap is the sameresolveToolResultInlineCapthe 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, reasoningxhigh, read-only sandbox, no network) read the diff against the merge-base, today'smain, the ported-from planner at22b540dand 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
WorkspaceModuleagainst Chronicle 0.4.0. - Who caught what: R1-1 by both (Sol by reading; Claude reproduced the abort and found the
edit_manyform). R1-2 by Sol, reproduced by Claude and traced into Chronicle 0.4.0. R1-4 by both (Claude found the missingedit_manytest). 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.
…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>
|
Replying to this review. Addressed in 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
R1-2. The tests:
R1-3. R1-4. This needs 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 Written by Cuthbert-2453. — Tabitha-2696 |
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>
|
Replying to this review. @Anarchid Addressed in
The tests, all in
Each of these fails with the piece it pins taken out: The merge carries #462's kept mode too. 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 Since then, #468 and #525 landed in Written by Cuthbert-2453. — Fiona-2757 |
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
left a comment
There was a problem hiding this comment.
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
replaceAllin a small-heap child? Yes, as in the table. The growing case runs in 64 MiB, whered92344baborted at the default ~4 GiB heap. A same-lengtha→bsuccess 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 oneTreeBatchrecord, and the answer now reports what that record did. Repro: wraptreeBatchso 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
treeSetbranch answers "stored". - On an
autoMaterializemount the answer adds that the push didn't run. A latermaterializewrites 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:
mainof chronicle still callsauto_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 atmax(expected, SHOWN_MATCHES) + 1and 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
hintBudgetdoes 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_CHARSand 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 onetreeBatch(:2790), or onetreeSetfor one file. The named pass at:3289is the only await before the commit. Chronicle 0.4.0'stree_batchvalidates 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,deleteandwriteBinaryshare 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_manyrefusals (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.editmisses 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>/xcreates 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
updatedcopy, which the receipts need only around their regions. - Not counted against this PR: a deletion whose
treeRemoverecords 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".lsthen shows the disk file asnot-in-branch, andmaterializewithapplyDeletionsdoesn't delete it. Reproduced.main'scommitToolChangehas 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
resultMaxCharstest 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, reasoningxhigh, 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 at22b540d. 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 orgit show --remerge-diff. It checked the merges through combined diffs. - Claude fetched upstream state (agent-framework
main, Chronicle'smainand npm tags), built the head in a scratch worktree with Chronicle 0.4.0, and ran every case above throughWorkspaceModule.handleToolCallin 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.
…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>
…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>
|
Replying to this review. All three costs in the R2 review of R2-1. 200 edits of R2-2. A random old text of 3,240,000 code units ran out of a 128 MiB heap at R2-3. A Since Written by Cuthbert-2453. — Cuthbert-2453 |
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>
…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>
… 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>
The workspace's
editnow matches and replaces through a planner ported from hands-mcp, and a newedit_manytool 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 carriesresultMaxChars, 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
editcounted matches withString.splitand replaced withString.replaceorString.replaceAll(handleEditinsrc/modules/workspace/index.ts, atmainb96e918b).oldStringwith LF line ends missed in a file with CRLF ones, and a CRLFoldStringwith an LFnewStringmixed the file's line ends.hands-mcp's
EditandEditManytools 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 getscallerAgentName.utilsand an operator's puppet call carry the agent's effective inline cap (resolveToolResultInlineCap).code_executionscript carries the script's limit, feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262'sAgentFramework.SCRIPT_RESULT_MAX_CHARS(5,000,000), and so does a utility the script runs.ModuleContext.callTool,executeToolCall) carries what its caller put on it, and a utility it runs throughutilscarries 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).id,name,input,callerAgentNameand, since fix(discord): edit a turn's thoughts only into the agent's own post in that turn #445,callerTurnId. SoresultMaxCharsreaches a module.src/modules/workspace/text.tsholds line numbering with a long line's cut, a BOM check and a string's length as JSON, which the planner andedit's answers use.src/modules/workspace/edit-plan.tsis hands-mcp's planner, ported fromanima-research/hands-mcp, a private repository, at22b540d. 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:readnumbers lines, and its hints point togrep.readshows such a line. A CR before a line feed inoldStringis 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.xof a text holding 500 of them are planned in 0.3 s of CPU.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).maxFileSizeis 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. AreplaceAllof 3,000,000awith 200 characters each is refused from its counts within an 80 MiB heap, and so is areplaceAllof 5,242,880awith U+0800 each, which widens the file only in UTF-8 (Anarchid's R2-3). Twelve edits each asking for all 1,000,000aof a file are refused as crowded within a 48 MiB heap.edit_manyhas noreplaceAll, so a count is what lets an agent name the occurrences to ask for. 200 edits ofaeach asking for one, in a file of 5,000,000a, take a quarter of a second (Anarchid's R2-1).applyEdits(src/modules/workspace/index.ts) runseditandedit_many.applyEditschecks each edit's own input: an emptyoldString, anoldStringandnewStringthat are the same once CRLF is read as LF, and a lone surrogate. AnoldStringandnewStringthat differ only in their line ends are refused with a note that an edit keeps the file's line ends andwritereplaces 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, whichresolvegives 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 rulehandleToolCallapplies toedit's path, now namingset_tool_visibilityandset_tool_descriptionas the way to change what it shows), and edits naming files in more than one mount. A problem there refuseseditat once, andedit_manywhen its edits name more than one mount. Otherwiseedit_manygoes 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.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,applyEditsreads and plans every file and sets every entry with no await between.commitToolChangescommits several paths. Every path's adoption check runs before any entry is set, then the entries are set in onetreeBatch, 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'scommitToolChangedid. 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'scommitToolChangereturn them for one path.commitToolChangecalls it with one path, which it sets withtreeSetortreeRemoveas before, andwrite,deleteandwriteBinarycommit through it. A failure before the entries are set leaves none set, andedit_manyanswers it as a call that stored none of its edits.update_state_with_builderappends the record, then writes the state's auto-snapshot, whose error reaches the caller (src/store.rsatv0.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 withtreeGet. 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,deleteandedit_manyeach 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 andedit_many's answers name any conflict with disk, as their success does. Where the entries can't be read back either,editandedit_manysay 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 awriteof 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.maxFileSizeis refused, aswriterefuses 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.tswords the answers. Its problem wording and the way a receipt shows regions are ported from hands-mcp'ssrc/edit.tsat22b540d.edit's refusal names its first problem, andedit_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'sdatareaches neither the model nor a script. A success givesreplacementsandchanged, each edit's first changed region with two lines on each side, numbered asreadnumbers lines, a line over 2,000 characters as a stretch from at most 80 characters before each change on it. Itsnotesays 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,modeKeptsays why, as fix(workspace): keep a file's mode with its bytes #462'seditanswer does, andedit_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.resultMaxChars.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 tols. It never says to send the call again. What failed after the entries were set goes in its head, which is never cut.edit_many's answers, a thrown message is cut at 400 characters, as a problem's first line is.edit_manylists 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.edit's answer to an edit stored but not finished is the onewriteanddeletegive, one path's, whose length its templates and its path set.inputProblemnow checks an array of objects against its items' schema, with the tool's minimums, soedit_many's edits are checked item by item.What callers will notice
editanswers a success withreplacements,changedand, 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:oldStringmatches in a CRLF file, and the new text takes the line ends of the text it replaces, where before it went in as given.oldStringisn't in the file as given, or read without a BOM or CR as below, straight and curly quotes are matched as equal.oldStringthat 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 ofnewStringis 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.replaceAlltoo. Before,replaceAllof "aa" in "aaa" replaced the first one.maxFileSizeis refused, from its counts where they show it: "would be at least N bytes".work/sub//b.txt, is refused, naming its plain spelling. Before,edittook it as written, and the workspace could hold one file as two entries. A path naming the mount's root, such asworkorwork/., is refused as naming no file.write. Before,editchanged the line ends of the text it replaced.write,editordeletemeets 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 ofwriteBinary, such assave_recent_image, gets the same words withinChronicle: true, wherewriteBinarythrew the store's error, so the image is listed as saved, with them as itsdiskCopyError.set_tool_visibilityandset_tool_descriptionchange what it shows. The same refusal reacheswriteanddelete.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:".resultMaxCharson its calls, with every other field the framework sets on a call, where it receivedid,name,input,callerAgentNameandcallerTurnIdalone.edit's refusal text sees the new wording given above.edit's description and its parameters' descriptions changed andedit_manyis 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.
edit-plan.tsis 954 lines, 579 of them code and 345 comments, in place of the few lines ofhandleEditthat matched and replaced. Writing it anew would cost as much and lose what those reviews found.edit_manycosts 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.commitToolChangesiscommitToolChangeover several paths. Setting them in onetreeBatchkeeps 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 itstreeSetortreeRemove, sowrite,deleteandeditrecord what they did before.resultMaxCharsis 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.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.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, onereplaceAllof millions of occurrences holds an object for each and aborts a host whose heap is 512 MiB.oldStringcopied 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.replaceAllthat lengthens or widens millions of occurrences, or a few edits asking for millions each, aborts the host's process wheremainrefuses or applies them.changesLandedandholds,CommitUnknownErrorandStoredThenFailed, 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, makesedit_manysay it stored none of them, and a second call can apply them twice.commitToolChangesand into both tools' answers costs about 20 lines. Without it,edit_manywould say nothing where its push left a file's mode as disk had it, whereedit,writeandwriteBinarysay so asmodeKept.readdoes 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.readshows 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.edit_manycall is planned twice from one text and set as two entries, and one edit never reaches disk.workspace-file-toolsand 3 toworkspace-three-way), 5 onresultMaxCharsand 3 on the text helpers. Each piece fails a test when it's taken out (Tests).Cut:
edit_manycall holds together, beyond each file'smaxFileSize.editcall'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,000atook 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.dataon a refusal, which reaches neither the model nor a script.edit's answer to an edit stored but not finished, which is one file's and is shared withwriteanddelete.Tests
mainaae86b7fc, on Linux from a clean detached worktree, withnpm installas CI runs it, Node 24.22.0 and npm 11.19.1:tsc --noEmitand the build are clean, and CI'snpm testpasses 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.aae86b7fc, the same way: CI'snpm testpasses 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 testmainruns, by name, and 134 more, this change's.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-rootsandcode-execution-structured-results).edit_manycase among them, which skip on Linux.test/workspace-edits.test.tsas it stood at6a1d0e3d9, less its one test ofedit-text.ts's fitting, which needs a module this change adds, fails all 30 of its tests at41e3c5fdb, the commit before the planner. Theedittests fail on behaviour; theedit_manytests 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.tsfails 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 pastmaxFileSize, passes there, and the removals below show what the merges' tests catch. At6a1d0e3d9, the memory test fails because the planner runs out of its 80 MiB heap, and so do the tool tests of anoldStringcarrying a BOM or CR and of a long line's receipt. At706da54a7, 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. Ata3eaa3334, 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. Atd92344b9e, the tests of the size floor, of a growingreplaceAlland of edits asking for millions in a small heap, of crowded edits, of plain paths, of the refusal pastmaxFileSize, of the line-end refusal's wording, and of a store that throws after recording fail. At072f5a8ef, 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. At43f857baf, the first merge ofmain, the tests of every spelling of the mount's root, ofwriteBinary's answers after a store that failed after recording, and of a partly refusededit_manybeside a kept mode fail. At2f9219c37, the tests of the root refusal's wording, ofwriteBinary's words on a mount that pushes, and of a partly refusededit_many's kept mode fail. Atc63578fce, the tests ofeditrefusing 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. Atfbe0635bc, the test of one edit in a file of one long line, whose region now shows where it fits, fails. At39d0151d4, the test of lines copied across a line whose own text ends in a CR fails. At4abad1fd5, 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. At5395501a8, 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 wideningreplaceAlland 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 exports0d95cb31fadds, so the removals below carry it. At0d95cb31f, 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. Atb8be9d25e, 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 areplaceAllof a lone CR or BOM, and of the floor's words for occurrences that overlap fail. At6d839f12a, 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 what383f9696balready does and pass there, and the removals below carry them.mainbrought 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-editsandworkspace-file-toolscatch these, andworkspace-path-spellingcatches the last on a filesystem that folds case. One is in the text helpers, the BOM check's third byte, and 10 are inresultMaxChars, 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 at39d0151d4, 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.replaceAlland 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 themedit'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 ofwrite,edit,deleteandwriteBinaryafter a store that throws, a deletion of a draft whose store throws after recording it among them (workspace-file-tools);edit_manyrefusing 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_manyrefusing 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_manynaming 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).c63578fce, and in each of its thirteen other rounds the changes since the round before, the last through383f9696b, except that no round read39d0151d4'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.e6556ad29changes 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. At383f9696b, 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'sreadevery 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, theedit_manycase among them, also ran on a FUSE filesystem on Linux that matches names without regard to case or Unicode normalization.Not verified
path.win32, a copy of its computation passes a name ending in.or a space, which Windows strips, asparsePath's containment check passes it too.Out of scope
readandgrep, which number lines and hold their answers as they did.Open pull requests this meets
This branch merged
mainfive times, atd9bd6dc97,c7ad41d4d,c0155d390,5227741b3andaae86b7fc, 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 withmainas of 09:10Z on Oct 19, whenmainis4fd2bfb9e. Of the 50 other open pull requests then, bygit merge-tree, 9 merge with both, among them #465, which is stacked on this branch, and 4 merge with this branch but not withmain. Two merge withmainbut not with this branch. #536, inhandleEdit's body, whose work this change moves intoapplyEdits, spells the path in the settling's note as the call gave it. #564, a read-only direct mount, adds its helpers inindex.tsat the place where this change adds its commit's types and helpers. Two that conflict withmaintoo meet lines this change adds:origin, and carries it throughutils, the module registry and the operator's puppet call, where this change carriesresultMaxChars, and documents it besideresultMaxChars.watchtool,write { append }#271 addswrite'sappendto ahandleWritefrom before fix(workspace): the file tools store exactly what they're given or refuse, and say when a stored change didn't reach disk #456. Its blocks hold this change's answer to a store that failed after recording a write, and the workspace module's imports.The other 33 conflict with
mainalready, away from this change's lines.changelog.d/tool-call-result-max-chars.added.md,changelog.d/workspace-edit-many.added.mdandchangelog.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 fromc63578fceon 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 ofmainand 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).