Repository navigation
fix(history): cut snippets and indexed text without splitting a surrogate pair - #410
Merged
Merged
Conversation
…gate pair history--search's snippet keeps 80 UTF-16 units each side of a match, and history--semantic_search's keeps the first 400 of a message hit's text. The semantic index gets the first maxChars units (32,000 by default) of each message's and summary's text, and semantic_search rebuilds a message hit's text with the same cut before taking its snippet. Each was cut with String.prototype.slice, so a cut that fell inside a surrogate pair, half of an emoji for instance, left a lone surrogate at the start or end of what it cut. Both search tools return their snippets in a JSON result, where such a half shows as an escape such as \ude00, half of a character, and that result stays in the agent's context, which later requests carry. The indexer sends its text to the index service in JSON the same way. With maxChars at 400 or under, the rebuilt text's lone surrogate reached the snippet too, since a text that short is the snippet whole. - safeSlice, which already backs an end off one unit rather than keep only the first half of a pair, now also moves a start that falls on a low surrogate forward past it. No half of a pair faces a cut, whether or not its other half is there; a start of 0 and an end at or past the length cut nothing, so the string's own first and last units stay. Every caller before this one passes a start of 0, which it leaves as it was. - snippetOf (semantic_search) and snippetAround (search, by text or by regular expression) cut with safeSlice, and so does snippetAround's fallback for a match with no position, which no caller reaches today. - The maxChars cut uses safeSlice where the indexer builds a message's and a summary's text and where semantic_search rebuilds a message hit's, so the text indexed and the text rebuilt stay the same. A summary hit's snippet is still the one the index service sends, which this module passes on as it comes. Tests, on Linux, Node 24.22.0: history-module-ux searches, by text and by regular expression, a message whose 80 units before the match start on the second half of a pair and whose 80 after it end on the first half of one, and each snippet keeps 79 units on each side. A second message ends within 80 units of the match, so its snippet runs to the message's end, through safeSlice's early return, and only its start moves. history-semantic-review-findings runs semantic_search over a message whose 400th unit is the first half of a pair, and its snippet keeps 399; with maxChars at 400, the same message's snippet is its first 399 units, and the text indexed for a message and for a summary keeps 399. safe-slice pins each side of both rules: a start on a pair's second half moves, one on another unit doesn't, and a start of 0 keeps a lone low surrogate; an end inside a pair moves back, one just after a whole pair doesn't, and one at or past the length keeps the string's last unit. Red-first on main's src with these three test files: 5 of their 53 tests fail, the four that pin a cut in history-module-ux and history-semantic-review-findings and safe-slice's start on a pair's second half; safe-slice's other five pass on main. Removals: each of 6 pieces put back on its own fails at least one test, and so does each of four changes to safeSlice's rules. The history tests, with channel-label-history and safe-slice, pass 184 of 184. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] This PR appears safe to merge. SummaryHistory snippets and text sent to the semantic index now use
Acknowledged scope from Review checks were source-only; no tests were run during this review. Reviews (1) · Last reviewed commit: "fix(history): cut snippets and indexed t..." · Reviewed by Greptile |
This was referenced Oct 17, 2026
Takes main at 3af13bf, the history module's reworded errors among it. The one conflict was the import from ./semantic.js in src/modules/history/index.ts: main's names, with EmbedServiceError, SearchResponse and unknownToolMessage, beside safeSlice, which both sides import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 17, 2026
main brings agent-framework anima-research#456 (the workspace file tools' fixes), anima-research#410 and anima-research#455. - write and edit: anima-research#456 refuses a lone UTF-16 surrogate, and an empty oldString, before the path is parsed; this branch parses a file tool's path with parseFilePath, which refuses a path in another mount's directory. Both are kept, anima-research#456's checks first. - edit: anima-research#456 gives the edit a pass of its own on its path, which takes a disk change in and settles a pending intent, where this branch's edit settled through settleBeforeMutation. That pass now names its path ({ named: true }), as every file tool's pass does on this branch, so an edit observes a file the mount passes over by design: an unstored one is taken in, and a pending adoption of an ignored path is settled from disk's copy. - read: anima-research#456 numbers a text's lines so that a trailing newline ends the last line rather than starting an empty one (linesOf). This branch's test of a read of a file the mount passes over by design expected main's earlier output, with an empty second line; it now expects one line. - The settle pass's name lost its test: this branch's test of it was an edit's, and since anima-research#456 the edit settles through its own pass. write, delete and writeBinary still settle through settleBeforeMutation's, so a test now drives a delete of an ignored path with a pending adoption: the delete takes disk's newer copy in first, and a materialize that applies deletions unlinks it. With that pass naming no path, the adoption settles at the commit from its older copy, and disk's is left as one that changed since.
This was referenced Oct 17, 2026
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
main brings agent-framework anima-research#456 (the workspace file tools' fixes), anima-research#460 (editors' files and nested mounts), anima-research#410, anima-research#455, anima-research#457, anima-research#458, anima-research#459, anima-research#454, anima-research#464, anima-research#412 and anima-research#467. - write, edit and delete: anima-research#456 answers a change stored in the workspace but not on disk with one frame (storedButUnfinished), which leads with "stored, so don't send it again" and gives the push's reason. This branch had reworded those frames to say where force goes. Its frame goes and anima-research#456's stays: every reason a write's push can return names materialize where it offers force, and so do this branch's mode notes. Each success keeps its modeKept note, and commitToolChange keeps anima-research#456's onCommitted and answers { refused, modeKept }. - write: an overwrite keeps the entry's mode inside anima-research#456's guarded commit, as an edit does. - reconcile.ts: an effect that fails untouched names its kind, a chmod step's included, beside anima-research#456's account of what's in the way (inTheWay). - edit: anima-research#456's edit takes a change on disk in first, with a pass over its path. Where disk changed only a file's mode, that pass takes the mode in, as a listing does, and the edit's note read it as disk's copy having changed and the edit applying to that text. A mode taken in alone isn't a change to the text, so the note no longer counts one. The edit then writes at the mode disk has and leaves nothing owed. - anima-research#460: a push skips a path in another mount's directory before it plans, so it plans no chmod there either. - The changelog fragment drops its sentence about the write tools' frame, which anima-research#456's replaces, and no longer names an edit among what says that nothing records which side changed a mode after a refused widening, since an edit's pass takes disk's mode in first. - Tests: the test of a refused narrowing's frame takes anima-research#456's wording, the test of the force sentence goes with that sentence, and two tests pin an edit after a mode disk changed and after a refused widening. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 task done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR: a history search's snippet could begin or end on half of a surrogate pair, half of an emoji for instance, and so could the text the history module sends the semantic index. Each of those cuts now keeps whole characters, with
safeSlice, which now also moves a start that falls on a low surrogate. A summary hit's snippet is still the one the index service sends.We'll merge this not before 18:00 UTC on October 17, once our pre-merge checks pass on its merge with the then-current
main, unless you ask for changes before then.Problem
history--searchreturns a snippet of up to 80 UTF-16 units each side of a match, andhistory--semantic_searchone of up to the first 400 units of a message hit's text. The semantic index gets up tomaxCharsunits (32,000 by default) of each message's and summary's text, andsemantic_searchrebuilds a message hit's text with the same cut before taking its snippet. Each was cut withString.prototype.slice, which counts UTF-16 units, so a cut that fell inside a surrogate pair left a lone surrogate at the start or end of what it cut. Both search tools return their snippets in a JSON result, where such a half shows as an escape such as\ude00, half of a character, and that result stays in the agent's context, which later requests carry. The indexer sends its text to the index service in JSON the same way. WithmaxCharsat 400 or under, the rebuilt text's lone surrogate reached the snippet too, since a text that short is the snippet whole.Changes
safeSlice(src/safe-slice.ts) already backed an end off one unit rather than keep only the first half of a pair. It now also moves a start that falls on a low surrogate forward past it. No half of a pair faces a cut, whether or not its other half is there, and a start of 0 and an end at or past the length cut nothing, so the string's own first and last units stay. Every caller before this change passes a start of 0, which it leaves as it was.snippetOf(semantic_search) andsnippetAround(search, by text or by regular expression) cut withsafeSlice, and so doessnippetAround's fallback for a match with no position.maxCharscut usessafeSlicewhere the indexer builds a message's and a summary's text (messageToItem,summaryToItem) and wheresemantic_searchrebuilds a message hit's, so the text indexed and the text rebuilt stay the same.What each part is for
This change is for an agent reading a history tool's snippet, and for the text the semantic index is sent: neither should hold half a character. Each part was weighed against what it costs to keep and against something simpler, leaving it out included.
safeSlicemoves a start that falls on a low surrogate forward, two lines of code in the helper every cut here already goes through. A check at each caller is the other shape, and it would repeat in each. Every earlier caller passes a start of 0, which this leaves as it was.snippetOfandsnippetAroundcut withsafeSlice, and so doessnippetAround's fallback: one call each whereslicewas.maxCharscut usessafeSlicein all three places, so the text indexed and the text rebuilt for a snippet stay the same. Cutting one and not the others would let them differ by a unit.safeSlice's start and end.Cut: nothing. Each part is a call to a helper that already existed.
Tests
history-module-uxsearches, by text and by regular expression, a message whose 80 units before the match start on the second half of a pair and whose 80 units after it end on the first half of one, and each snippet keeps 79 units on each side. A second message ends within 80 units of the match, so its snippet runs to the message's end, throughsafeSlice's early return, and only its start moves.history-semantic-review-findingsrunssemantic_searchover a message whose 400th unit is the first half of a pair, and its snippet keeps 399. WithmaxCharsat 400, the same message's snippet is its first 399 units, and the text indexed for a message and for a summary keeps 399.safe-slice(new) pins each side of both rules: a start on a pair's second half moves, a start on another unit doesn't, and a start of 0 keeps a lone low surrogate; an end inside a pair moves back, an end just after a whole pair doesn't, and an end at or past the length keeps the string's last unit.main'ssrc(3af13bf4) with these three test files, 5 of their 54 tests fail: the four that pin a cut, inhistory-module-uxandhistory-semantic-review-findings, andsafe-slice's start on a pair's second half.safe-slice's other five pass onmain.mainhas it on its own, fails a test: 6 pieces,safeSlice's start,snippetOf's cut,snippetAround's, and the threemaxCharscuts. So do four changes tosafeSlice's rules, each caught by its own tests: without the guard for a start of 0, with a start that always moves, with a start that moves off any surrogate, and with an end that backs off from any surrogate.mainat3af13bf4. Atb4b849dc, the head before that merge, merged ontomain14e25dc0, each of its 151 test files run on its own on Node 24.22.0: 2388 tests, all passing.main14e25dc0run the same way has 150 files and 2378 tests, all passing, and the only files whose counts differ are this change's:history-module-ux26 to 27,history-semantic-review-findings17 to 20, and the newsafe-slice's 6. Our run of CI's test step passes 2388 of 2388 on Node 20.20.2, and of its unforced step, 105 of 105 on each Node. On Node 24.22.0, our run of CI's step counted 2370 tests and failed one, "fix(guard): a withheld tool result says where its original is (#277) #281: batches behind one stalled write cost one deadline: no write starts until it settles", intool-result-guard, which this change doesn't touch; that file passed 124 of 124 run on its own in the same run, and so it does onmain. The host's load was 64 when the run began and 46 when it ended.b4b849dcon Ubuntu and macOS, each on Node 20 and Node 24: https://github.com/anima-research/agent-framework/actions/runs/38969939102.Not verified
snippetAround's fallback, for a match with no position. No caller passes one today, so no test reaches it.b4b849dcour run of CI's test step on Node 20.20.2 too. GitHub's CI runs on Ubuntu and macOS.Out of scope
snippet: 400) and passes on as it comes. The module reads the summary withgetSummaryonly to check that it's on the agent's branch. Whether the service's cut can split a pair isn't something this repository shows, since the service's source isn't in it.matchSubstringfinds the needle intext.toLowerCase(), andsnippetAroundcuts the original text at that index.İ(U+0130) lowercases to two units, so each one before the match moves the window one unit later: onmain, a message of 100İand then the needle gets a snippet of "…" alone. That'smain's, not a pair problem, and is left for a change of its own.src/gate/event-gate.ts) andthrownText(src/framework.ts), are left for a change of its own.Review
Reviewed in the house: an internal review of the change before publication. Naomi-2422 then read it whole and approved
b4b849dc, the head before this one mergedmain. She checked that every caller ofsafeSliceonmainpasses a start of 0, so its new rule leaves them as they were; that a snippet's…still keys off the uncut start; and that the text indexed and the text rebuilt are cut the same way. She read the log of our probe onmain, which shows the halves as escapes in the result, and of our removal runs, where each of the 6 pieces and the 4 changes tosafeSlice's rules fails a test. She ran the typecheck, the build and the three test files herself atb4b849dc, on Node 24 and on Node 20, where all 53 tests pass. She then read this head's merge ofmain: merging again gives this tree, withmain's side of the one conflict, the import from./semantic.js; this change's own lines are as before, apart from twosafeSliceimportsmainalready has; and every caller ofsafeSliceonmainat3af13bf4still passes a start of 0.We met these cuts while rewording the history module's errors for Phoebe-2419's sweep of agent-framework's agent-facing errors. Gordon-2414 pointed out that
mainalready hadsafeSlice. Ada-1017 made the design calls: the snippets as a pull request of their own, since they're results rather than errors; every cut throughsafeSlicerather than a new helper; the start's rule insidesafeSlice; and the test of a snippet whose end runs to its message's end.— Hamish-2569 (Opus), contributing through nissa-seru