Skip to content

fix(tool-results): a cut keeps past it only the image slots the model was shown - #440

Merged
nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/403-truncation-image-slots
Oct 18, 2026
Merged

nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/403-truncation-image-slots

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

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.ts on main) added back, after the truncation notice, every slot that parseImagePlaceholders found at or past the cut. The slots were kept so that save_recent_image's index counts every image the resident saw (#104), since the stored text is the only place its inventory finds tool images. But parseImagePlaceholders returned every [ref_…] … line, whether or not it named an image, and nothing bounded how many lines were kept or how long each was. With toolResultInlineMaxChars: 2000 and no workspace, main stored 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, toMembraneToolResult sends 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. tryNativeToolResultContent sends 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

  • The stored copy keeps what the live copy showed. The serializer now reports where each block of a content array sits in the text it writes (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.
  • At most 64 are kept. A result that carried an image keeps at most the newest 64 of those slots. The bound is the tool-image ledger's entry limit, the most images it holds bytes for (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, and save_recent_image doesn'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.
  • The cut never leaves part of a slot. 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 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 most REFERENCE_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 on main. 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.
  • Only image slots count, and a slot is no longer than the framework writes one. parseImagePlaceholders returns only image slots: inline image placeholders, and reference stubs whose text shows an image/ type. A stub-shaped line longer than 677 characters, the sum of buildReferenceStub's own bounds, is not a slot. PLACEHOLDER_RE bounds 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. normalizeMediaType falls back to application/octet-stream for 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.
  • A reference stub counts by what its text shows. On main the 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.
  • A save in the same batch counts a result as its stored copy will. It reads the text the commit path stores, cut at the agent's inline cap as spillOrTruncate cuts 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.
  • truncateForHistory keeps 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, which main added 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 main added 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.

  • The shown ranges cost an option on the serializer (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, as truncateForHistory now 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.
  • The 64 is one constant and the note's second form. Without a bound, the lines kept past the cap grow with a result's images. What it costs is under "Not covered".
  • The bounded grammars are two named constants in 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 stub buildReferenceStub writes and checks that it parses and that one more character doesn't, so the bound can't drift from the writer unnoticed.
  • toolResultCutAt replaces main'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.
  • Counting only image slots, read from the stored text, takes a few lines in parseImagePlaceholders and 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.
  • storedImageSlots is a small helper, so a save in the same batch counts a result as it will be stored. main read such a result uncut, which agreed with the stored copy only while the cut kept every slot.
  • Left out: toMembraneToolResult's fallback to JSON after the precomputed copy keeps main's cut. Its only two callers pass the copy buildStoredToolResultContent made 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

  • New (test/save-recent-image.test.ts):
    • a text result over the cap keeps nothing after the notice, and neither does a content array of text blocks alone, so index 0 reaches the older attachment;
    • a result of 200 attachment lines, 100 of them images, then an image, keeps past its cut only the image, the one slot there its live copy showed, so index 0 is the image and index 1 the older attachment;
    • a result of 1,500 characters, 100 image reference blocks and an image keeps the newest 63 stubs and the image, with the note's counts, and index 64 reaches the older attachment;
    • a result carrying an image keeps an attachment line in a later text block within that block's own cap, and not one past it;
    • an array with a block of no known shape beside an image keeps nothing past its cut, before the commit and after it;
    • the live copy cuts a text block beside an image before a quoted reference line the cap falls in, and index 0 is the picture, index 1 the older attachment;
    • at an inline cap of 100, below a reference stub's length, the stub the live copy showed whole beside a picture is kept past the cut, so index 1 is the picture and index 2 the older attachment;
    • text results whose cap falls inside an attachment line, or 499 characters into a quoted stub-shaped line too long to be a slot, move the cut back to the line's start, and 1,000 characters into that line the cut stays at the cap; a save in the batch with each, and one after the commit, reaches the older attachment;
    • a save in the same batch counts a cut text result, a cut result carrying an image, and one past 64 slots as each counts after the commit;
    • a same-batch error result carrying data beside its error, and a result with no data, count the placeholder their error text quotes, before the commit and after it;
    • a reference stub that showed image/png is still a slot after its reference is issued again as text/plain;
    • unit tests: the serializer's block spans tile its text, and it reports none when it gives an array up; the cut, at every position in a mixed text, keeps whole slots only, and with the kept ones exactly the text's; a slot outside the shown ranges, or partly outside them, isn't kept, and one starting where a range ends is kept by a range that starts there; truncateForHistory moves 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 stub buildReferenceStub writes 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.
  • Changed: the unit test of the old split, and the parser's test, which expected a non-image stub with a null type.
  • Red-first: on main (14e25dc0), with these tests and stand-ins for the new exports that keep main'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 on main, 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 what main keeps past the cut or counts as a slot: the live-copy test at its index, the text results' move-back test at the slot main adds back after the notice, and the boundary and surrogate tests at the quoted line main counts as a slot. Taking each of those parts out fails its test, as the removals below show.
  • Removals: each of 38 pieces, taken out or inverted on its own, fails at least one test aimed at it, and the head passes 44 of 44 before and after. Among them, the live copy cutting its text blocks at the cap again, or a shown range ending at the cap rather than where the live copy cut, fails the live-copy test; a shown range cut for every kind of block rather than text alone fails the small-cap test; settling a surrogate pair after the moves back, or not at all, fails the surrogate test; reading the cap's line only up to the cut fails the cut's unit test and its boundary test; truncateForHistory cutting 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.
  • A differential probe, Abigail-2676's, set the stored copy against the live copy over generated arrays of text blocks, images and references, at caps from 10 to 500, with placeholders, stubs, long stub-shaped lines, emoji, and stubs after a carriage return or a U+2028 in their text. Rerun on October 19 against main with 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.
  • Speed: on 10,000 image reference stubs and one inline image past a cap of 2,000, the cut's median over five runs is 4 ms (Bun 1.4.3).
  • Size: with an inline cap of 2,000 and no workspace, the issue's 25,980-character text result is stored at 2,090 characters, where main (14e25dc0) stored 25,168. The same lines followed by an image are stored at 2,228, where main stored 25,210, since the live copy showed none of the lines past the cap.

Checks

  • At this head, with main 07563022 merged 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.
  • The house's cross-package check passed at 3a3168d5, this branch before its merge of 07563022, merged onto main 86a3683. It builds and tests agent-framework, then the packages that depend on it.
  • The suite: this change adds 15 tests to save-recent-image, which runs 45 at this head, where main runs 30.

Not covered

  • More than 64 image slots past one cut. When a result that carried inline images has more than 64 image slots past its cut that its live copy showed, the slots left out aren't counted, so every older image's index shifts by their number. On the turn the result arrives, the resident saw them, so a save by that turn's count can reach an older image than meant, an attachment or another tool result's image, and get its bytes if they're still held. When the slots past the cut are the result's own images, none of the images left out still has bytes. When image stubs or quoted placeholders follow the result's images past the cut, an image left out can still have bytes that no save reaches.
  • A text result's image stubs past the cut, with no workspace. They aren't kept after the notice in any case, since the resident never saw them. With no writable workspace, or a spill that fails, they aren't kept anywhere, like the rest of the text past the cut, as the notice says. Each reference's record is still in the registry, so running the tool again brings its stub back.

Out of scope

  • A live copy's total length: its text blocks beside an image are each cut at the cap on their own, so the live text of a result that carried images can exceed the cap in total, as on main.
  • A stub's type is the first image/ token in its text, which can come from its name, as on main.
  • A slot inside another slot's text, such as a stub-shaped line that quotes a placeholder, counts as two slots, and as three once both are kept past a cut, as on main.

Merge order

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.

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

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The changes since the previous review appear safe to merge.

Summary

Keeps only image slots the model saw past a stored tool result's cut, with a limit of 64, and avoids cutting through slots.

  • Stored tool results keep only image slots the live copy showed.
  • Tool-result cuts stop before they split an image slot.
  • Same-batch image saves use the inventory that storage will keep.

Reviews (2) · Last reviewed commit: "perf(tool-results): walk the slots and t..." · Reviewed by Greptile

Comment thread src/tool-image-ledger.ts Outdated
…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.
@nissa-seru nissa-seru added the no-greptile For maintainers and the team: Greptile skips this PR. Not for general use. label Oct 17, 2026
Crispin-2713 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
@nissa-seru
nissa-seru merged commit d6593a3 into anima-research:main Oct 18, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Truncation adds back every reference-stub-shaped line past the cut, so a text result can be stored far over the inline cap

1 participant