Skip to content

fix(history): cut snippets and indexed text without splitting a surrogate pair - #410

Merged
nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/history-snippets
Oct 17, 2026
Merged

nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/history-snippets

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

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--search returns a snippet of up to 80 UTF-16 units each side of a match, and history--semantic_search one of up to the first 400 units of a message hit's text. The semantic index gets up to 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, 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. 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.

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) and snippetAround (search, by text or by regular expression) cut with safeSlice, and so does snippetAround's fallback for a match with no position.
  • The maxChars cut uses safeSlice where the indexer builds a message's and a summary's text (messageToItem, summaryToItem) and where semantic_search rebuilds 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.

  • safeSlice moves 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.
  • snippetOf and snippetAround cut with safeSlice, and so does snippetAround's fallback: one call each where slice was.
  • The maxChars cut uses safeSlice in 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.
  • The tests (93 lines across three files) pin each cut with a pair across it, and safeSlice's start and end.

Cut: nothing. Each part is a call to a helper that already existed.

Tests

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 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, 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 (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.

  • Red first: on main's src (3af13bf4) with these three test files, 5 of their 54 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.
  • Each piece, put back as main has it on its own, fails a test: 6 pieces, safeSlice's start, snippetOf's cut, snippetAround's, and the three maxChars cuts. So do four changes to safeSlice'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.
  • The suite: GitHub's CI runs it at this head, which merges main at 3af13bf4. At b4b849dc, the head before that merge, merged onto main 14e25dc0, each of its 151 test files run on its own on Node 24.22.0: 2388 tests, all passing. main 14e25dc0 run the same way has 150 files and 2378 tests, all passing, and the only files whose counts differ are this change's: history-module-ux 26 to 27, history-semantic-review-findings 17 to 20, and the new safe-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", in tool-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 on main. The host's load was 64 when the run began and 46 when it ended.
  • GitHub's CI passed at b4b849dc on 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.
  • Windows. Our runs were on Linux, on Node 24.22.0, and at b4b849dc our run of CI's test step on Node 20.20.2 too. GitHub's CI runs on Ubuntu and macOS.

Out of scope

  • A summary hit's snippet is the text the index service sends for it, which this module asks for at 400 (snippet: 400) and passes on as it comes. The module reads the summary with getSummary only 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.
  • A case-insensitive search's snippet can slide off its match. matchSubstring finds the needle in text.toLowerCase(), and snippetAround cuts the original text at that index. İ (U+0130) lowercases to two units, so each one before the match moves the window one unit later: on main, a message of 100 İ and then the needle gets a snippet of "…" alone. That's main's, not a pair problem, and is left for a change of its own.
  • Fixed-length cuts of text an agent reads outside the history module, such as the gate's quote of a debounced event (src/gate/event-gate.ts) and thrownText (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 merged main. She checked that every caller of safeSlice on main passes 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 on main, which shows the halves as escapes in the result, and of our removal runs, where each of the 6 pieces and the 4 changes to safeSlice's rules fails a test. She ran the typecheck, the build and the three test files herself at b4b849dc, on Node 24 and on Node 20, where all 53 tests pass. She then read this head's merge of main: merging again gives this tree, with main's side of the one conflict, the import from ./semantic.js; this change's own lines are as before, apart from two safeSlice imports main already has; and every caller of safeSlice on main at 3af13bf4 still 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 main already had safeSlice. Ada-1017 made the design calls: the snippets as a pull request of their own, since they're results rather than errors; every cut through safeSlice rather than a new helper; the start's rule inside safeSlice; and the test of a snippet whose end runs to its message's end.

— Hamish-2569 (Opus), contributing through nissa-seru

…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>
@greptile-apps

greptile-apps Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] This PR appears safe to merge.

Summary

History snippets and text sent to the semantic index now use safeSlice to avoid cutting surrogate pairs.

  • History search snippets keep whole characters at their cut points.
  • Semantic indexing keeps message and summary text whole at maxChars.

Acknowledged scope from nissa-seru (description signed Hamish-2569): summary snippets remain service-owned because that service's source is outside this repository. Existing lowercase-offset errors and cuts outside history are deferred. The author also notes that the unused fallback, Windows, and macOS were not tested.

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

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
nissa-seru merged commit d0f0a7f into anima-research:main Oct 17, 2026
6 checks passed
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.
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant