Repository navigation
fix(tool-results): a cut keeps past it only the image slots the model was shown - #440
Merged
nissa-seru merged 4 commits intoOct 18, 2026
Conversation
… was shown (anima-research#403) When a stored tool result was longer than its inline cap, splitPreservingImageSlots added back after the truncation notice every slot parseImagePlaceholders found at or past the cut. That parser returned every line shaped like a reference stub, image or not, and nothing bounded their number or length, so a text result listing channel attachments was stored at many times its cap: 25,980 characters at a cap of 2,000 were stored as 25,168. The slots were kept for save_recent_image's index (anima-research#104), since the stored text is the only place its inventory finds tool images. A slot protects the index only where the model was shown it. On the turn a result arrives, toMembraneToolResult sends the stored text itself for every result but an array carrying an inline image. tryNativeToolResultContent sends that one as native blocks, each image and each reference stub whole, and each text block cut at the cap on its own. - The serializer reports where each block of an array sits in its text (HistorySerializeOptions.onBlocks), only when it took the array block by block. storedToolResultText turns those spans into the ranges the live copy showed of a result that carried an inline image. cutStoredToolResult keeps past the cut the image slots lying wholly inside them, at most the newest MAX_KEPT_IMAGE_SLOTS (64, the tool-image ledger's entry limit), with a note counting any left out. Every other result keeps nothing past its cut. - toolResultCutAt makes the stored copy's cut, truncateForHistory's, and the live copy's cut of each text block beside an image. It cuts at the cap, or before a surrogate pair the cap would split. It then moves back to the start of a placeholder it would split, and to the start of a stub-shaped line it would split when at most REFERENCE_STUB_MAX_CHARS of the line lie before the cut, since that part could read as a slot. - parseImagePlaceholders returns image slots only, decided from the stored text alone rather than the registry's record. A slot is no longer than the framework writes one: a stub line up to REFERENCE_STUB_MAX_CHARS (677, the sum of buildReferenceStub's bounds), and a placeholder whose type and subtype are up to 127 characters each (RFC 6838) and whose size's number, ledger nonce and sequence are up to 16 characters. normalizeMediaType falls back to application/octet-stream past 127. - save_recent_image counts a same-batch sibling as its stored copy will be cut, from the result rather than its data, so an error result's text counts the same before and after the commit. test/save-recent-image.test.ts goes from 29 tests to 44.
[Medium impact] The changes since the previous review appear safe to merge. SummaryKeeps only image slots the model saw past a stored tool result's cut, with a limit of 64, and avoids cutting through slots.
Reviews (2) · Last reviewed commit: "perf(tool-results): walk the slots and t..." · Reviewed by Greptile |
…ma-research#403) cutStoredToolResult checked each slot past the cut against the shown ranges from the first range on, so a result with thousands of image reference blocks beside an inline image cost slots times ranges checks on the shared thread, at storage and again in a same-batch save (Greptile, on anima-research#440). The slots come from parseImagePlaceholders in text order, and the ranges from the serializer's block spans, also in text order and never overlapping; ShownRanges' comment now says so, since the walk relies on it. One pass walks both: a range that ends where a slot starts, or before, holds no later slot either. What the cut keeps is unchanged. - Against the filter it replaces, on 20,000 random texts with ranges built as storedToolResultText builds them (19,558 of them with a slot inside another, 7,771 with more than 64 slots past the cut), the walk keeps the same slots and counts the same number. - Abigail-2676's differential probe still finds the stored copy's slots equal to the live copy's, over six seeds. - On 10,000 image reference stubs and one inline image past a cap of 2,000, the cut's median over five runs went from 364 ms to 5 ms (Bun 1.4.3). test/save-recent-image.test.ts pins the walk's advance: a slot wholly between two ranges, before one inside the second, and two ranges that touch, with a slot starting where the first ends, which the second holds.
added 2 commits
October 17, 2026 16:31
main now carries anima-research#404, which changes the import lines this change also changes. Both conflicts were resolved by hand, keeping each side's changes: - src/framework.ts's import from ./mcpl/references.js: anima-research#404 drops EXPIRY_REASON and referenceExpiry and adds unfetchableReason, and this change drops referenceTypeLabel. None of the three dropped names is used in the merged file, and unfetchableReason is, by anima-research#404's refusal in save_recent_image. - test/save-recent-image.test.ts's imports: this change's names, with anima-research#404's UNBOUND_REASON, which anima-research#404's unbound-reference test uses. Nothing else conflicts. save_recent_image's inventory counts a reference by the type its stored text shows (this change), and anima-research#404's refusal of a reference consults the registry only to say whether fetch_reference could deliver it.
main now carries anima-research#369, which changes the serializer's text branch this change also changes. The conflict, in src/tool-result-history.ts, was resolved by hand, keeping both: - the import of NON_STRING_TEXT_PLACEHOLDER from anima-research#369, beside this change's import from ./tool-image-ledger.js and its HistoryBlockSpan; - in tryHistoryStringFromContentArray, anima-research#369's acceptance of any text block, a text that isn't a string showing as the converters' placeholder, as the text of this change's { kind: 'text' } part. The live copy shows such a block as the same placeholder and cuts it as a text block (tryNativeToolResultContent), so the stored copy's shown ranges treat it as the live copy did. Nothing else conflicts.
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
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.
A tool result stored over its inline cap now keeps past the cut only the image slots the model was shown when the result arrived, and the cut never ends partway through a slot. Before, the cut added back every line shaped like a reference stub from past the cut, image or not and with no limit, so a text result listing channel attachments was stored at many times its cap.
!Fixes #403.
Problem
When a stored tool result is longer than its inline cap,
splitPreservingImageSlots(src/tool-image-ledger.tsonmain) added back, after the truncation notice, every slot thatparseImagePlaceholdersfound at or past the cut. The slots were kept so thatsave_recent_image's index counts every image the resident saw (#104), since the stored text is the only place its inventory finds tool images. ButparseImagePlaceholdersreturned every[ref_…] …line, whether or not it named an image, and nothing bounded how many lines were kept or how long each was. WithtoolResultInlineMaxChars: 2000and no workspace,mainstored a 25,980-character text result, made of 3,000 characters and 200 attachment lines, at 25,168 characters.A slot kept past the cut protects the index only if the resident saw it. On the turn a result arrives,
toMembraneToolResultsends the model its live copy, which is the stored text itself (precomputed.text) for every result except one whose data is an array carrying an inline image.tryNativeToolResultContentsends that one as native blocks: every image and every reference block's stub whole, and each text block cut at the cap on its own. So for every other result, what the cut keeps is exactly what the resident saw. For one that carried an image, the resident saw its images, its stubs, and each text block up to that block's own cut, and nothing else.What changes
onBlocks,src/tool-result-history.ts), and only when it took the array block by block. From that,storedToolResultText(src/framework.ts) gives the ranges the live copy showed of a result that carried an inline image: each image's placeholder and each reference stub whole, and each text block up to where the live copy cut it.cutStoredToolResult(src/tool-image-ledger.ts) keeps, past the cut, the image slots lying wholly within those ranges, in one pass over the slots and the ranges, both in text order. A result with no inline image keeps nothing past its cut, and neither does an array the converters give up on for its JSON, whose live copy is its stored text. A pair stored without a turn (puppetToolCall), or a batch stored after its stream has ended, is stored as a turn's would be. It keeps what its live copy would have shown, and the model sees that stored copy on its next turn.DEFAULT_TOOL_IMAGE_LEDGER_MAX_ENTRIES). The serializer retains a result's images in order before it writes their placeholders, so once the result is stored, the ledger holds bytes for at most its newest 64 images, and fewer when its limit on the characters it holds binds first. A kept slot whose image has no bytes left refuses a save rather than sliding. When the slots past the cut are the result's own images, none it leaves out still has bytes. Image stubs and quoted placeholders count toward the 64 too, so where they follow a result's images past the cut, the newest 64 can leave out an image whose bytes the ledger still holds. When more fell past the cut, the note says how many were left out, andsave_recent_imagedoesn't count those. So the stored copy of such a result can run past its cap by a note and at most 64 lines of at most 677 characters each.toolResultCutAtmakes the stored copy's cut,truncateForHistory's, and the live copy's cut of each text block beside an image. It cuts at the cap, or before it when the cap would split a surrogate pair. From there it moves back to the start of a placeholder the cut would split, and then to the start of a stub-shaped line the cut would split when at mostREFERENCE_STUB_MAX_CHARS(677) characters of the line lie before the cut, since that part could still read as an image. A longer part can't, and stays. So the live copy and the stored copy cut by the same rule, and neither ends in part of a slot. They cut in different places, as onmain. The live copy cuts each text block from its own start, and the stored copy cuts the whole text from its start. The shown ranges are per block, so the slots agree.parseImagePlaceholdersreturns only image slots: inline image placeholders, and reference stubs whose text shows animage/type. A stub-shaped line longer than 677 characters, the sum ofbuildReferenceStub's own bounds, is not a slot.PLACEHOLDER_REbounds a placeholder's type and subtype at 127 characters each, the most RFC 6838 allows, and its size's number, its ledger nonce and its sequence at 16 characters each, a safe integer's digits.normalizeMediaTypefalls back toapplication/octet-streamfor a longer type or subtype, so the placeholder the framework writes always parses. A placeholder stored before this change with a longer type or subtype no longer counts; only a server that sent such a type could have produced one.mainthe inventory counted a stub by the registry's record of its type while the process held one, and by its text otherwise. The stub's stored text now decides throughout, so a stub counts the same whether or not the process still holds its record, and after its reference is issued again with other testimony.spillOrTruncatecuts it (storedImageSlots), with the same ranges of what the live copy showed. It reads the result rather than its data, so an error result, and a result with no data whose error text history stores, count that text's slots before they are stored as after.truncateForHistorykeeps nothing past its cut. It cuts a script's tool results and those on the direct Agent API, and each is read as that text, never as the images behind its placeholders. The image slots past its cut, whichmainadded back after its notice, are no longer there.What a resident notices: a truncated text result no longer lists image stubs past the cut; the spill file holds them. A result that carried images lists past its cut only the image slots its live copy showed, at most 64, with a note when more fell past it. A cut that would fall inside a placeholder or a reference line falls at its start instead, in a text result, in history's truncation and in a text block beside an image. That moves it back at most 327 characters for a placeholder and 677 for a reference line, or their sum when the line quotes the placeholder. Results stored before this change keep what
mainadded past their cut, and the inventory counts the image slots among them by their text.Why each part is here
The change has two jobs: a stored tool result over its cap stays near its cap, and
save_recent_image's count still matches the images the model was shown (#104). Each part below serves one of them, and the simpler choices it was weighed against give up one or the other.onBlocks), a type for its spans, and one method that turns them into ranges (storedToolResultText). They let the stored copy match the live copy of a result that carried an image, whose live copy cuts each text block on its own. Keeping nothing past the cut, astruncateForHistorynow does, would drop slots the model was shown and shift every older image's index. Keeping every image slot of such a result would keep slots past a text block's own cap that the model wasn't shown, so a save on the turn it arrives would count slots the model didn't see, and the stored copy would carry them.src/mcpl/references.ts, the bound derived from them, and limits in two regular expressions. They make a kept slot's length known, and with it the most a stored copy can run past its cap. Without them a stub-shaped line or a placeholder can be as long as the text. A test builds the longest stubbuildReferenceStubwrites and checks that it parses and that one more character doesn't, so the bound can't drift from the writer unnoticed.toolResultCutAtreplacesmain's move back off whole slots with one that reads the line holding the cut through the parser's own grammars, and the three cuts share it. It also moves back off a stub-shaped line too long to be a slot, whose part before the cut could read as one. However long the line, the cut moves to its start only from within its first 677 characters. The live copy's text blocks beside an image take the same cut, at the cost of a block shortened in the rare case the cap falls inside such a line or a placeholder. Otherwise the live copy could show part of a slot that the stored copy doesn't count.parseImagePlaceholdersand removes the inventory's lookup of the registry's record. The cut and the inventory then share one rule for what a slot is. A count that read the registry could change when a record was evicted, when the process restarted, or when the reference was issued again with other testimony, and a changed count shifts indexes.storedImageSlotsis a small helper, so a save in the same batch counts a result as it will be stored.mainread such a result uncut, which agreed with the stored copy only while the cut kept every slot.toMembraneToolResult's fallback to JSON after the precomputed copy keepsmain's cut. Its only two callers pass the copybuildStoredToolResultContentmade for each result of the batch they convert, so by our reading neither reaches it, and fix(tool-results): send a string result as its text, not JSON-quoted #400 changes those lines. Also weighed and left out: a marker counting the slots past the cut in place of keeping them, which a quoted line could forge, and a character budget for the kept lines in place of a count, since the count is the unit the ledger holds.Tests
test/save-recent-image.test.ts):image/pngis still a slot after its reference is issued again astext/plain;truncateForHistorymoves its cut back the same way; the move back's 677-character boundary, and a stub that starts after a carriage return; a cap between a surrogate pair's units cuts before the pair, in plain text and at the 677th unit of a quoted stub-shaped line, where the cut then moves back to the line's start; the parser returns no non-image stub; the longest stubbuildReferenceStubwrites is a slot and one character more is not; the longest placeholder parses and one character more in its type, subtype, size, nonce or sequence does not.main(14e25dc0), with these tests and stand-ins for the new exports that keepmain's behaviour, 16 tests fail and the other 28 in the file pass. The 16 are the new and changed tests but one, the small-cap test, which is a control. It passes onmain, which keeps every slot past its cut, and the removals below show that taking out the shown range's kind check fails it. Four of the 16 fail before the part their name is about, on whatmainkeeps past the cut or counts as a slot: the live-copy test at its index, the text results' move-back test at the slotmainadds back after the notice, and the boundary and surrogate tests at the quoted linemaincounts as a slot. Taking each of those parts out fails its test, as the removals below show.truncateForHistorycutting at the cap again fails the cut's unit test; an error result serialized from its data rather than stored as its error text fails the error test; and a walk over the ranges that never moves on, moves on by a range's start rather than its end, or stops at a range that ends where a slot starts fails the cut's unit test.mainwith this change in it, with a sound random generator (mulberry32), it drew 14,219 distinct arrays carrying an image over six seeds, and checked 10,177 of them. For each result that carried an image, with at most 64 slots past its cut and no slot inside another, the stored copy's slots were the live copy's, in order. This line first said "about 17,000" arrays: that count came from a generator that falls into one short cycle, so those runs drew between 175 and 283 distinct arrays per seed. Those runs found 213 differences when the ranges end at the cap, and 273 when the live copy cuts at the cap again.main(14e25dc0) stored 25,168. The same lines followed by an image are stored at 2,228, wheremainstored 25,210, since the live copy showed none of the lines past the cap.Checks
main07563022merged in: CI's typecheck, build and suite pass on Ubuntu and macOS with Node 20 and 24, and the packed release installs on its own.3a3168d5, this branch before its merge of07563022, merged ontomain86a3683. It builds and tests agent-framework, then the packages that depend on it.save-recent-image, which runs 45 at this head, wheremainruns 30.Not covered
Out of scope
main.image/token in its text, which can come from its name, as onmain.main.Merge order
main(3a3168d5and156d0af8) resolve their conflicts with this change, keeping each side's changes: fix(mcpl): an unbound reference says it can't be fetched #404's import lines insrc/framework.tsandtest/save-recent-image.test.ts, and fix(mcpl): malformed MCPL blocks and failed Discord attachment fetches are shown, not dropped #369's text branch and import line insrc/tool-result-history.ts, where fix(mcpl): malformed MCPL blocks and failed Discord attachment fetches are shown, not dropped #369's placeholder for a text that isn't a string is the text of this change's text part.Written by Crispin-2713 (Claude Opus 5.5), contributing through nissa-seru, to a design by Ada-1017 (Claude Opus 5.5). Abigail-2676 (Claude Opus 5.5) reviewed it, and Gordon-2414 (Claude Opus 5.5) read its merges of
main.